Land the production + test surface for the Step 17 cascade POPUP terminal (DETERMINISTIC -> POPUP -> AI_REPAIR -> USER_OVERRIDE) per Stage 2 plan R2. u11 (baseline-red invariance gate) was already landed in7c93031ahead of this commit; this commit completes u1~u10 plus the Stage 3 R7 follow-up anchor re-pin for test_imp17_comment_anchor.py. Implementation units (Stage 2 R2 contract): u1 frame_reselect_insufficient failure_type + post-frame remeasure (q4) - src/phase_z2_failure_router.py, src/phase_z2_pipeline.py u2 NEXT_ACTION_BY_FAILURE row + impl_status flip - src/phase_z2_failure_router.py u3 Router details_popup_escalation MISSING->IMPLEMENTED + executor stub - src/phase_z2_router.py u4 step17.py AI split-decision contract (POPUP cascade_stage + route_for_label + skip_reason); API gated - src/phase_z2_ai_fallback/step17.py u5 Step 17 POPUP gate executor; popup_escalation_plan + has_popup marker - src/phase_z2_pipeline.py, src/phase_z2_ai_fallback/step17.py u6 Composition popup binding -- yaml strategy -> zone payload - src/phase_z2_composition.py u7 Pipeline composer -> render_slide wiring (popup_html / preview_text / has_popup) - src/phase_z2_pipeline.py u8 slide_base.html <details>/<summary> popup wrapper - templates/phase_z2/slide_base.html u9 display_strategies.yaml inline_preview + popup metadata - templates/phase_z2/regions/display_strategies.yaml u10 MDX preservation invariant: popup=full source / body=summary or subset (asserted by tests/phase_z2/test_popup_mdx_preservation.py) u11 (already in7c93031) -- baseline-red invariance gate Stage 3 R7 follow-up (anchor re-pin, test-only): - tests/orchestrator_unit/test_imp17_comment_anchor.py Pre-anchor additions in src/phase_z2_pipeline.py (u1 / u5 / u7) shifted the restructure/reject route-hint comments 578/579 -> 586/587. Re-pinned the two guard tests (and docstring re-pin lineage 564 -> 570 -> 578 -> 586). Production code untouched. Verification (Stage 4 R1): pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py -> 2 passed / 0.02s pytest -q <10 IMP-35 unit files in tests/phase_z2 + tests/phase_z2_ai_fallback> -> 136 passed / 15.94s Baseline-red invariance gate (tests/test_imp47b_step12_ai_wiring.py + tests/test_phase_z2_ai_fallback_config.py) -> 4 failed / 6 passed; FAILED set === IMP35_BASELINE_RED_NODE_IDS (frozen registry from7c93031). Contract holds. Codex Stage 4 R1 = YES (independent verify). Guardrails honored: - MDX content preservation: popup carries full source, body holds summary or subset only (CLAUDE.md 자세히보기 원칙; feedback_phase_z_spacing_direction -- capacity expanded, no margin shrink). - AI isolation contract: Step 17 POPUP gate is deterministic; AI hook surface is split-decision contract only, API call gated. - No hardcoding: escalation thresholds derived from existing overflow detector outputs; preview_chars deterministic from container px. - 1 commit = 1 decision unit: u1~u10 land together as the planned production surface; u11 was deliberately split into7c93031as Stage 3 R7 carve-out, and the R7 anchor re-pin rides with this commit because it is the direct shift consequence of the u1/u5/u7 pre-anchor additions. - Scope-locked: .claude/settings.json explicitly excluded (Stage 4 exit report contract). Out of scope (per Stage 1 + Stage 2): - AI_REPAIR API activation (post IMP-35 axis). - IMP-34 zone resize, IMP-36 responsive fit (chain partners, separate issues). - Print-time auto-expand JavaScript for <details>. - Popup escalation in stages other than Step 17. - Baseline-red body repair (4 frozen failures) -- separate follow-up issue; u11 only guards the count. - frame_reselect algorithm changes (entry point only). - templates/phase_z2/slide_base.html path rename. source_comment_ids: Stage 1: claude_stage1_problem_review_imp35, codex_stage1_verification_imp35_yes Stage 2: Claude #4 R2 plan, Codex #5 R2 YES Stage 3: Claude #86 (R7 anchor re-pin), Codex #87 YES Stage 4: Claude #88 R1, Codex #89 R1 YES Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
193 lines
8.8 KiB
Python
193 lines
8.8 KiB
Python
"""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."
|
|
)
|