"""IMP-33 u9 — Step 17 AI repair wiring tests (BLOCKED until IMP-34 + IMP-35). Covers: * :data:`OVERFLOW_CASCADE_ORDER` canonical order (4 stages). * :class:`OverflowCascadeStage` member values. * :data:`STEP17_AI_REPAIR_BLOCKED_REASON` constant value. * :func:`gather_step17_ai_repair_proposals` BLOCKED contract — every unit returns ``ai_called=False`` + ``skip_reason=STEP17_AI_REPAIR_BLOCKED_REASON`` + ``proposal=None`` regardless of provisional / label / route_hint. * Structural guarantee — the u9 module does NOT import :func:`src.phase_z2_ai_fallback.router.route_ai_fallback` or the ``anthropic`` SDK. Step 17 AI repair stays structurally blocked. """ from __future__ import annotations import ast from dataclasses import dataclass, field from pathlib import Path from src.phase_z2_ai_fallback import step17 as step17_mod from src.phase_z2_ai_fallback.step17 import ( OVERFLOW_CASCADE_ORDER, STEP17_AI_REPAIR_BLOCKED_REASON, OverflowCascadeStage, gather_step17_ai_repair_proposals, ) @dataclass class FakeUnit: label: str | None provisional: bool frame_template_id: str = "tmpl" frame_id: str = "fid" source_section_ids: list[str] = field(default_factory=lambda: ["s1"]) raw_content: str = "raw" v4_rank: int | None = 1 _ROUTE_HINTS: dict[str | None, str | None] = { "use_as_is": "direct_render", "light_edit": "deterministic_minor_adjustment", "restructure": "ai_adaptation_required", "reject": "design_reference_only", None: None, } def _route_for_label(label: str | None) -> str | None: return _ROUTE_HINTS.get(label) # ─── Stage / order constants ───────────────────────────────────────── def test_overflow_cascade_order_is_canonical(): assert OVERFLOW_CASCADE_ORDER == ( OverflowCascadeStage.DETERMINISTIC, OverflowCascadeStage.POPUP, OverflowCascadeStage.AI_REPAIR, OverflowCascadeStage.USER_OVERRIDE, ) def test_overflow_cascade_stage_string_values(): assert OverflowCascadeStage.DETERMINISTIC.value == "deterministic" assert OverflowCascadeStage.POPUP.value == "popup" assert OverflowCascadeStage.AI_REPAIR.value == "ai_repair" assert OverflowCascadeStage.USER_OVERRIDE.value == "user_override" def test_step17_blocked_reason_constant_value(): assert ( STEP17_AI_REPAIR_BLOCKED_REASON == "step17_ai_blocked_imp_34_35_prerequisites_missing" ) # ─── BLOCKED contract: every unit returns blocked record ───────────── def test_gather_returns_one_record_per_unit(): units = [ FakeUnit(label="restructure", provisional=True), FakeUnit(label="reject", provisional=False), FakeUnit(label="use_as_is", provisional=True), ] records = gather_step17_ai_repair_proposals(units, route_for_label=_route_for_label) assert len(records) == 3 def test_gather_records_blocked_skip_reason(): """Every record must carry the IMP-34/IMP-35 prerequisite block reason.""" units = [FakeUnit(label="restructure", provisional=True)] records = gather_step17_ai_repair_proposals(units, route_for_label=_route_for_label) assert records[0]["skip_reason"] == STEP17_AI_REPAIR_BLOCKED_REASON def test_gather_blocks_even_when_route_is_ai_adaptation_required(): """Provisional + ai_adaptation_required must NOT bypass the u9 block. Stage 2 contract: AI repair at Step 17 is blocked behind IMP-34 + IMP-35 regardless of V4 route hint. Only u8 (Step 12) is allowed to invoke AI today. """ units = [FakeUnit(label="restructure", provisional=True)] record = gather_step17_ai_repair_proposals( units, route_for_label=_route_for_label )[0] assert record["route_hint"] == "ai_adaptation_required" assert record["ai_called"] is False assert record["proposal"] is None assert record["skip_reason"] == STEP17_AI_REPAIR_BLOCKED_REASON def test_gather_blocks_reject_units_too(): """Reject units (design_reference_only) are also blocked at u9 — same reason.""" units = [FakeUnit(label="reject", provisional=False)] record = gather_step17_ai_repair_proposals( units, route_for_label=_route_for_label )[0] assert record["ai_called"] is False assert record["skip_reason"] == STEP17_AI_REPAIR_BLOCKED_REASON def test_gather_records_proposal_none_and_no_error(): units = [FakeUnit(label="restructure", provisional=True)] record = gather_step17_ai_repair_proposals( units, route_for_label=_route_for_label )[0] assert record["proposal"] is None assert record["error"] is None def test_gather_records_cascade_stage_is_ai_repair(): units = [FakeUnit(label="restructure", provisional=True)] record = gather_step17_ai_repair_proposals( units, route_for_label=_route_for_label )[0] assert record["cascade_stage"] == OverflowCascadeStage.AI_REPAIR.value def test_gather_preserves_unit_metadata(): units = [ FakeUnit( label="restructure", provisional=True, frame_template_id="frame_05_overview", source_section_ids=["s1", "s2"], ) ] record = gather_step17_ai_repair_proposals( units, route_for_label=_route_for_label )[0] assert record["unit_index"] == 0 assert record["frame_template_id"] == "frame_05_overview" assert record["source_section_ids"] == ["s1", "s2"] assert record["label"] == "restructure" assert record["provisional"] is True def test_gather_with_empty_units_returns_empty_list(): records = gather_step17_ai_repair_proposals([], route_for_label=_route_for_label) assert records == [] # ─── Structural guarantee: u9 must NOT import route_ai_fallback / anthropic ─ def _u9_imports() -> list[str]: src_path = Path(step17_mod.__file__) tree = ast.parse(src_path.read_text(encoding="utf-8")) imports: list[str] = [] for node in ast.walk(tree): if isinstance(node, ast.Import): imports.extend(alias.name for alias in node.names) elif isinstance(node, ast.ImportFrom): module = node.module or "" for alias in node.names: imports.append(f"{module}.{alias.name}") return imports def test_step17_module_does_not_import_route_ai_fallback(): """u9 must not be able to reach the u7 router — structural block.""" imports = _u9_imports() forbidden = { "src.phase_z2_ai_fallback.router.route_ai_fallback", "src.phase_z2_ai_fallback.router", } assert not any(imp in forbidden for imp in imports), imports assert not hasattr(step17_mod, "route_ai_fallback") def test_step17_module_does_not_import_anthropic(): """u9 must not reach the Anthropic SDK directly — AI=0 in this layer.""" imports = _u9_imports() leaked = [imp for imp in imports if imp.split(".", 1)[0] == "anthropic"] assert leaked == [], leaked def test_step17_module_does_not_import_ai_fallback_client(): """u9 must not instantiate the u4 client either.""" imports = _u9_imports() forbidden_prefixes = ("src.phase_z2_ai_fallback.client",) leaked = [ imp for imp in imports if imp.startswith(forbidden_prefixes) ] assert leaked == [], leaked