"""IMP-35 (#64) u9 — display_strategies.yaml popup-wiring catalog tests. Stage 2 binding contract (unit u9): ``templates/phase_z2/regions/display_strategies.yaml`` is the source of truth for the popup-wiring axis. u9 adds two strategy-level fields: preview_chars : int | null Soft char budget for the inline body shown alongside the popup trigger. ``null`` when the strategy has no popup (``inline_full``, ``dropped``). For popup-bearing strategies the value is the soft budget for the INLINE preview / summary surface only — the popup body itself ALWAYS holds the FULL original (MDX 원문 무손실 보존, 오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110). popup_target_slot : str | null Frame Layer B slot identifier the popup trigger anchors to. ``null`` when the strategy has no popup. See CLAUDE.md "위계 + 용어" → "Frame Slot" / "Layer B" for the slot vocabulary. Invariants this file locks (catalog side only — u9 is "data only"): 1. Both fields exist on every catalog entry (no missing keys). 2. ``preview_chars`` is ``int >= 0`` for popup-bearing strategies (``inline_preview_with_details``, ``details_only``) and ``None`` for non-popup strategies (``inline_full``, ``dropped``). 3. ``popup_target_slot`` is a non-empty ``str`` for popup-bearing strategies and ``None`` for non-popup strategies. 4. The two fields are mutually consistent — both null OR both populated within a single strategy entry (no half-wired strategy). 5. The popup-bearing strategies still preserve original content (popup body = full original; preview_chars governs only the inline surface, never the popup body). Cross-references: - u6 binder (consumes ``DISPLAY_STRATEGIES`` via catalog key): src/phase_z2_composition.py:bind_popup_display_strategy - u6 binding tests (existing — must still pass with u9 fields added): tests/phase_z2/test_composition_popup_strategy.py - u7 preview text helper (line-budget cut; the char-budget axis u9 introduces is forward config the future wiring will honor): src/phase_z2_composition.py:compute_popup_preview_text """ from __future__ import annotations import pytest from src.phase_z2_composition import ( DISPLAY_STRATEGIES, POPUP_BINDING_ESCALATED_STRATEGY_ID, POPUP_BINDING_NO_POPUP_STRATEGY_ID, ) # Catalog keys grouped by popup capability. Sourced from the loaded # DISPLAY_STRATEGIES so a yaml-side rename surfaces immediately (no # hardcoded duplicate of catalog keys outside the binder constants). _POPUP_BEARING_STRATEGY_IDS = ( "inline_preview_with_details", "details_only", ) _NON_POPUP_STRATEGY_IDS = ( "inline_full", "dropped", ) def test_all_strategies_declare_preview_chars_field(): """Every catalog entry MUST declare ``preview_chars`` (int or null). Missing key = yaml drift; the binder + future wiring need a present field to read deterministically.""" for name, meta in DISPLAY_STRATEGIES.items(): assert "preview_chars" in meta, ( f"display_strategies.yaml entry {name!r} is missing the u9 " f"`preview_chars` field. Every entry must declare it (int >= 0 " f"for popup-bearing strategies, null otherwise)." ) def test_all_strategies_declare_popup_target_slot_field(): """Every catalog entry MUST declare ``popup_target_slot`` (str or null). Missing key = yaml drift.""" for name, meta in DISPLAY_STRATEGIES.items(): assert "popup_target_slot" in meta, ( f"display_strategies.yaml entry {name!r} is missing the u9 " f"`popup_target_slot` field. Every entry must declare it " f"(non-empty str for popup-bearing strategies, null otherwise)." ) @pytest.mark.parametrize("strategy_id", _POPUP_BEARING_STRATEGY_IDS) def test_popup_bearing_strategies_have_nonnegative_int_preview_chars(strategy_id): """Popup-bearing strategies declare ``preview_chars`` as ``int >= 0``. The popup body itself always holds the FULL original (user lock), so this budget governs only the INLINE preview / summary surface.""" meta = DISPLAY_STRATEGIES[strategy_id] value = meta.get("preview_chars") assert isinstance(value, int) and not isinstance(value, bool), ( f"display_strategies.yaml {strategy_id!r} preview_chars must be an " f"int (got {type(value).__name__}={value!r}). The future wiring " f"reads it as a deterministic budget — bool / float / str would " f"silently break downstream comparisons." ) assert value >= 0, ( f"display_strategies.yaml {strategy_id!r} preview_chars must be " f">= 0 (got {value!r}). Negative budgets are not a valid surface." ) @pytest.mark.parametrize("strategy_id", _POPUP_BEARING_STRATEGY_IDS) def test_popup_bearing_strategies_have_nonempty_string_popup_target_slot(strategy_id): """Popup-bearing strategies declare ``popup_target_slot`` as a non-empty ``str`` — the frame Layer B slot identifier the popup trigger anchors to.""" meta = DISPLAY_STRATEGIES[strategy_id] value = meta.get("popup_target_slot") assert isinstance(value, str), ( f"display_strategies.yaml {strategy_id!r} popup_target_slot must " f"be a str (got {type(value).__name__}={value!r})." ) assert value, ( f"display_strategies.yaml {strategy_id!r} popup_target_slot must " f"be a non-empty string identifying a frame Layer B slot." ) @pytest.mark.parametrize("strategy_id", _NON_POPUP_STRATEGY_IDS) def test_non_popup_strategies_have_null_preview_chars(strategy_id): """Non-popup strategies (``inline_full`` / ``dropped``) declare ``preview_chars`` as null — they have no popup-side budget axis.""" meta = DISPLAY_STRATEGIES[strategy_id] assert meta.get("preview_chars") is None, ( f"display_strategies.yaml {strategy_id!r} has no popup; " f"preview_chars must be null (got {meta.get('preview_chars')!r})." ) @pytest.mark.parametrize("strategy_id", _NON_POPUP_STRATEGY_IDS) def test_non_popup_strategies_have_null_popup_target_slot(strategy_id): """Non-popup strategies declare ``popup_target_slot`` as null — nothing for the popup trigger to anchor to.""" meta = DISPLAY_STRATEGIES[strategy_id] assert meta.get("popup_target_slot") is None, ( f"display_strategies.yaml {strategy_id!r} has no popup; " f"popup_target_slot must be null (got {meta.get('popup_target_slot')!r})." ) def test_popup_wiring_fields_are_mutually_consistent_per_strategy(): """For every catalog entry, ``preview_chars`` and ``popup_target_slot`` must be either BOTH null OR BOTH populated. A half-wired strategy (one null, one populated) is a yaml-drift bug — surfaces here.""" for name, meta in DISPLAY_STRATEGIES.items(): preview = meta.get("preview_chars") slot = meta.get("popup_target_slot") both_null = preview is None and slot is None both_set = preview is not None and slot is not None assert both_null or both_set, ( f"display_strategies.yaml {name!r} has inconsistent popup " f"wiring fields — preview_chars={preview!r}, " f"popup_target_slot={slot!r}. Must be both null OR both set." ) def test_binder_constants_point_to_popup_bearing_strategies(): """The u6 binder constants must continue to resolve against the catalog entries that carry u9 popup-wiring fields. Cross-axis lock between the binder (u6) and the catalog (u9) — drift on either side breaks the popup path silently.""" assert POPUP_BINDING_ESCALATED_STRATEGY_ID in _POPUP_BEARING_STRATEGY_IDS, ( f"u6 binder POPUP_BINDING_ESCALATED_STRATEGY_ID points to " f"{POPUP_BINDING_ESCALATED_STRATEGY_ID!r} which is NOT a popup-" f"bearing strategy per the u9 catalog axis." ) assert POPUP_BINDING_NO_POPUP_STRATEGY_ID in _NON_POPUP_STRATEGY_IDS, ( f"u6 binder POPUP_BINDING_NO_POPUP_STRATEGY_ID points to " f"{POPUP_BINDING_NO_POPUP_STRATEGY_ID!r} which IS popup-bearing " f"per the u9 catalog axis — wiring would be miscategorised." ) def test_popup_bearing_strategies_still_preserve_original(): """u9 does not alter the existing absolute user lock: popup-bearing strategies have ``preserves_original=True`` (popup body == full original). u9 only adds inline-surface budget fields — must NOT silently degrade the existing invariant.""" for strategy_id in _POPUP_BEARING_STRATEGY_IDS: meta = DISPLAY_STRATEGIES[strategy_id] assert meta.get("preserves_original") is True, ( f"display_strategies.yaml {strategy_id!r} must preserve " f"original content even after u9 — preview_chars governs " f"the inline surface only, never the popup body." )