"""IMP-35 (#64) u6 — Composition popup binding tests. Stage 2 binding contract (unit u6): ``bind_popup_display_strategy`` in ``src/phase_z2_composition.py`` is the composition-side binding that translates the unit-side marker (``has_popup`` + ``popup_escalation_plan``) stamped by the Step 17 POPUP gate (u5 in ``src/phase_z2_ai_fallback/step17.py``) into a deterministic zone payload structure that u7 wires into the renderer. Key invariants this file locks: 1. Strategy id is the catalog key (yaml is source of truth) — no hardcoded literal string drift between code and ``display_strategies.yaml``. 2. ``has_popup=False`` units bind to ``inline_full`` (no popup). 3. ``has_popup=True`` units bind to ``inline_preview_with_details`` (preview = excerpt from container px budget downstream; popup body holds the FULL original per CLAUDE.md 자세히보기 원칙). 4. ``popup_body_source`` is the FULL ``raw_content``, verbatim — u6 NEVER trims or summarizes (MDX 원문 무손실 보존, 오답노트 #5, IMPROVEMENT-REDESIGN.md §3.6 line 110). 5. ``detail_trigger.placement`` / ``label`` come from the catalog entry's ``detail_trigger`` block, not from code constants. 6. The popup-binding strategy MUST have ``preserves_original=True`` in the catalog (defensive yaml-drift guard). 7. No AI call. ``bind_popup_display_strategy`` is pure composition- side binding — feedback_ai_isolation_contract. Cross-references: - u3 router stub (``plan_details_popup_escalation``): tests/phase_z2/test_phase_z2_router_popup.py - u4 api_gated split-decision contract: tests/phase_z2_ai_fallback/test_step17.py - u5 Step 17 POPUP gate (stamps the marker u6 reads): tests/phase_z2/test_phase_z2_step17_popup_gate.py """ from __future__ import annotations from dataclasses import dataclass, field from typing import Optional import pytest from src.phase_z2_composition import ( DISPLAY_STRATEGIES, POPUP_BINDING_ESCALATED_STRATEGY_ID, POPUP_BINDING_NO_POPUP_STRATEGY_ID, bind_popup_display_strategy, ) # ─── Synthetic stubs ───────────────────────────────────────────────── @dataclass class _StubUnit: """Minimal duck-typed CompositionUnit for u6 binding tests. Mirrors only the fields ``bind_popup_display_strategy`` reads via getattr — keeps the test independent of the full CompositionUnit dataclass evolution (e.g., IMP-30 / IMP-48 axis additions). """ raw_content: str = "MOCK_ORIGINAL_CONTENT" has_popup: bool = False popup_escalation_plan: Optional[dict] = None def _stub_popup_plan(category: str = "structural_major_overflow") -> dict: """Mirror the shape ``plan_details_popup_escalation`` returns on a feasible escalation. u6 echoes this verbatim — no field is consumed here other than as a traceable payload.""" return { "action": "details_popup_escalation", "stub": True, "feasible": True, "category": category, "needs_split_decision": True, "rationale": "MOCK_RATIONALE", "mapping_source": "IMP-35 u3 plan_details_popup_escalation stub", } # ─── Catalog constants are catalog keys (no hardcoded drift) ───────── def test_popup_binding_strategy_ids_are_catalog_keys(): """u6 — both constants used by the binder must resolve against the yaml catalog. Defensive guard against catalog rename / removal.""" assert POPUP_BINDING_NO_POPUP_STRATEGY_ID in DISPLAY_STRATEGIES assert POPUP_BINDING_ESCALATED_STRATEGY_ID in DISPLAY_STRATEGIES def test_popup_binding_escalated_strategy_preserves_original_in_catalog(): """u6 — the escalated-path strategy MUST preserve original content in the catalog (yaml lock — MDX 원문 무손실 보존). If yaml drift ever flips this to False, the binder must surface the violation; this test locks the catalog side of that invariant.""" meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID] assert meta.get("preserves_original") is True, ( "Catalog entry for the popup-binding strategy must declare " "preserves_original=True (오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6)." ) def test_popup_binding_escalated_strategy_has_detail_trigger_in_catalog(): """u6 — the escalated-path strategy MUST declare a detail_trigger block with placement + label in the catalog. The binder reads from the yaml — no code-side string literal drift.""" meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID] trigger = meta.get("detail_trigger") assert isinstance(trigger, dict) assert trigger.get("placement"), ( "Catalog detail_trigger.placement must be non-empty so the binder " "can stamp a deterministic trigger position on the zone payload." ) assert trigger.get("label"), ( "Catalog detail_trigger.label must be non-empty so the binder " "can stamp a deterministic trigger identifier on the zone payload." ) # ─── has_popup=False path ──────────────────────────────────────────── def test_bind_returns_inline_full_when_unit_has_no_popup_marker(): """u6 — units that never went through the Step 17 POPUP gate carry has_popup=False. The binder returns the catalog ``inline_full`` strategy with no popup body / no detail trigger.""" unit = _StubUnit(raw_content="MOCK_BODY", has_popup=False) payload = bind_popup_display_strategy(unit) assert payload["display_strategy"] == POPUP_BINDING_NO_POPUP_STRATEGY_ID assert payload["popup_body_source"] is None assert payload["detail_trigger"] is None assert payload["has_popup"] is False assert payload["popup_escalation_plan"] is None # preserves_original mirrors the catalog inline_full entry. expected_preserves = bool( DISPLAY_STRATEGIES[POPUP_BINDING_NO_POPUP_STRATEGY_ID].get( "preserves_original" ) ) assert payload["preserves_original"] is expected_preserves def test_bind_default_when_unit_has_no_has_popup_attr_at_all(): """u6 — defensive default. Units that lack the ``has_popup`` attr entirely (e.g., third-party duck-typed stubs that don't carry the Step 17 marker) bind to the no-popup path. The getattr() default branch must hold.""" class _BareUnit: raw_content = "MOCK_BODY" payload = bind_popup_display_strategy(_BareUnit()) assert payload["display_strategy"] == POPUP_BINDING_NO_POPUP_STRATEGY_ID assert payload["has_popup"] is False assert payload["popup_body_source"] is None # ─── has_popup=True path ───────────────────────────────────────────── def test_bind_returns_inline_preview_with_details_when_has_popup_true(): """u6 — feasible POPUP gate escalation flips the binder onto the ``inline_preview_with_details`` strategy (preview = px-budget excerpt downstream; popup body holds FULL original).""" plan = _stub_popup_plan() unit = _StubUnit( raw_content="MOCK_BODY", has_popup=True, popup_escalation_plan=plan, ) payload = bind_popup_display_strategy(unit) assert payload["display_strategy"] == POPUP_BINDING_ESCALATED_STRATEGY_ID assert payload["has_popup"] is True assert payload["popup_escalation_plan"] is plan def test_bind_popup_body_source_is_full_raw_content_verbatim(): """u6 — popup body MUST be the FULL raw_content, byte-for-byte. The binder NEVER trims or summarizes (MDX 원문 무손실 보존 — 오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110). u7 composes the body preview from container px telemetry downstream.""" full_text = ( "## MOCK_SECTION_TITLE\n\n" "- bullet one with **bold** marker\n" "- bullet two with *italic* marker\n" "- bullet three trailing\n" "\n" "| col_a | col_b |\n| --- | --- |\n| MOCK | DATA |\n" ) unit = _StubUnit( raw_content=full_text, has_popup=True, popup_escalation_plan=_stub_popup_plan(), ) payload = bind_popup_display_strategy(unit) assert payload["popup_body_source"] == full_text # Verbatim guarantee — no length-trimming side channel. assert len(payload["popup_body_source"]) == len(full_text) def test_bind_detail_trigger_placement_and_label_come_from_catalog(): """u6 — detail_trigger.placement / label MUST be read from the yaml catalog entry's detail_trigger block, not from code constants. This test compares the binder output against a fresh catalog read so a catalog rename (e.g., placement: top-right → top-left) propagates automatically.""" catalog_trigger = ( DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID] .get("detail_trigger") or {} ) unit = _StubUnit( has_popup=True, popup_escalation_plan=_stub_popup_plan(), ) payload = bind_popup_display_strategy(unit) assert payload["detail_trigger"] == { "placement": catalog_trigger.get("placement"), "label": catalog_trigger.get("label"), } def test_bind_preserves_original_is_true_on_popup_path(): """u6 — the popup-binding strategy MUST surface preserves_original= True so downstream consumers can rely on the absolute user lock (오답노트 #5). The binder mirrors the catalog value (which the catalog-side test already locks).""" unit = _StubUnit( has_popup=True, popup_escalation_plan=_stub_popup_plan(), ) payload = bind_popup_display_strategy(unit) assert payload["preserves_original"] is True def test_bind_strategy_meta_is_the_full_catalog_entry(): """u6 — strategy_meta echoes the full catalog entry so downstream debug traces can self-explain without re-reading the yaml. Tests that the binder does not strip / re-shape the catalog dict.""" expected_meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID] unit = _StubUnit( has_popup=True, popup_escalation_plan=_stub_popup_plan(), ) payload = bind_popup_display_strategy(unit) assert payload["strategy_meta"] is expected_meta def test_bind_popup_escalation_plan_is_echoed_verbatim(): """u6 — the popup_escalation_plan from u5 is echoed verbatim onto the zone payload so downstream debug surfaces can trace WHICH router category triggered the escalation (structural_major_overflow vs tabular_overflow). Object identity is preserved (no dict copy).""" plan = _stub_popup_plan("tabular_overflow") unit = _StubUnit( has_popup=True, popup_escalation_plan=plan, ) payload = bind_popup_display_strategy(unit) assert payload["popup_escalation_plan"] is plan assert payload["popup_escalation_plan"]["category"] == "tabular_overflow" # ─── Defensive guards ─────────────────────────────────────────────── def test_bind_raises_when_strategy_id_missing_from_catalog(monkeypatch): """u6 defensive guard — if catalog drift removes the escalated strategy id, the binder must raise RuntimeError rather than silently falling back to a wrong strategy. Locks the "yaml is source of truth" invariant against accidental rename.""" drifted_catalog = { k: v for k, v in DISPLAY_STRATEGIES.items() if k != POPUP_BINDING_ESCALATED_STRATEGY_ID } monkeypatch.setattr( "src.phase_z2_composition.DISPLAY_STRATEGIES", drifted_catalog, ) unit = _StubUnit( has_popup=True, popup_escalation_plan=_stub_popup_plan(), ) with pytest.raises(RuntimeError, match="catalog drift"): bind_popup_display_strategy(unit) def test_bind_raises_when_escalated_strategy_loses_preserves_original( monkeypatch, ): """u6 defensive guard — if the catalog entry for the escalated strategy ever flips preserves_original to False (yaml drift), the binder must raise RuntimeError. The absolute user lock — MDX 원문 무손실 보존 — must NOT silently degrade through the binding layer.""" drifted_meta = { **DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID], "preserves_original": False, } drifted_catalog = { **DISPLAY_STRATEGIES, POPUP_BINDING_ESCALATED_STRATEGY_ID: drifted_meta, } monkeypatch.setattr( "src.phase_z2_composition.DISPLAY_STRATEGIES", drifted_catalog, ) unit = _StubUnit( has_popup=True, popup_escalation_plan=_stub_popup_plan(), ) with pytest.raises(RuntimeError, match="preserves_original"): bind_popup_display_strategy(unit) # ─── AI isolation contract (structural import lock) ───────────────── def test_composition_module_does_not_import_anthropic_or_route_ai_fallback(): """u6 — bind_popup_display_strategy MUST stay AI-free. Structural guard — composition module is allowed to consult the catalog and unit state, never the Anthropic SDK / route_ai_fallback path. This mirrors the import-isolation pattern locked by u5 tests in tests/phase_z2_ai_fallback/test_step17.py.""" import src.phase_z2_composition as composition_module source = composition_module.__file__ assert source is not None with open(source, encoding="utf-8") as f: text = f.read() assert "import anthropic" not in text assert "from anthropic" not in text assert "route_ai_fallback" not in text