diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14da2a9..1a70109 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,9 @@ on: push: pull_request: +permissions: + contents: read + jobs: cpp: runs-on: ${{ matrix.os }} @@ -12,7 +15,9 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Configure run: cmake -S . -B build -DCASTE_BUILD_TESTS=ON - name: Build @@ -28,22 +33,26 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} - name: Install and test run: | - python -m pip install -U pip cd python - python -m pip install -e ".[test]" - python -m pytest -q + python -m pip install --require-hashes -r constraints-test.txt + python -m pip install --no-build-isolation -e . + python -m unittest discover -s tests -p 'test_*.py' coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Configure (coverage) @@ -57,11 +66,11 @@ jobs: -DCMAKE_SHARED_LINKER_FLAGS="--coverage" - name: Build and test (coverage) run: | + python -m pip install "gcovr==7.0" cmake --build build-cov --config Debug ctest --test-dir build-cov -C Debug --output-on-failure - name: Generate coverage report run: | - python -m pip install -U pip gcovr gcovr \ --root . \ build-cov \ @@ -81,7 +90,7 @@ jobs: --output build-cov/coverage.xml \ --html-details build-cov/coverage.html - name: Upload coverage artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-report path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1194af4..af92bde 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,9 @@ on: tags: - "v*" +permissions: + contents: read + jobs: cpp-artifacts: runs-on: ${{ matrix.os }} @@ -13,7 +16,9 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Configure run: cmake -S . -B build -DCASTE_BUILD_TESTS=OFF - name: Build @@ -27,7 +32,7 @@ jobs: cd stage tar -czf "../${name}.tar.gz" . - name: Upload workflow artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: cpp-artifact-${{ runner.os }} path: caste-*.tar.gz @@ -39,29 +44,32 @@ jobs: contents: write steps: - name: Download cpp artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: cpp-artifact-* merge-multiple: true path: release-assets - name: Upload release assets - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 with: files: release-assets/caste-*.tar.gz - pypi: + pypi-build: runs-on: ubuntu-latest - permissions: - id-token: write - contents: read + env: + PIP_CONSTRAINT: ${{ github.workspace }}/python/constraints-ci.txt + PIP_REQUIRE_HASHES: "1" + CIBW_ENVIRONMENT: PIP_CONSTRAINT=/project/python/constraints-ci.txt PIP_REQUIRE_HASHES=1 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Build sdist and wheels run: | - python -m pip install -U pip build cibuildwheel + python -m pip install --require-hashes -r python/constraints-ci.txt CASTE_VERSION="$(python - <<'PY' import tomllib with open("python/pyproject.toml", "rb") as f: @@ -83,7 +91,7 @@ jobs: ./ .pypi-stage/ cat > .pypi-stage/pyproject.toml << EOF [build-system] - requires = ["scikit-build-core>=0.10.0", "pybind11>=2.11.0"] + requires = ["scikit-build-core==0.10.0", "pybind11==2.11.0"] build-backend = "scikit_build_core.build" [project] @@ -92,7 +100,7 @@ jobs: description = "Opinionated hardware classification library." readme = "python/README.md" requires-python = ">=3.8" - license = "MIT" + license = { text = "MIT" } authors = [{ name = "Zeth", email = "holdercardteam+caste@gmail.com" }] keywords = ["hardware", "classification", "system-info", "gpu", "cpu"] @@ -100,9 +108,6 @@ jobs: Repository = "https://github.com/zeth/caste" Issues = "https://github.com/zeth/caste/issues" - [project.optional-dependencies] - test = ["pytest"] - [tool.scikit-build] cmake.version = ">=3.20" cmake.source-dir = "python" @@ -113,7 +118,25 @@ jobs: EOF python -m build --sdist --outdir dist .pypi-stage python -m cibuildwheel --output-dir dist .pypi-stage + - name: Upload PyPI workflow artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: pypi-dist + path: dist/* + + pypi-publish: + runs-on: ubuntu-latest + needs: pypi-build + permissions: + id-token: write + contents: read + steps: + - name: Download PyPI workflow artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: pypi-dist + path: dist - name: Upload to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@106e0b0b7c337fa67ed433972f777c6357f78598 # v1.13.0 with: packages-dir: dist diff --git a/.github/workflows/testpypi.yml b/.github/workflows/testpypi.yml index 8635b89..e6023d8 100644 --- a/.github/workflows/testpypi.yml +++ b/.github/workflows/testpypi.yml @@ -3,20 +3,26 @@ name: TestPyPI on: workflow_dispatch: +permissions: + contents: read + jobs: - pypi-test: + pypi-test-build: runs-on: ubuntu-latest - permissions: - id-token: write - contents: read + env: + PIP_CONSTRAINT: ${{ github.workspace }}/python/constraints-ci.txt + PIP_REQUIRE_HASHES: "1" + CIBW_ENVIRONMENT: PIP_CONSTRAINT=/project/python/constraints-ci.txt PIP_REQUIRE_HASHES=1 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Build sdist and wheels run: | - python -m pip install -U pip build cibuildwheel + python -m pip install --require-hashes -r python/constraints-ci.txt CASTE_VERSION="$(python - <<'PY' import tomllib with open("python/pyproject.toml", "rb") as f: @@ -38,7 +44,7 @@ jobs: ./ .pypi-stage/ cat > .pypi-stage/pyproject.toml << EOF [build-system] - requires = ["scikit-build-core>=0.10.0", "pybind11>=2.11.0"] + requires = ["scikit-build-core==0.10.0", "pybind11==2.11.0"] build-backend = "scikit_build_core.build" [project] @@ -47,7 +53,7 @@ jobs: description = "Opinionated hardware classification library." readme = "python/README.md" requires-python = ">=3.8" - license = "MIT" + license = { text = "MIT" } authors = [{ name = "Zeth", email = "holdercardteam+caste@gmail.com" }] keywords = ["hardware", "classification", "system-info", "gpu", "cpu"] @@ -55,9 +61,6 @@ jobs: Repository = "https://github.com/zeth/caste" Issues = "https://github.com/zeth/caste/issues" - [project.optional-dependencies] - test = ["pytest"] - [tool.scikit-build] cmake.version = ">=3.20" cmake.source-dir = "python" @@ -68,8 +71,26 @@ jobs: EOF python -m build --sdist --outdir dist .pypi-stage python -m cibuildwheel --output-dir dist .pypi-stage + - name: Upload TestPyPI workflow artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: testpypi-dist + path: dist/* + + pypi-test-publish: + runs-on: ubuntu-latest + needs: pypi-test-build + permissions: + id-token: write + contents: read + steps: + - name: Download TestPyPI workflow artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: testpypi-dist + path: dist - name: Upload to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@106e0b0b7c337fa67ed433972f777c6357f78598 # v1.13.0 with: repository-url: https://test.pypi.org/legacy/ packages-dir: dist diff --git a/CMakeLists.txt b/CMakeLists.txt index 1fab856..ffa7a27 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,7 +61,8 @@ if (CASTE_BUILD_TESTS) FetchContent_Declare( Catch2 GIT_REPOSITORY https://github.com/catchorg/Catch2.git - GIT_TAG v3.5.4 + GIT_TAG abb467ecd60fae9a727afca033c1eb5d20af2c12 # v3.5.4 + GIT_SHALLOW TRUE ) FetchContent_MakeAvailable(Catch2) endif() diff --git a/README.md b/README.md index 635e3c8..096ec83 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,38 @@ auto result = detect_caste(); // result.reason is a short string (optional) ``` +If you want to use the detected caste in policy code, +`caste` supports direct comparisons: + +```cpp +Caste caste = detect_caste().caste; + +if (caste >= Caste::Developer) { + // developer, workstation, or rig +} +``` + +You can also define reusable ranges: + +```cpp +constexpr CasteRange user_or_below{ + Caste::Mini, + Caste::User +}; + +if (user_or_below.contains(caste)) { + // mini or user +} +``` + +Common policy bands are also provided directly: + +```cpp +if (dev_or_above.contains(caste)) { + // developer, workstation, or rig +} +``` + If you only want the single-word label: ```cpp diff --git a/python/constraints-ci.txt b/python/constraints-ci.txt new file mode 100644 index 0000000..ba29b59 --- /dev/null +++ b/python/constraints-ci.txt @@ -0,0 +1,48 @@ +# Pinned Python build and release toolchain for CI/publish workflows. +# Install with: +# python -m pip install --require-hashes -r python/constraints-ci.txt + +scikit-build-core==0.10.0 \ + --hash=sha256:d6ba451a84da6515b696d1aaecc935ce4e7a28ed18a8093d6effa2726d50e3fe +pybind11==2.11.0 \ + --hash=sha256:307443ea89b73ce88f68fa48687d160c036622a54bc2a25aae9d5ea792bef268 +packaging==26.0 \ + --hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529 +pathspec==1.0.4 \ + --hash=sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723 + +build==1.4.2 \ + --hash=sha256:7a4d8651ea877cb2a89458b1b198f2e69f536c95e89129dbf5d448045d60db88 +pyproject-hooks==1.2.0 \ + --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 +wheel==0.46.3 \ + --hash=sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d +tomli==2.3.0 ; python_version < "3.11" \ + --hash=sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549 +importlib-metadata==8.7.0 ; python_full_version < "3.10.2" \ + --hash=sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd +zipp==3.23.0 ; python_full_version < "3.10.2" \ + --hash=sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e +colorama==0.4.6 ; os_name == "nt" \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + +cibuildwheel==3.4.1 \ + --hash=sha256:d495eb8780473029382976e86363562923401ac575c18bc52e6a9102512853ad +bashlex==0.18 \ + --hash=sha256:91d73a23a3e51711919c1c899083890cdecffc91d8c088942725ac13e9dcfffa +bracex==2.6 \ + --hash=sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952 +certifi==2026.2.25 \ + --hash=sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa +dependency-groups==1.3.1 \ + --hash=sha256:51aeaa0dfad72430fcfb7bcdbefbd75f3792e5919563077f30bc0d73f4493030 +filelock==3.25.2 \ + --hash=sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70 +humanize==4.15.0 \ + --hash=sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769 +patchelf==0.17.2.4 ; (sys_platform == "linux" or sys_platform == "darwin") and (platform_machine == "x86_64" or platform_machine == "arm64" or platform_machine == "aarch64") \ + --hash=sha256:d9b35ebfada70c02679ad036407d9724ffe1255122ba4ac5e4be5868618a5689 +platformdirs==4.9.4 \ + --hash=sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868 +pyelftools==0.32 \ + --hash=sha256:013df952a006db5e138b1edf6d8a68ecc50630adbd0d83a2d41e7f846163d738 diff --git a/python/constraints-test.txt b/python/constraints-test.txt new file mode 100644 index 0000000..5820f0e --- /dev/null +++ b/python/constraints-test.txt @@ -0,0 +1,18 @@ +# Pinned Python test/build toolchain for the CI matrix. +# Install with: +# python -m pip install --require-hashes -r python/constraints-test.txt + +scikit-build-core==0.10.0 \ + --hash=sha256:d6ba451a84da6515b696d1aaecc935ce4e7a28ed18a8093d6effa2726d50e3fe +pybind11==2.11.0 \ + --hash=sha256:307443ea89b73ce88f68fa48687d160c036622a54bc2a25aae9d5ea792bef268 +packaging==26.0 \ + --hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529 +pathspec==1.0.4 \ + --hash=sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723 +exceptiongroup==1.3.1 ; python_version < "3.11" \ + --hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598 +typing-extensions==4.15.0 ; python_version < "3.11" \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 +tomli==2.3.0 ; python_version < "3.11" \ + --hash=sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549 diff --git a/python/pyproject.toml b/python/pyproject.toml index 2de485b..d7b472d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.4" description = "Opinionated hardware classification library." readme = "README.md" requires-python = ">=3.8" -license = "MIT" +license = { text = "MIT" } authors = [{ name = "Zeth", email = "holdercardteam+caste@gmail.com" }] keywords = ["hardware", "classification", "system-info", "gpu", "cpu"] @@ -16,9 +16,6 @@ keywords = ["hardware", "classification", "system-info", "gpu", "cpu"] Repository = "https://github.com/zeth/caste" Issues = "https://github.com/zeth/caste/issues" -[project.optional-dependencies] -test = ["pytest"] - [tool.scikit-build] cmake.version = ">=3.20" cmake.source-dir = "." diff --git a/python/tests/test_caste.py b/python/tests/test_caste.py index 37ed742..b6406fb 100644 --- a/python/tests/test_caste.py +++ b/python/tests/test_caste.py @@ -1,30 +1,34 @@ -import caste +import unittest +import caste -def test_detect_caste_word(): - word = caste.detect_caste_word() - assert word in {"Mini", "User", "Developer", "Workstation", "Rig"} +class CasteTests(unittest.TestCase): + def test_detect_caste_word(self) -> None: + word = caste.detect_caste_word() + self.assertIn(word, {"Mini", "User", "Developer", "Workstation", "Rig"}) -def test_detect_caste_tuple(): - word, reason = caste.detect_caste() - assert word in {"Mini", "User", "Developer", "Workstation", "Rig"} - assert isinstance(reason, str) + def test_detect_caste_tuple(self) -> None: + word, reason = caste.detect_caste() + self.assertIn(word, {"Mini", "User", "Developer", "Workstation", "Rig"}) + self.assertIsInstance(reason, str) + def test_detect_hw_facts_shape(self) -> None: + facts = caste.detect_hw_facts() + self.assertIsInstance(facts, dict) + self.assertGreaterEqual(facts["ram_bytes"], 0) + self.assertIsInstance(facts["physical_cores"], int) + self.assertIsInstance(facts["logical_threads"], int) + self.assertIn(facts["gpu_kind"], {0, 1, 2, 3}) + self.assertGreaterEqual(facts["vram_bytes"], 0) + self.assertIsInstance(facts["has_discrete_gpu"], bool) + self.assertIsInstance(facts["is_apple_silicon"], bool) + self.assertIsInstance(facts["is_intel_arc"], bool) -def test_detect_hw_facts_shape(): - facts = caste.detect_hw_facts() - assert isinstance(facts, dict) - assert facts["ram_bytes"] >= 0 - assert isinstance(facts["physical_cores"], int) - assert isinstance(facts["logical_threads"], int) - assert facts["gpu_kind"] in {0, 1, 2, 3} - assert facts["vram_bytes"] >= 0 - assert isinstance(facts["has_discrete_gpu"], bool) - assert isinstance(facts["is_apple_silicon"], bool) - assert isinstance(facts["is_intel_arc"], bool) + def test_version_string(self) -> None: + self.assertIsInstance(caste.__version__, str) + self.assertTrue(caste.__version__) -def test_version_string(): - assert isinstance(caste.__version__, str) - assert caste.__version__ +if __name__ == "__main__": + unittest.main() diff --git a/src/caste.hpp b/src/caste.hpp index cd3889e..8eb851e 100644 --- a/src/caste.hpp +++ b/src/caste.hpp @@ -11,6 +11,56 @@ enum class Caste { Rig }; +constexpr int caste_rank(Caste caste) { + return static_cast(caste); +} + +constexpr bool operator>=(Caste a, Caste b) { + return caste_rank(a) >= caste_rank(b); +} + +constexpr bool operator<=(Caste a, Caste b) { + return caste_rank(a) <= caste_rank(b); +} + +constexpr bool operator>(Caste a, Caste b) { + return caste_rank(a) > caste_rank(b); +} + +constexpr bool operator<(Caste a, Caste b) { + return caste_rank(a) < caste_rank(b); +} + +constexpr bool caste_at_least(Caste actual, Caste minimum) { + return actual >= minimum; +} + +constexpr bool caste_at_most(Caste actual, Caste maximum) { + return actual <= maximum; +} + +struct CasteRange { + Caste min; + Caste max; + + constexpr Caste lower() const { + return min <= max ? min : max; + } + + constexpr Caste upper() const { + return min <= max ? max : min; + } + + constexpr bool contains(Caste caste) const { + return caste >= lower() && caste <= upper(); + } +}; + +constexpr CasteRange user_or_below{Caste::Mini, Caste::User}; +constexpr CasteRange user_or_above{Caste::User, Caste::Rig}; +constexpr CasteRange dev_or_below{Caste::Mini, Caste::Developer}; +constexpr CasteRange dev_or_above{Caste::Developer, Caste::Rig}; + enum class GpuKind { None, Integrated, // Intel UHD/Iris Xe, AMD iGPU, etc. (shared memory) diff --git a/tests/test_classify.cpp b/tests/test_classify.cpp index 3ecbed7..7272936 100644 --- a/tests/test_classify.cpp +++ b/tests/test_classify.cpp @@ -80,6 +80,82 @@ TEST_CASE("Caste names are stable") { REQUIRE(std::string(caste_name(Caste::Rig)) == "Rig"); } +TEST_CASE("Castes support direct ordered comparison") { + REQUIRE(Caste::Mini < Caste::User); + REQUIRE(Caste::User < Caste::Developer); + REQUIRE(Caste::Developer < Caste::Workstation); + REQUIRE(Caste::Workstation < Caste::Rig); + + REQUIRE(Caste::Rig > Caste::Workstation); + REQUIRE(Caste::Workstation >= Caste::Developer); + REQUIRE(Caste::Developer >= Caste::Developer); + REQUIRE(Caste::User <= Caste::Developer); + REQUIRE(Caste::Mini <= Caste::Mini); +} + +TEST_CASE("Named caste comparison helpers match operator semantics") { + REQUIRE(caste_at_least(Caste::Developer, Caste::Developer)); + REQUIRE(caste_at_least(Caste::Workstation, Caste::Developer)); + REQUIRE(caste_at_least(Caste::Rig, Caste::Developer)); + REQUIRE_FALSE(caste_at_least(Caste::User, Caste::Developer)); + + REQUIRE(caste_at_most(Caste::Mini, Caste::User)); + REQUIRE(caste_at_most(Caste::User, Caste::User)); + REQUIRE_FALSE(caste_at_most(Caste::Developer, Caste::User)); +} + +TEST_CASE("Caste ranges support reusable policy buckets") { + constexpr CasteRange user_or_below{Caste::Mini, Caste::User}; + constexpr CasteRange dev_or_above{Caste::Developer, Caste::Rig}; + constexpr CasteRange dev_to_workstation{ + Caste::Developer, + Caste::Workstation + }; + + REQUIRE(user_or_below.contains(Caste::Mini)); + REQUIRE(user_or_below.contains(Caste::User)); + REQUIRE_FALSE(user_or_below.contains(Caste::Developer)); + + REQUIRE(dev_or_above.contains(Caste::Developer)); + REQUIRE(dev_or_above.contains(Caste::Workstation)); + REQUIRE(dev_or_above.contains(Caste::Rig)); + REQUIRE_FALSE(dev_or_above.contains(Caste::User)); + + REQUIRE(dev_to_workstation.contains(Caste::Developer)); + REQUIRE(dev_to_workstation.contains(Caste::Workstation)); + REQUIRE_FALSE(dev_to_workstation.contains(Caste::Rig)); +} + +TEST_CASE("Caste ranges normalize reversed endpoints") { + constexpr CasteRange rigs_down_to_user{Caste::Rig, Caste::User}; + + REQUIRE(rigs_down_to_user.lower() == Caste::User); + REQUIRE(rigs_down_to_user.upper() == Caste::Rig); + REQUIRE(rigs_down_to_user.contains(Caste::User)); + REQUIRE(rigs_down_to_user.contains(Caste::Developer)); + REQUIRE(rigs_down_to_user.contains(Caste::Workstation)); + REQUIRE(rigs_down_to_user.contains(Caste::Rig)); + REQUIRE_FALSE(rigs_down_to_user.contains(Caste::Mini)); +} + +TEST_CASE("Predefined caste ranges cover common policy bands") { + REQUIRE(user_or_below.contains(Caste::Mini)); + REQUIRE(user_or_below.contains(Caste::User)); + REQUIRE_FALSE(user_or_below.contains(Caste::Developer)); + + REQUIRE_FALSE(user_or_above.contains(Caste::Mini)); + REQUIRE(user_or_above.contains(Caste::User)); + REQUIRE(user_or_above.contains(Caste::Rig)); + + REQUIRE(dev_or_below.contains(Caste::Mini)); + REQUIRE(dev_or_below.contains(Caste::Developer)); + REQUIRE_FALSE(dev_or_below.contains(Caste::Workstation)); + + REQUIRE_FALSE(dev_or_above.contains(Caste::User)); + REQUIRE(dev_or_above.contains(Caste::Developer)); + REQUIRE(dev_or_above.contains(Caste::Rig)); +} + namespace { bool is_valid_caste(Caste c) { switch (c) {