"""Tests for the product-scope orchestrator (step 3). Acceptance: missing P0 facts -> discover_scope NOT run; ready -> run exactly once; response separates applicable/excluded/uncertain; environmental triggers appear only as unsupported_domain (future_corpus_needed), never as a legal evaluation. """ from __future__ import annotations import pytest from fastapi import FastAPI from fastapi.testclient import TestClient import compliance.product_scope.orchestrator as orch from compliance.product_scope import ScopeStatus, resolve_product_scope from compliance.profile.canonical import ( CanonicalLifecyclePhase, CanonicalProductRegulatoryProfile, CanonicalProductType, EconomicOperatorRole, EnvironmentalImpact, ) _KNOWN_REGS = {"CRA", "MaschinenVO", "RED", "EMV", "DataAct", "NIS2"} def ready_profile(**ov) -> CanonicalProductRegulatoryProfile: base = dict( name="Industriespülmaschine", product_type=CanonicalProductType.MACHINERY, markets=["EU", "DE"], economic_operator_role=EconomicOperatorRole.MANUFACTURER, lifecycle_phase=CanonicalLifecyclePhase.PLACING_ON_MARKET, is_machine=True, is_component=False, has_software_updates=True, has_embedded_software=True, has_remote_access=True, has_safety_function=True, technologies=["cloud", "ota_updates"], ) base.update(ov) return CanonicalProductRegulatoryProfile(**base) def _spy(monkeypatch): calls = {"n": 0} real = orch.discover_scope def counting(profile): calls["n"] += 1 return real(profile) monkeypatch.setattr(orch, "discover_scope", counting) return calls # 1. missing P0 facts -> discover_scope is NOT executed. def test_needs_facts_does_not_run_scope(monkeypatch): calls = _spy(monkeypatch) resp = resolve_product_scope(CanonicalProductRegulatoryProfile(name="X")) assert resp.status == ScopeStatus.NEEDS_FACTS assert resp.regulatory_scope is None assert resp.missing_facts assert calls["n"] == 0 # 2. ready_for_scope -> discover_scope runs exactly once. def test_ready_runs_scope_once(monkeypatch): calls = _spy(monkeypatch) resp = resolve_product_scope(ready_profile()) assert resp.status == ScopeStatus.RESOLVED assert resp.regulatory_scope is not None assert calls["n"] == 1 applicable = {r.regulation_id for r in resp.regulatory_scope.applicable_regulations} assert "CRA" in applicable and "MaschinenVO" in applicable # 3. the response separates the regulation categories. def test_response_separates_categories(): scope = resolve_product_scope(ready_profile()).regulatory_scope assert scope is not None # all three buckets exist and only carry known regulation ids for bucket in (scope.applicable_regulations, scope.excluded_regulations, scope.uncertain_regulations): for r in bucket: assert r.regulation_id in _KNOWN_REGS assert scope.uncertain_regulations # e.g. RED/DataAct/NIS2 with unknown facts # 4. environmental triggers surface ONLY as unsupported_domain, never as law. def test_environmental_only_unsupported_domain(): profile = ready_profile( environmental=EnvironmentalImpact(discharges_to_wastewater=True, uses_cleaning_chemicals=True) ) scope = resolve_product_scope(profile).regulatory_scope assert scope is not None domains = {d.domain for d in scope.unsupported_domains} assert "environment_water" in domains and "chemicals" in domains assert all(d.status == "future_corpus_needed" for d in scope.unsupported_domains) # no environmental "regulation" leaked into the scope verdict all_regs = ( scope.applicable_regulations + scope.excluded_regulations + scope.uncertain_regulations ) assert all(r.regulation_id in _KNOWN_REGS for r in all_regs) # 5. endpoint smoke — both cases. @pytest.fixture(scope="module") def client(): from compliance.api.reasoning_routes import router app = FastAPI() app.include_router(router) return TestClient(app) def test_endpoint_needs_facts(client): r = client.post("/reasoning/product-scope", json={"product_profile": {"name": "X"}}) assert r.status_code == 200 body = r.json() assert body["status"] == "needs_facts" assert body["regulatory_scope"] is None assert body["missing_facts"] def test_endpoint_resolved(client): r = client.post( "/reasoning/product-scope", json={ "product_profile": { "name": "M", "product_type": "machinery", "markets": ["EU"], "economic_operator_role": "manufacturer", "lifecycle_phase": "placing_on_market", "is_machine": True, "is_component": False, "has_software_updates": True, "has_embedded_software": True, "has_remote_access": True, "technologies": ["cloud"], } }, ) assert r.status_code == 200 body = r.json() assert body["status"] == "resolved" applicable = {x["regulation_id"] for x in body["regulatory_scope"]["applicable_regulations"]} assert "CRA" in applicable and "MaschinenVO" in applicable