78aeedafae
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>
128 lines
4.6 KiB
Python
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)
|