Files
breakpilot-compliance/backend-compliance/tests/test_product_scope.py
T
Benjamin Admin 4e8eb2dc0e feat(product-scope): gate Navigator facts, then reuse discover_scope (step 3)
Connects the Navigator's fact-gate to the existing reasoning discover_scope —
the Scope Engine decides only once the minimum (P0) facts are released.

- resolve_product_scope(canonical): if not ready_for_scope -> NEEDS_FACTS
  (missing_facts + suggested_questions, discover_scope NOT run); else project
  canonical->reasoning profile and run the EXISTING discover_scope exactly once
  -> RESOLVED with applicable/excluded/uncertain regulations.
- Environmental triggers surface ONLY as unsupported_domains (future_corpus_needed),
  never as a legal evaluation — transparency, no false completeness.
- POST /reasoning/product-scope (thin handler) returns case NEEDS_FACTS or RESOLVED.
- No new scope rules, no new regulations, no environmental-law evaluation, no UI,
  no Go, no RAG, no percent-compliance. Response types are application-level, not
  meta-model classes (freeze v1.0 untouched).
- 6 tests incl. discover_scope spy (0 calls when gated, exactly 1 when ready),
  category separation, environmental-as-unsupported-only. 47 tests green (existing
  reasoning MVP tests stay green), mypy clean, LOC ok.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:21:27 +02:00

150 lines
5.2 KiB
Python

"""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