Files
breakpilot-compliance/backend-compliance/tests/test_navigator.py
T
Benjamin Admin 78aeedafae feat(navigator): Product Regulatory Navigator as a thin missing-facts layer
Step 2 of the convergence sequence. The Navigator sits over the
CanonicalProductRegulatoryProfile (prefilled from company-profile / ProductWizard)
and reports ONLY which facts are still missing + prioritized questions to collect
them. It decides which facts are needed, NEVER what applies — that stays with the
Scope Engine (step 3). No regulation logic, no UI, no Go, no RAG.

- NavigatorQuestion (interaction type, NOT a compliance-meta-model class — freeze
  v1.0 untouched): question_id, target_field, label, why_needed,
  regulatory_domains_unblocked (static metadata), answer_type, options, priority.
- QUESTION_CATALOG: 12 questions over canonical gaps — P0 (markets, role,
  lifecycle, machine/component), P1 (radio, usage-data, security-function,
  environmental wastewater/air/chemicals triggers), P2 (structured BOM).
- engine: navigate() -> missing_facts + suggested_questions (priority-sorted) +
  completeness_summary (ready_for_scope = no P0 missing); apply_answers() ->
  updated profile. Pure field-presence; no scope import.
- 8 tests: <=10 questions for a filled company-profile, known facts not re-asked,
  environmental = trigger questions only (no law evaluation), apply round-trip,
  P0 ordering, ready_for_scope. 41 tests green, mypy clean, LOC ok.

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

128 lines
4.6 KiB
Python

"""Tests for the Product Regulatory Navigator (missing-facts layer).
Acceptance: a well-filled company-profile yields <= 10 questions; known facts are
not re-asked; environmental questions are trigger-only (no law evaluation); the
Navigator decides which facts are missing, NOT what applies.
"""
from __future__ import annotations
from compliance.navigator import NavigatorResult, apply_answers, navigate
from compliance.navigator.questions import QUESTION_CATALOG, QuestionPriority
from compliance.profile import from_company_profile
from compliance.profile.canonical import CanonicalProductRegulatoryProfile, EconomicOperatorRole
COMPANY = {
"industry": "Maschinenbau",
"business_model": "B2B",
"company_size": "medium",
"target_markets": ["DE", "EU"],
"primary_jurisdiction": "DE",
"machine_builder": {
"productTypes": ["special_machine"],
"containsFirmware": True,
"hasSafetyFunction": True,
"isNetworked": True,
"hasRemoteAccess": True,
"hasOTAUpdates": True,
"hasRiskAssessment": True,
},
}
def _empty() -> CanonicalProductRegulatoryProfile:
return CanonicalProductRegulatoryProfile(name="X")
# 1. well-filled company-profile -> at most 10 questions.
def test_filled_company_profile_at_most_10_questions():
result = navigate(from_company_profile(COMPANY))
assert len(result.suggested_questions) <= 10
# 2. known facts (markets, is_machine) are not re-asked; true gaps still are.
def test_known_facts_not_reasked():
result = navigate(from_company_profile(COMPANY))
assert "markets" not in result.missing_facts
assert "is_machine" not in result.missing_facts
# genuine gaps the company-profile cannot provide are still surfaced
assert "economic_operator_role" in result.missing_facts
assert "has_radio_module" in result.missing_facts
# 3. environmental questions are trigger-only — no environmental-law evaluation.
def test_environmental_questions_are_triggers_only():
result = navigate(_empty())
env = [q for q in result.suggested_questions if q.target_field.startswith("environmental.")]
assert len(env) >= 3
assert all(q.answer_type.value == "bool" for q in env)
# 4. the Navigator decides only missing facts, never what applies.
def test_navigator_decides_only_missing_facts():
assert set(NavigatorResult.model_fields.keys()) == {
"missing_facts",
"suggested_questions",
"completeness_summary",
}
# no question carries a verdict — only metadata about what it would unblock
for q in QUESTION_CATALOG:
assert q.regulatory_domains_unblocked # metadata, not a decision
assert hasattr(q, "answer_type")
# 5. apply_answers updates the profile; answered facts drop out of missing.
def test_apply_answers_updates_profile():
profile = from_company_profile(COMPANY)
updated = apply_answers(
profile,
{
"economic_operator_role": "manufacturer",
"markets": ["DE", "US"],
"has_radio_module": True,
"env_wastewater": True,
},
)
assert updated.economic_operator_role == EconomicOperatorRole.MANUFACTURER
assert updated.markets == ["DE", "US"]
assert updated.has_radio_module is True
assert updated.environmental.discharges_to_wastewater is True
after = navigate(updated)
assert "economic_operator_role" not in after.missing_facts
assert "has_radio_module" not in after.missing_facts
assert "environmental.discharges_to_wastewater" not in after.missing_facts
# 6. questions are ordered P0 -> P1 -> P2.
def test_priority_ordering():
questions = navigate(_empty()).suggested_questions
orders = [q.order() for q in questions]
assert orders == sorted(orders)
assert questions[0].priority == QuestionPriority.P0
# 7. ready_for_scope flips once all P0 facts are answered.
def test_ready_for_scope_after_p0():
profile = _empty()
assert navigate(profile).completeness_summary.ready_for_scope is False
answered = apply_answers(
profile,
{
"markets": ["DE"],
"economic_operator_role": "manufacturer",
"lifecycle_phase": "placing_on_market",
"is_machine": True,
"is_component": False,
},
)
summary = navigate(answered).completeness_summary
assert summary.ready_for_scope is True
# 8. empty profile asks the full (bounded) catalog.
def test_empty_profile_bounded_catalog():
result = navigate(_empty())
assert len(result.suggested_questions) == len(QUESTION_CATALOG)
assert result.completeness_summary.total_relevant == len(QUESTION_CATALOG)