feat(#64): IMP-35 details_popup_escalation u1~u10 + Stage 3 R7 anchor re-pin

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 in 7c93031 ahead 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 in 7c93031) -- 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 from 7c93031). 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 into 7c93031 as 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>
This commit is contained in:
2026-05-23 07:36:57 +09:00
parent 7c93031f9b
commit f3ef4d917c
17 changed files with 3692 additions and 25 deletions

View File

@@ -17,6 +17,13 @@ shifted only the post-comment table downward. The restructure anchor itself move
the restructure line. Re-pinned 570 → 578 (restructure / IMP-17) and 571 → 579
(reject / IMP-47B supersession of the prior IMP-29 reference).
Anchor re-pin (2026-05-23, IMP-35 u1/u5/u7 / Gitea #64 Stage 3): IMP-35 added a
single-line ``compose_zone_popup_payload`` import (u7) plus a 7-line
``run_step17_popup_gate`` import block (u5) ahead of the route-hint table, totaling
+8 lines of pre-anchor additions. The post-import body shifted uniformly downward;
the restructure anchor moved 578 → 586 and the reject anchor moved 579 → 587.
Re-pinned 578 → 586 (restructure / IMP-17) and 579 → 587 (reject / IMP-47B).
Run: pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py
"""
from pathlib import Path
@@ -29,17 +36,17 @@ def _lines() -> list[str]:
return PIPELINE.read_text(encoding="utf-8").splitlines()
def test_line_578_references_imp17_not_imp31():
line = _lines()[577] # 1-indexed line 578
assert "restructure" in line, f"line 578 anchor drifted: {line!r}"
assert "IMP-17" in line, f"line 578 must reference IMP-17 (carve-out): {line!r}"
assert "IMP-31" not in line, f"line 578 must not reference non-existent IMP-31: {line!r}"
def test_line_586_references_imp17_not_imp31():
line = _lines()[585] # 1-indexed line 586
assert "restructure" in line, f"line 586 anchor drifted: {line!r}"
assert "IMP-17" in line, f"line 586 must reference IMP-17 (carve-out): {line!r}"
assert "IMP-31" not in line, f"line 586 must not reference non-existent IMP-31: {line!r}"
def test_line_579_references_imp47b_supersession():
line = _lines()[578] # 1-indexed line 579
assert "reject" in line, f"line 579 anchor drifted: {line!r}"
def test_line_587_references_imp47b_supersession():
line = _lines()[586] # 1-indexed line 587
assert "reject" in line, f"line 587 anchor drifted: {line!r}"
assert "IMP-47B" in line, (
f"line 579 must reference IMP-47B (supersedes prior IMP-29 reject disposition): "
f"line 587 must reference IMP-47B (supersedes prior IMP-29 reject disposition): "
f"{line!r}"
)

View File

@@ -0,0 +1,333 @@
"""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

View File

@@ -0,0 +1,192 @@
"""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."
)

View File

@@ -117,3 +117,136 @@ def test_rerender_still_fails_preserved_routes_to_frame_reselect():
assert fc["failure_type"] == "rerender_still_fails"
nr = route_retry_failure("rerender_still_fails")
assert nr["next_proposed_action"] == "frame_reselect"
def test_frame_reselect_insufficient_classifier_emits_from_salvage_steps():
"""IMP-35 (#64) u1 — post-frame remeasure contract.
When the future frame_reselect orchestrator appends a salvage_steps entry
with action='frame_reselect', passed=False, and a post-frame remeasure
in post_salvage_overflow, the classifier must emit frame_reselect_insufficient
via SALVAGE_FAILURE_TYPE_BY_ACTION (q4 = explicit remeasure, not flag
carryover). NEXT_ACTION routing (→ details_popup_escalation) landed in u2;
see test_frame_reselect_insufficient_routes_to_details_popup_escalation
below for the u2-locked routing contract.
"""
from src.phase_z2_failure_router import (
FAILURE_TYPE_DESCRIPTIONS,
SALVAGE_FAILURE_TYPE_BY_ACTION,
)
# Registry contract: the new failure_type + SALVAGE action mapping exist.
assert "frame_reselect_insufficient" in FAILURE_TYPE_DESCRIPTIONS
assert SALVAGE_FAILURE_TYPE_BY_ACTION["frame_reselect"] == "frame_reselect_insufficient"
trace = {
"retry_attempted": True,
"retry_passed": False,
"salvage_passed": False,
"salvage_steps": [
{
"action": "frame_reselect",
"passed": False,
"failure_reason": "post-frame remeasure: overflow persists",
"post_salvage_overflow": {"passed": False, "fail_reasons": ["body still clipped"]},
}
],
}
fc = classify_retry_failure(trace)
assert fc is not None
assert fc["failure_type"] == "frame_reselect_insufficient"
assert "frame_reselect" in fc["classification_rule"]
# q4 contract: classification_rule MUST cite post_salvage_overflow so the
# remeasure evidence is auditable from the trace (not a bare action flag).
assert "post_salvage_overflow" in fc["classification_rule"]
def test_frame_reselect_without_post_salvage_overflow_is_not_classified_as_insufficient():
"""IMP-35 (#64) u1 — q4 negative guard.
A failed frame_reselect salvage step **without** post_salvage_overflow
evidence must NOT be classified as frame_reselect_insufficient. q4 of the
Stage 2 plan locks the contract: classification requires an explicit
post-frame remeasure payload, not a carried/manual failure flag. Without
that evidence the classifier falls through to the lower-priority cases
(defensive fallback) so the cascade never escalates onto
details_popup_escalation spuriously.
"""
trace = {
"retry_attempted": True,
"retry_passed": False,
"salvage_passed": False,
"salvage_steps": [
{
"action": "frame_reselect",
"passed": False,
"failure_reason": "carried failure flag — no remeasure payload",
# post_salvage_overflow intentionally absent
}
],
}
fc = classify_retry_failure(trace)
assert fc is not None
assert fc["failure_type"] != "frame_reselect_insufficient", (
"frame_reselect without post_salvage_overflow must not classify as "
"frame_reselect_insufficient (q4 contract — explicit remeasure, not "
"failure-flag carryover)."
)
# Routing must NOT escalate onto details_popup_escalation when the gate
# is not satisfied. u2 landed the frame_reselect_insufficient →
# details_popup_escalation mapping; this negative path protects against
# premature popup escalation when classifier fell through to a lower-
# priority failure type (not frame_reselect_insufficient).
nr = route_retry_failure(fc["failure_type"])
assert nr["next_proposed_action"] != "details_popup_escalation"
def test_frame_reselect_insufficient_routes_to_details_popup_escalation():
"""IMP-35 (#64) u2 — cascade terminal routing contract.
frame_reselect_insufficient is the deterministic cascade terminal. u2
locks the NEXT_ACTION_BY_FAILURE row so the failure_router escalates onto
details_popup_escalation when (and only when) u1's q4-gated classifier
has emitted the insufficient verdict. Implementation status is reported
as MISSING here because the executor stub + MISSING→IMPLEMENTED flip
live in src/phase_z2_router.py (u3); the failure_router surface must not
claim implementation it does not own.
"""
# Direct mapping (u2 lock)
assert NEXT_ACTION_BY_FAILURE["frame_reselect_insufficient"] == (
"details_popup_escalation"
)
# u2 advertises cascade terminal as MISSING; u3 flips it on the router
# surface (separate file). Until u3 lands, failure_router must report
# MISSING to avoid premature "popup ready" claims.
assert NEXT_ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"] == "MISSING"
nr = route_retry_failure("frame_reselect_insufficient")
assert nr["next_proposed_action"] == "details_popup_escalation"
assert nr["next_action_implementation_status"] == "MISSING"
assert "details_popup_escalation" in (nr["next_action_rationale"] or "")
# End-to-end via the classifier path: q4 contract satisfied →
# enrichment composes the cascade terminal proposal onto the trace.
trace = {
"retry_attempted": True,
"retry_passed": False,
"salvage_passed": False,
"salvage_steps": [
{
"action": "frame_reselect",
"passed": False,
"failure_reason": "post-frame remeasure: overflow persists",
"post_salvage_overflow": {
"passed": False,
"fail_reasons": ["body still clipped"],
},
}
],
}
enrich_retry_trace_with_failure_classification(trace)
assert trace["failure_classification"]["failure_type"] == (
"frame_reselect_insufficient"
)
assert trace["next_action_proposal"]["next_proposed_action"] == (
"details_popup_escalation"
)

View File

@@ -0,0 +1,419 @@
"""IMP-35 (#64) u7 — Pipeline composer -> render_slide wiring tests.
Stage 2 wiring contract (unit u7):
u6 (``bind_popup_display_strategy`` in ``src/phase_z2_composition.py``)
produced the composition-side binding from the unit-side marker stamped
by Step 17 POPUP gate (u5). u7 is the pipeline composer side: it
surfaces three uniform render-context field names per zone in
``zones_data`` so slide_base.html (u8) sees the same shape on every
zone regardless of whether the unit went through the POPUP gate:
has_popup : bool — escalation marker echo
popup_html : str — popup body source (FULL ``raw_content``
per u6 ``popup_body_source``; u8 wraps
it in ``<details>/<summary>``). ``None``
when has_popup=False.
preview_text : str — px-budgeted line-boundary excerpt of
``raw_content`` shown in the body /
inline_preview slot. ``None`` when
has_popup=False. Popup body retains
the FULL original so the excerpt loses
no information.
Key invariants this file locks:
1. ``compose_zone_popup_payload`` returns the three uniform field
names plus the full u6 binding under ``popup_binding`` for
downstream debug.
2. has_popup=False units bind to the no-popup branch — popup_html
and preview_text are both ``None``, popup_binding echoes u6
``inline_full`` strategy.
3. has_popup=True units bind to the popup branch — popup_html ==
u6 ``popup_body_source`` == FULL ``raw_content`` (MDX 원문
무손실 보존, 오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110),
and preview_text is a deterministic line-boundary excerpt.
4. ``compute_popup_preview_text`` is a CUT, never a rewrite —
``raw_content.startswith(preview_text)`` when the content
exceeds the container budget; otherwise preview == full content.
5. Line-boundary cut never trims inside a line (no mid-CJK-word cut).
6. Non-positive container budget falls back to the full content
(no spurious truncation when telemetry is missing — popup gate
would not have fired without a real budget anyway).
7. AI isolation contract — pure deterministic helpers; no anthropic
import, no route_ai_fallback path.
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 u7 reads via u6):
tests/phase_z2/test_phase_z2_step17_popup_gate.py
- u6 composition popup binding (input to u7):
tests/phase_z2/test_composition_popup_strategy.py
"""
from __future__ import annotations
from dataclasses import dataclass
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,
POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX,
compose_zone_popup_payload,
compute_popup_preview_text,
)
# ─── Synthetic stubs ─────────────────────────────────────────────────
@dataclass
class _StubUnit:
"""Minimal duck-typed CompositionUnit for u7 wiring tests.
Mirrors only the fields ``compose_zone_popup_payload`` reads via
getattr — keeps the test independent of CompositionUnit dataclass
evolution (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. u7 echoes this verbatim via u6 binding — 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",
}
# ─── compose_zone_popup_payload — uniform render-context surface ─────
def test_payload_returns_uniform_field_names():
"""u7 — every payload (popup or not) MUST surface the same four
field names so slide_base.html (u8) does not have to branch on the
presence of popup fields. Field uniformity is the wiring contract."""
payload = compose_zone_popup_payload(_StubUnit(has_popup=False), 200)
assert set(payload.keys()) == {
"has_popup",
"popup_html",
"preview_text",
"popup_binding",
}
payload_popup = compose_zone_popup_payload(
_StubUnit(
raw_content="MOCK_BODY",
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
),
200,
)
assert set(payload_popup.keys()) == {
"has_popup",
"popup_html",
"preview_text",
"popup_binding",
}
def test_payload_has_popup_false_returns_no_popup_branch():
"""u7 — has_popup=False units bind to the no-popup branch: both
popup_html and preview_text are None, popup_binding echoes the u6
``inline_full`` strategy."""
unit = _StubUnit(raw_content="MOCK_BODY", has_popup=False)
payload = compose_zone_popup_payload(unit, 200)
assert payload["has_popup"] is False
assert payload["popup_html"] is None
assert payload["preview_text"] is None
binding = payload["popup_binding"]
assert isinstance(binding, dict)
assert binding["display_strategy"] == POPUP_BINDING_NO_POPUP_STRATEGY_ID
assert binding["has_popup"] is False
def test_payload_default_when_unit_lacks_has_popup_attr_at_all():
"""u7 defensive default — units that lack the has_popup attribute
entirely (e.g., third-party duck-typed stubs) bind to the no-popup
path through the getattr() default branch (mirrors u6 test)."""
class _BareUnit:
raw_content = "MOCK_BODY"
payload = compose_zone_popup_payload(_BareUnit(), 200)
assert payload["has_popup"] is False
assert payload["popup_html"] is None
assert payload["preview_text"] is None
def test_payload_has_popup_true_popup_html_is_full_raw_content_verbatim():
"""u7 — popup_html MUST be the FULL raw_content verbatim. u6
popup_body_source already locks this at the binding layer; u7
must NOT re-shape, trim, or HTML-escape on the way to the zone
dict. MDX 원문 무손실 보존 (오답노트 #5)."""
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 = compose_zone_popup_payload(unit, container_height_px=200)
assert payload["popup_html"] == full_text
assert len(payload["popup_html"]) == len(full_text)
def test_payload_has_popup_true_preview_text_is_deterministic_line_cut():
"""u7 — preview_text MUST be a deterministic line-boundary excerpt
of raw_content. With container_height_px=36 and the default
line metric (18 px), the budget = 2 lines."""
full_text = "line1\nline2\nline3\nline4\nline5"
unit = _StubUnit(
raw_content=full_text,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = compose_zone_popup_payload(unit, container_height_px=36)
assert payload["preview_text"] == "line1\nline2"
# popup body still holds the FULL original — no information loss.
assert payload["popup_html"] == full_text
def test_payload_popup_binding_echoes_full_u6_output():
"""u7 — popup_binding MUST echo the full u6 output so debug
consumers can read display_strategy / detail_trigger / strategy_meta
/ popup_escalation_plan without re-reading the yaml."""
plan = _stub_popup_plan("tabular_overflow")
unit = _StubUnit(
raw_content="MOCK_BODY",
has_popup=True,
popup_escalation_plan=plan,
)
payload = compose_zone_popup_payload(unit, container_height_px=200)
binding = payload["popup_binding"]
assert binding["display_strategy"] == POPUP_BINDING_ESCALATED_STRATEGY_ID
assert binding["has_popup"] is True
assert binding["popup_escalation_plan"] is plan
# detail_trigger comes from the catalog entry's detail_trigger block.
catalog_trigger = (
DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
.get("detail_trigger") or {}
)
assert binding["detail_trigger"] == {
"placement": catalog_trigger.get("placement"),
"label": catalog_trigger.get("label"),
}
# ─── compute_popup_preview_text — deterministic line-budget cut ──────
def test_preview_returns_empty_string_when_raw_content_is_empty():
"""u7 — empty raw_content returns empty preview; no IndexError /
TypeError on the splitlines path."""
assert compute_popup_preview_text("", container_height_px=200) == ""
def test_preview_returns_full_content_when_it_fits_budget():
"""u7 — when the content already fits the container budget, the
preview equals the full content (no spurious truncation)."""
full_text = "line1\nline2\nline3"
# budget = 200 / 18 = 11 lines → fits 3 lines easily.
assert (
compute_popup_preview_text(full_text, container_height_px=200)
== full_text
)
def test_preview_truncates_to_line_budget_when_content_overflows():
"""u7 — when the content exceeds the budget, the preview is the
leading N lines that fit, joined verbatim with '\\n'. Never trims
inside a line (no mid-CJK-word cut)."""
full_text = "L1\nL2\nL3\nL4\nL5\nL6"
# budget = 54 / 18 = 3 lines.
assert (
compute_popup_preview_text(full_text, container_height_px=54)
== "L1\nL2\nL3"
)
def test_preview_is_a_prefix_of_raw_content_when_truncated():
"""u7 — invariant: a truncated preview is a CUT, never a rewrite.
raw_content.startswith(preview_text) MUST hold when truncation
happened. Locks the line-boundary semantics — preview is always a
leading-substring of raw_content (modulo \\n re-join, which matches
splitlines round-trip)."""
full_text = (
"- 첫 번째 항목 (CJK)\n"
"- 두 번째 항목 (CJK)\n"
"- 세 번째 항목 (CJK)\n"
"- 네 번째 항목 (CJK)\n"
"- 다섯 번째 항목 (CJK)\n"
)
preview = compute_popup_preview_text(full_text, container_height_px=54)
# 3 lines budget. preview ends at the third line boundary.
assert preview == "- 첫 번째 항목 (CJK)\n- 두 번째 항목 (CJK)\n- 세 번째 항목 (CJK)"
# Leading-substring guarantee — raw_content starts with preview verbatim.
assert full_text.startswith(preview)
def test_preview_never_returns_empty_string_when_budget_floors_to_zero():
"""u7 — if container_height_px is positive but smaller than one
line, the floor would yield 0 lines. The helper clamps max_lines
to at least 1 so the preview always contains at least the first
line (otherwise the popup wrapper would have an empty preview
slot — UX degradation)."""
full_text = "first line\nsecond line"
# budget = 5 / 18 = 0 floor → clamp to 1.
assert (
compute_popup_preview_text(full_text, container_height_px=5)
== "first line"
)
def test_preview_falls_back_to_full_content_when_budget_non_positive():
"""u7 — non-positive container_height_px (0 or negative) returns
the full content unchanged. u5 POPUP gate would not have fired
without a real budget, so this branch is only reachable for
non-popup units (where preview is unused). No spurious truncation."""
full_text = "line1\nline2\nline3"
assert (
compute_popup_preview_text(full_text, container_height_px=0)
== full_text
)
assert (
compute_popup_preview_text(full_text, container_height_px=-100)
== full_text
)
def test_preview_falls_back_to_full_content_when_line_height_non_positive():
"""u7 defensive guard — non-positive line_height_px override would
divide-by-zero. Helper falls back to the full content unchanged
(no spurious truncation, no exception)."""
full_text = "line1\nline2\nline3"
assert (
compute_popup_preview_text(
full_text, container_height_px=200, line_height_px=0
)
== full_text
)
def test_preview_default_line_height_constant_matches_slide_base_body_metric():
"""u7 no-hardcoding lock — the default line height constant is a
parametric default (not a magic literal). Locked at 18 px to match
slide_base.html ``--font-body`` (11 px) * line-height (1.6) + guard.
If slide_base.html body metric changes, this test should fail and
force an explicit re-derivation."""
assert POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX == 18.0
def test_preview_accepts_line_height_override():
"""u7 — line_height_px is overridable so a tighter-font frame can
pass a smaller line metric. Locks the parametric contract."""
full_text = "L1\nL2\nL3\nL4\nL5\nL6"
# budget = 30 / 10 = 3 lines under override.
assert (
compute_popup_preview_text(
full_text, container_height_px=30, line_height_px=10.0
)
== "L1\nL2\nL3"
)
# ─── Integration: pipeline composer attaches popup payload to zone ────
def test_pipeline_zone_dict_includes_popup_fields():
"""u7 — the pipeline composer (src/phase_z2_pipeline.py) calls
``compose_zone_popup_payload(unit, min_height_px)`` per-unit and
spreads the four wiring keys into the zone dict via
``zones_data.append({..., **payload})``. This test rebuilds the
spread surface against a synthetic unit + container budget to lock
the integration contract without booting the entire pipeline."""
unit = _StubUnit(
raw_content="line1\nline2\nline3\nline4\nline5\nline6",
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
base_zone = {
"position": "single",
"template_id": "MOCK_FRAME",
"slot_payload": {},
"content_weight": {"score": 0},
"min_height_px": 54, # 3 lines budget at default metric.
"assignment_source": "MOCK",
"section_assignment_override": False,
"provisional": False,
}
popup_payload = compose_zone_popup_payload(unit, base_zone["min_height_px"])
zone = {**base_zone, **popup_payload}
assert zone["has_popup"] is True
assert zone["popup_html"] == unit.raw_content
assert zone["preview_text"] == "line1\nline2\nline3"
assert isinstance(zone["popup_binding"], dict)
# Spread MUST NOT clobber the pre-existing zone fields — popup
# payload keys are disjoint from the base zone dict keys.
assert zone["position"] == "single"
assert zone["template_id"] == "MOCK_FRAME"
assert zone["min_height_px"] == 54
def test_pipeline_zone_dict_no_popup_keys_are_uniform_across_branches():
"""u7 — the pipeline composer has three zones_data.append sites
(empty-shell unit, main renderable unit, unrenderable empty plan
record). All three MUST stamp the same four wiring keys with
consistent shape so slide_base.html (u8) does not have to branch
on key presence. This test locks the no-popup defaults stamped by
the unrenderable empty plan branch."""
no_popup_defaults = {
"has_popup": False,
"popup_html": None,
"preview_text": None,
"popup_binding": None, # unrenderable branch — no unit, no u6 binding.
}
# And compose_zone_popup_payload for a no-popup unit MUST surface
# the same three render-context keys (popup_binding differs — it
# holds the u6 ``inline_full`` echo when there IS a unit).
payload = compose_zone_popup_payload(_StubUnit(has_popup=False), 200)
for k in ("has_popup", "popup_html", "preview_text"):
assert no_popup_defaults[k] == payload[k]
# ─── AI isolation contract (structural import lock) ─────────────────
def test_composition_module_does_not_import_anthropic_or_route_ai_fallback():
"""u7 — compose_zone_popup_payload + compute_popup_preview_text MUST
stay AI-free. Mirrors the import-isolation pattern locked by u4/u5
tests. composition module is allowed to consult the catalog and
unit state, never the Anthropic SDK / route_ai_fallback path."""
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

View File

@@ -0,0 +1,209 @@
"""IMP-35 (#64) u3 — router popup escalation stub tests.
Stage 2 binding contract (unit u3):
- `details_popup_escalation` MISSING → IMPLEMENTED on the *primary* router
surface (`src/phase_z2_router.py`). Downstream surfaces remain decoupled:
* `src/phase_z2_failure_router.py` keeps the cascade-terminal entry as
MISSING until u5 wires the Step 17 POPUP gate executor.
* `src/phase_z2_ai_fallback/step17.py` (u4) binds the AI split-decision
contract that the stub flags via `needs_split_decision=True`.
- `plan_details_popup_escalation(classification)` stub is the deterministic
executor surface — no AI call, no HTML/CSS/MDX mutation. It emits the
canonical popup_escalation_plan marker that u4/u5 consume.
- The two ACTION_BY_CATEGORY rows that map onto `details_popup_escalation`
— `structural_major_overflow` and `tabular_overflow` — must route to the
cascade terminal via `route_action` / `route_fit_classification`.
Cross-references:
- u1 (frame_reselect_insufficient classifier gate, q4 contract):
tests/phase_z2/test_phase_z2_failure_router_cascade.py::
test_frame_reselect_insufficient_classifier_emits_from_salvage_steps
tests/phase_z2/test_phase_z2_failure_router_cascade.py::
test_frame_reselect_without_post_salvage_overflow_is_not_classified_as_insufficient
- u2 (failure_router cascade terminal row + MISSING status lock):
tests/phase_z2/test_phase_z2_failure_router_cascade.py::
test_frame_reselect_insufficient_routes_to_details_popup_escalation
"""
from __future__ import annotations
from src.phase_z2_router import (
ACTION_BY_CATEGORY,
ACTION_IMPLEMENTATION_STATUS,
ACTION_RATIONALE,
POPUP_ESCALATION_CATEGORIES,
plan_details_popup_escalation,
route_action,
route_fit_classification,
)
def test_action_implementation_status_details_popup_escalation_flipped_to_implemented():
"""IMP-35 u3 — primary router surface flip.
`ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"]` was MISSING
prior to u3 (Stage 2 binding). u3 lands the deterministic
`plan_details_popup_escalation` stub on the router surface, so the
status must read IMPLEMENTED here. u5 owns the matching flip on the
failure_router surface — until u5 lands, the failure_router still
reports MISSING (locked by the u2 test).
"""
assert (
ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"] == "IMPLEMENTED"
), (
"u3 must flip the primary router surface from MISSING to IMPLEMENTED. "
"The failure_router companion surface stays MISSING until u5 (see u2 "
"test test_frame_reselect_insufficient_routes_to_details_popup_escalation)."
)
def test_structural_major_overflow_routes_to_details_popup_escalation_implemented():
"""IMP-35 u3 — `structural_major_overflow` is one of the two
ACTION_BY_CATEGORY rows that map onto the cascade terminal. After u3
flips the status, `route_action` must report IMPLEMENTED for that
routing.
"""
assert ACTION_BY_CATEGORY["structural_major_overflow"] == "details_popup_escalation"
routing = route_action("structural_major_overflow")
assert routing["proposed_action"] == "details_popup_escalation"
assert routing["implementation_status"] == "IMPLEMENTED"
assert routing["mapping_source"] == "spec §4 ACTION_BY_CATEGORY"
# rationale text must remain non-empty so trace explains *why* this
# category escalates (downstream debugging hinges on it).
assert (routing["rationale"] or "").strip(), (
"rationale text must be present so the router trace explains why "
"structural_major_overflow escalates onto the popup terminal."
)
def test_tabular_overflow_routes_to_details_popup_escalation_implemented():
"""IMP-35 u3 — `tabular_overflow` is the second ACTION_BY_CATEGORY row
that maps onto the cascade terminal. Same surface flip applies.
"""
assert ACTION_BY_CATEGORY["tabular_overflow"] == "details_popup_escalation"
routing = route_action("tabular_overflow")
assert routing["proposed_action"] == "details_popup_escalation"
assert routing["implementation_status"] == "IMPLEMENTED"
def test_popup_escalation_categories_is_derived_from_action_by_category():
"""IMP-35 u3 — POPUP_ESCALATION_CATEGORIES must be the *derived*
projection of ACTION_BY_CATEGORY (single source of truth). If a future
edit changes which categories map onto details_popup_escalation, this
constant must follow automatically; the stub guard relies on it.
"""
expected = frozenset(
category
for category, action in ACTION_BY_CATEGORY.items()
if action == "details_popup_escalation"
)
assert POPUP_ESCALATION_CATEGORIES == expected
# Sanity: at u3 landing time, the two locked categories are present.
assert "structural_major_overflow" in POPUP_ESCALATION_CATEGORIES
assert "tabular_overflow" in POPUP_ESCALATION_CATEGORIES
def test_plan_details_popup_escalation_returns_feasible_plan_for_structural_major():
"""IMP-35 u3 — accepted category produces a feasible popup escalation
plan with the canonical stub shape. u4 (AI hook) reads
`needs_split_decision=True`; u5 (POPUP gate executor) reads
`feasible=True` + `category` + `rationale` to compose the
popup_html / preview_text / has_popup payload.
"""
plan = plan_details_popup_escalation({"category": "structural_major_overflow"})
assert plan["action"] == "details_popup_escalation"
assert plan["feasible"] is True
assert plan["stub"] is True
assert plan["needs_split_decision"] is True
assert plan["category"] == "structural_major_overflow"
assert plan["rationale"] == ACTION_RATIONALE["structural_major_overflow"]
assert plan["mapping_source"] == "IMP-35 u3 plan_details_popup_escalation stub"
# No side-effect markers: stub must not pretend to have done downstream work.
for forbidden_key in ("popup_html", "preview_text", "has_popup", "ai_decision"):
assert forbidden_key not in plan, (
f"u3 stub must NOT carry {forbidden_key!r} — that payload is "
f"composed downstream (u4 AI hook + u5 POPUP gate executor)."
)
def test_plan_details_popup_escalation_returns_feasible_plan_for_tabular():
"""IMP-35 u3 — tabular_overflow is the second accepted category."""
plan = plan_details_popup_escalation({"category": "tabular_overflow"})
assert plan["feasible"] is True
assert plan["stub"] is True
assert plan["needs_split_decision"] is True
assert plan["category"] == "tabular_overflow"
assert plan["rationale"] == ACTION_RATIONALE["tabular_overflow"]
def test_plan_details_popup_escalation_rejects_non_popup_category():
"""IMP-35 u3 — defensive guard. Calling the stub with a category that
does not map onto `details_popup_escalation` in ACTION_BY_CATEGORY must
NOT silently popup-escalate. The stub returns `feasible=False` with a
`failure_reason` citing the accepted categories so the caller can
surface the misuse in trace.
"""
plan = plan_details_popup_escalation({"category": "minor_overflow"})
assert plan["action"] == "details_popup_escalation"
assert plan["feasible"] is False
assert plan["stub"] is True
assert plan["needs_split_decision"] is False
assert plan["category"] == "minor_overflow"
assert "failure_reason" in plan
assert "ACTION_BY_CATEGORY" in plan["failure_reason"]
def test_plan_details_popup_escalation_rejects_missing_category():
"""IMP-35 u3 — defensive guard for malformed classification dict
(no `category` key). Stub must not raise; it must return a
`feasible=False` plan so the caller never crashes the cascade.
"""
plan = plan_details_popup_escalation({})
assert plan["feasible"] is False
assert plan["needs_split_decision"] is False
assert plan["category"] is None
assert "failure_reason" in plan
plan_none = plan_details_popup_escalation(None) # type: ignore[arg-type]
assert plan_none["feasible"] is False
assert plan_none["category"] is None
def test_route_fit_classification_carries_popup_escalation_to_implemented_summary():
"""IMP-35 u3 — end-to-end via the fit_classification → router path.
When a fit_classification reports a `structural_major_overflow` row,
`route_fit_classification` must:
- attach `proposed_action == "details_popup_escalation"` onto the
classification entry
- report IMPLEMENTED in `implementation_status_summary`
- NOT list `details_popup_escalation` in
`missing_actions_pending_impl` (status is now IMPLEMENTED).
"""
fit_classification = {
"visual_check_passed": False,
"classifications": [
{
"source": "body",
"zone_position": "bottom",
"category": "structural_major_overflow",
},
{
"source": "table:summary",
"zone_position": "bottom",
"category": "tabular_overflow",
},
],
}
summary = route_fit_classification(fit_classification)
assert summary["router_active"] is True
assert summary["routed_count"] == 2
assert "details_popup_escalation" in summary["proposed_actions_summary"]
# Both rows escalated onto the popup terminal — status summary must
# therefore reflect 2 IMPLEMENTED counts (no MISSING) for u3.
assert summary["implementation_status_summary"].get("IMPLEMENTED") == 2
assert "details_popup_escalation" not in summary["missing_actions_pending_impl"]
# Per-row enrichment carries the new IMPLEMENTED status onto the
# classification entries (in-place mutation contract preserved).
for cls in fit_classification["classifications"]:
assert cls["proposed_action"] == "details_popup_escalation"
assert cls["proposed_action_implementation_status"] == "IMPLEMENTED"

View File

@@ -0,0 +1,551 @@
"""IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor tests.
Stage 2 binding contract (unit u5):
- ``run_step17_popup_gate`` is the deterministic cascade-terminal gate
that stamps ``popup_escalation_plan`` + idempotent ``has_popup``
marker per unit. Runs AFTER the DETERMINISTIC stage exhausts and
BEFORE the AI_REPAIR cascade stage (canonical OVERFLOW_CASCADE_ORDER).
- No AI call: deterministic-with-data. ``ai_called=False`` on every
record. The u4 ``gather_step17_popup_split_decisions`` AI hook is
a SEPARATE cascade-stage surface (api_gated) and is NOT invoked
from this gate.
- q1 (per-unit), q2 (idempotent via ``has_popup``), q3 (deterministic
split from container px telemetry — preview / popup body composed
downstream in u6 / u7).
Cross-references:
- u3 router stub (``plan_details_popup_escalation``) — accepted
categories ``structural_major_overflow`` / ``tabular_overflow``:
tests/phase_z2/test_phase_z2_router_popup.py
- u1 + u2 cascade-terminal classifier + NEXT_ACTION row:
tests/phase_z2/test_phase_z2_failure_router_cascade.py
- u4 api_gated split-decision contract:
tests/phase_z2_ai_fallback/test_step17.py
"""
from __future__ import annotations
from dataclasses import dataclass, field
from src.phase_z2_ai_fallback.step17 import (
STEP17_POPUP_GATE_ESCALATED_REASON,
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON,
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON,
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON,
OverflowCascadeStage,
run_step17_popup_gate,
)
from src.phase_z2_router import plan_details_popup_escalation
@dataclass
class FakeUnit:
label: str | None = "restructure"
provisional: bool = True
frame_template_id: str = "tmpl"
source_section_ids: list[str] = field(default_factory=lambda: ["s1"])
has_popup: bool = False
_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)
def _always_popup_classification(category: str = "structural_major_overflow"):
"""Helper: classification_for_unit fake returning a popup category."""
cls = {"category": category, "zone_position": "top"}
return lambda _unit: cls
def _no_classification(_unit):
"""Helper: classification_for_unit fake returning None (no overflow)."""
return None
# ─── Reason constants ────────────────────────────────────────────────
def test_popup_gate_reason_constants_are_distinct_and_stable():
"""u5 — gate_status / skip_reason enum constants must be machine-readable
and disjoint. Consumers parse the trace by these strings."""
reasons = {
STEP17_POPUP_GATE_ESCALATED_REASON,
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON,
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON,
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON,
}
assert len(reasons) == 4
assert STEP17_POPUP_GATE_ESCALATED_REASON == "step17_popup_gate_escalated"
assert (
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
== "step17_popup_gate_idempotent_short_circuit"
)
assert (
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON
== "step17_popup_gate_infeasible_category"
)
assert (
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON
== "step17_popup_gate_no_classification_for_unit"
)
# ─── Basic shape + cascade_stage ─────────────────────────────────────
def test_popup_gate_with_empty_units_returns_empty_list():
records = run_step17_popup_gate(
[],
classification_for_unit=_no_classification,
route_for_label=_route_for_label,
plan_for_classification=plan_details_popup_escalation,
)
assert records == []
def test_popup_gate_returns_one_record_per_unit():
units = [
FakeUnit(label="restructure"),
FakeUnit(label="reject"),
FakeUnit(label="use_as_is"),
]
records = run_step17_popup_gate(
units,
classification_for_unit=_no_classification,
route_for_label=_route_for_label,
plan_for_classification=plan_details_popup_escalation,
)
assert len(records) == 3
def test_popup_gate_cascade_stage_is_popup_everywhere():
"""u5 — gate runs at OverflowCascadeStage.POPUP, never AI_REPAIR."""
units = [
FakeUnit(label="restructure"),
FakeUnit(label="reject"),
FakeUnit(label=None),
]
records = run_step17_popup_gate(
units,
classification_for_unit=_no_classification,
route_for_label=_route_for_label,
plan_for_classification=plan_details_popup_escalation,
)
for record in records:
assert record["cascade_stage"] == OverflowCascadeStage.POPUP.value
assert record["cascade_stage"] != OverflowCascadeStage.AI_REPAIR.value
def test_popup_gate_ai_called_is_false_everywhere():
"""u5 — deterministic gate. NO Anthropic call. Never invokes AI even
when classification is present and plan is feasible. The AI hook is
a separate cascade-stage surface (u4 gather_step17_popup_split_decisions,
api_gated=True)."""
units = [FakeUnit(label="restructure")]
records = run_step17_popup_gate(
units,
classification_for_unit=_always_popup_classification(),
route_for_label=_route_for_label,
plan_for_classification=plan_details_popup_escalation,
)
assert all(record["ai_called"] is False for record in records)
def test_popup_gate_preserves_unit_metadata():
"""u5 — schema mirrors u4 (unit_index, source_section_ids,
frame_template_id, label, provisional, route_hint)."""
units = [
FakeUnit(
label="restructure",
provisional=True,
frame_template_id="frame_05_overview",
source_section_ids=["s1", "s2"],
)
]
record = run_step17_popup_gate(
units,
classification_for_unit=_no_classification,
route_for_label=_route_for_label,
plan_for_classification=plan_details_popup_escalation,
)[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
assert record["route_hint"] == "ai_adaptation_required"
# ─── Feasible escalation path: stamp popup_escalation_plan + has_popup ──
def test_popup_gate_feasible_path_stamps_plan_and_has_popup_marker():
"""u5 binding contract — when classification is a popup category
(structural_major_overflow / tabular_overflow) and plan is feasible,
the gate stamps popup_escalation_plan and flips has_popup=True."""
units = [FakeUnit(label="restructure", has_popup=False)]
record = run_step17_popup_gate(
units,
classification_for_unit=_always_popup_classification(
"structural_major_overflow"
),
route_for_label=_route_for_label,
plan_for_classification=plan_details_popup_escalation,
)[0]
assert record["gate_status"] == "escalated"
assert record["has_popup"] is True
assert record["popup_escalation_plan"] is not None
plan = record["popup_escalation_plan"]
assert plan["action"] == "details_popup_escalation"
assert plan["feasible"] is True
assert plan["category"] == "structural_major_overflow"
assert plan["needs_split_decision"] is True
def test_popup_gate_feasible_path_for_tabular_overflow():
"""u5 — tabular_overflow is the second popup-mapped category. Both
categories must successfully escalate through this gate."""
units = [FakeUnit(label="light_edit", has_popup=False)]
record = run_step17_popup_gate(
units,
classification_for_unit=_always_popup_classification("tabular_overflow"),
route_for_label=_route_for_label,
plan_for_classification=plan_details_popup_escalation,
)[0]
assert record["gate_status"] == "escalated"
assert record["has_popup"] is True
assert record["popup_escalation_plan"]["category"] == "tabular_overflow"
# ─── Idempotency (q2) ────────────────────────────────────────────────
def test_popup_gate_idempotent_short_circuit_when_has_popup_already_true():
"""u5 q2 — re-running Step 17 on a unit that already carries
has_popup=True must short-circuit. NO duplicate plan, NO re-routing.
The previously stamped marker stays True; gate_status records the
short-circuit explicitly."""
units = [FakeUnit(label="restructure", has_popup=True)]
# Even if classification would emit a feasible plan, idempotency
# short-circuit takes precedence.
record = run_step17_popup_gate(
units,
classification_for_unit=_always_popup_classification(),
route_for_label=_route_for_label,
plan_for_classification=plan_details_popup_escalation,
)[0]
assert record["gate_status"] == "idempotent_short_circuit"
assert (
record["skip_reason"]
== STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
)
assert record["has_popup"] is True
# No duplicate plan emitted — the plan field stays None on the
# short-circuit record (the previously stamped plan lives on the
# unit, not re-stamped here).
assert record["popup_escalation_plan"] is None
def test_popup_gate_lifecycle_first_call_escalates_second_call_short_circuits():
"""u5 q2 lifecycle — the actual rerun contract this gate must satisfy.
Scenario the Codex rewind flagged: a unit starts with
``has_popup=False``; the first call to ``run_step17_popup_gate``
escalates it (gate_status='escalated', record has_popup=True). On
the SAME unit (no manual marker reset), a second call must observe
the persisted ``unit.has_popup=True`` and short-circuit with
``gate_status='idempotent_short_circuit'`` — without re-invoking
the plan callable and without re-stamping the plan on the record.
This locks the unit-side persistence of ``has_popup`` and
``popup_escalation_plan`` (set via ``setattr`` on the feasible
escalation path). Without that persistence, a rerun would re-emit
a duplicate escalation record and re-invoke the router stub —
contradicting q2 / IMP-35 u5.
"""
unit = FakeUnit(label="restructure", has_popup=False)
units = [unit]
plan_calls: list[dict] = []
def _spy_plan(classification):
plan_calls.append(classification)
return plan_details_popup_escalation(classification)
# First call: feasible escalation. Unit should be stamped on its own
# attributes (not just the record) so a rerun can short-circuit.
first = run_step17_popup_gate(
units,
classification_for_unit=_always_popup_classification(
"structural_major_overflow"
),
route_for_label=_route_for_label,
plan_for_classification=_spy_plan,
)[0]
assert first["gate_status"] == "escalated"
assert first["has_popup"] is True
assert first["popup_escalation_plan"] is not None
assert first["popup_escalation_plan"]["feasible"] is True
# Unit-side persistence — this is the contract the rewind required.
assert getattr(unit, "has_popup") is True
assert getattr(unit, "popup_escalation_plan") is not None
assert (
getattr(unit, "popup_escalation_plan")["action"]
== "details_popup_escalation"
)
assert len(plan_calls) == 1
# Second call on the SAME unit (no reset) must short-circuit.
second = run_step17_popup_gate(
units,
classification_for_unit=_always_popup_classification(
"structural_major_overflow"
),
route_for_label=_route_for_label,
plan_for_classification=_spy_plan,
)[0]
assert second["gate_status"] == "idempotent_short_circuit"
assert (
second["skip_reason"]
== STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
)
assert second["has_popup"] is True
# No duplicate plan emitted on the rerun record (the unit-side plan
# is what u6/u7 consume; the gate does not re-stamp on rerun).
assert second["popup_escalation_plan"] is None
# plan callable must NOT be invoked again on the rerun — the
# idempotent short-circuit branch fires before classification or
# plan is consulted.
assert len(plan_calls) == 1, (
"plan_for_classification must NOT be invoked on the second call "
"over an already-escalated unit (q2 idempotent short-circuit)."
)
# Unit-side state stays stamped (not reset by the rerun).
assert getattr(unit, "has_popup") is True
assert getattr(unit, "popup_escalation_plan") is not None
def test_popup_gate_lifecycle_infeasible_path_does_not_persist_marker_on_unit():
"""u5 — symmetric guard. The infeasible_category branch must NOT
set ``unit.has_popup=True`` or stamp ``unit.popup_escalation_plan``.
A rerun on such a unit re-evaluates classification (no short-circuit)
— the marker is reserved for actually-escalated units."""
unit = FakeUnit(label="light_edit", has_popup=False)
units = [unit]
plan_calls: list[dict] = []
def _spy_plan(classification):
plan_calls.append(classification)
return plan_details_popup_escalation(classification)
first = run_step17_popup_gate(
units,
classification_for_unit=_always_popup_classification("minor_overflow"),
route_for_label=_route_for_label,
plan_for_classification=_spy_plan,
)[0]
assert first["gate_status"] == "infeasible_category"
# Unit-side marker NOT stamped on the infeasible path.
assert getattr(unit, "has_popup") is False
assert getattr(unit, "popup_escalation_plan", None) is None
assert len(plan_calls) == 1
second = run_step17_popup_gate(
units,
classification_for_unit=_always_popup_classification("minor_overflow"),
route_for_label=_route_for_label,
plan_for_classification=_spy_plan,
)[0]
# Second call must re-evaluate (no short-circuit) — plan_callable
# invoked again, gate_status still infeasible_category.
assert second["gate_status"] == "infeasible_category"
assert len(plan_calls) == 2
def test_popup_gate_idempotent_short_circuit_does_not_call_plan_callable():
"""u5 q2 — the plan_for_classification callable must NOT be invoked
when idempotency short-circuit fires. Guards against duplicate work."""
calls: list[dict] = []
def _spy_plan(classification):
calls.append(classification)
return plan_details_popup_escalation(classification)
units = [FakeUnit(label="restructure", has_popup=True)]
run_step17_popup_gate(
units,
classification_for_unit=_always_popup_classification(),
route_for_label=_route_for_label,
plan_for_classification=_spy_plan,
)
assert calls == [], (
"plan_for_classification must NOT be invoked when the unit already "
"carries has_popup=True (idempotent short-circuit takes precedence)."
)
# ─── No-classification path ──────────────────────────────────────────
def test_popup_gate_no_classification_skips_with_skip_reason():
"""u5 — when classification_for_unit returns None (no overflow on
this unit), the gate records gate_status='no_classification' and
does NOT call plan_for_classification."""
calls: list[dict] = []
def _spy_plan(classification):
calls.append(classification)
return plan_details_popup_escalation(classification)
units = [FakeUnit(label="restructure")]
record = run_step17_popup_gate(
units,
classification_for_unit=_no_classification,
route_for_label=_route_for_label,
plan_for_classification=_spy_plan,
)[0]
assert record["gate_status"] == "no_classification"
assert (
record["skip_reason"] == STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON
)
assert record["has_popup"] is False
assert record["popup_escalation_plan"] is None
assert calls == []
# ─── Infeasible category path (router defensive guard) ──────────────
def test_popup_gate_infeasible_category_records_skip_reason_and_keeps_has_popup_false():
"""u5 — when classification_for_unit returns a NON-popup category
(e.g., minor_overflow), plan_details_popup_escalation emits
feasible=False. The gate must NOT silently escalate; it records
gate_status='infeasible_category', stamps the plan dict (with
feasible=False) so traces are auditable, and leaves has_popup=False."""
units = [FakeUnit(label="light_edit", has_popup=False)]
record = run_step17_popup_gate(
units,
classification_for_unit=_always_popup_classification("minor_overflow"),
route_for_label=_route_for_label,
plan_for_classification=plan_details_popup_escalation,
)[0]
assert record["gate_status"] == "infeasible_category"
assert (
record["skip_reason"]
== STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON
)
assert record["has_popup"] is False
# plan dict is still recorded for trace auditability (router u3
# emits feasible=False with failure_reason).
assert record["popup_escalation_plan"] is not None
assert record["popup_escalation_plan"]["feasible"] is False
assert "failure_reason" in record["popup_escalation_plan"]
# ─── Mixed batch — per-unit gate decisions are independent ──────────
def test_popup_gate_per_unit_decisions_are_independent():
"""u5 q1 — gate runs per-unit. Mixed batch: one feasible-escalation,
one idempotent short-circuit, one infeasible-category, one
no-classification. Each record reflects its own unit's path."""
units = [
FakeUnit(label="restructure", has_popup=False), # 0 escalate
FakeUnit(label="reject", has_popup=True), # 1 idempotent
FakeUnit(label="light_edit", has_popup=False), # 2 infeasible
FakeUnit(label="use_as_is", has_popup=False), # 3 no_cls
]
def _classification_for_unit(unit):
idx = next(i for i, u in enumerate(units) if u is unit)
if idx == 0:
return {"category": "structural_major_overflow"}
if idx == 1:
return {"category": "tabular_overflow"}
if idx == 2:
return {"category": "minor_overflow"}
return None
records = run_step17_popup_gate(
units,
classification_for_unit=_classification_for_unit,
route_for_label=_route_for_label,
plan_for_classification=plan_details_popup_escalation,
)
assert [r["gate_status"] for r in records] == [
"escalated",
"idempotent_short_circuit",
"infeasible_category",
"no_classification",
]
assert [r["has_popup"] for r in records] == [True, True, False, False]
# ─── route_for_label callable is honored ────────────────────────────
def test_popup_gate_route_for_label_callable_is_honored_per_unit():
"""u5 — route_for_label callable shape mirrors u4 / Step 12 / Step 17
AI_REPAIR. The route_hint must be stamped on every record regardless
of gate path (escalated / idempotent / infeasible / no_cls)."""
units = [
FakeUnit(label="use_as_is"),
FakeUnit(label="light_edit"),
FakeUnit(label="restructure"),
FakeUnit(label="reject"),
FakeUnit(label=None),
]
records = run_step17_popup_gate(
units,
classification_for_unit=_no_classification,
route_for_label=_route_for_label,
plan_for_classification=plan_details_popup_escalation,
)
assert [r["route_hint"] for r in records] == [
"direct_render",
"deterministic_minor_adjustment",
"ai_adaptation_required",
"design_reference_only",
None,
]
# ─── plan_for_classification injection lock ─────────────────────────
def test_popup_gate_plan_for_classification_callable_is_used_not_imported_directly():
"""u5 — plan_for_classification is a callable parameter, not a module-
level import inside the gate. Pipeline injects the real router stub;
tests inject a stub. This keeps the gate decoupled from the router
surface for testability and isolation."""
sentinel_plan = {
"action": "details_popup_escalation",
"feasible": True,
"stub": True,
"category": "structural_major_overflow",
"needs_split_decision": True,
"mapping_source": "test sentinel",
}
def _sentinel_plan_for(_classification):
return sentinel_plan
units = [FakeUnit(label="restructure", has_popup=False)]
record = run_step17_popup_gate(
units,
classification_for_unit=_always_popup_classification(),
route_for_label=_route_for_label,
plan_for_classification=_sentinel_plan_for,
)[0]
assert record["popup_escalation_plan"] is sentinel_plan
assert record["gate_status"] == "escalated"
assert record["has_popup"] is True

View File

@@ -0,0 +1,305 @@
"""IMP-35 (#64) u10 — MDX preservation guard tests.
Stage 2 binding contract (unit u10):
After Step 17 POPUP gate (u5) stamps the unit, composition (u6) binds
the strategy, pipeline (u7) wires the render context, and slide_base
(u8) renders the ``<details>/<summary>`` wrapper, the end-to-end
invariant the user lock requires is:
MDX 원문 무손실 보존 (오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6
line 110, CLAUDE.md 자세히보기 원칙):
- popup body == FULL ``raw_content`` (byte-for-byte verbatim)
- body preview == SUBSET of ``raw_content`` (deterministic
leading-substring CUT — never a rewrite, never a re-summary)
- the original is ALWAYS reachable via the popup; the preview
loses no information because the popup holds the full source
- no structural element is dropped: text_block / table / image
/ ``<details>`` counts in popup body match the original
u6 and u7 each lock pieces of this invariant on their own surface.
u10 locks the END-TO-END no-content-drop guarantee on the rendered
payload — the surface a downstream verifier (Selenium / vision gate)
would inspect — so a future refactor on either u6 or u7 cannot
silently degrade MDX preservation without this test failing first.
Key invariants this file locks:
1. popup_html (full source) preserves every structural element from
raw_content byte-for-byte: bullet lines, paragraph blocks, markdown
table rows, image markdown, and nested ``<details>`` blocks.
2. preview_text is a deterministic leading-substring CUT of
raw_content — ``raw_content.startswith(preview_text)`` holds when
truncation happened.
3. Combined invariant: popup_html holds the FULL original even when
preview_text is shorter, so no content is dropped — the full
source is always reachable via the popup.
4. has_popup=False path: popup_html / preview_text are both None.
There is no popup escalation, so by definition no escalation can
drop content; the frame's partial_html (rendered separately by
slide_base.html and not part of u7 popup wiring) holds the inline
body.
5. AI isolation contract — pure deterministic preservation check;
no anthropic import, no route_ai_fallback path.
Cross-references:
- u6 composition popup binding (popup_body_source = full raw_content):
tests/phase_z2/test_composition_popup_strategy.py
- u7 pipeline wiring (popup_html = popup_body_source verbatim;
preview_text is a deterministic line-budget cut):
tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py
- u8 slide_base.html render surface (autoescaped popup body):
tests/phase_z2/test_slide_base_popup_render.py
- u9 display_strategies.yaml catalog (preserves_original=True for the
popup-bearing strategy):
tests/phase_z2/test_display_strategies_popup.py
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
import pytest
from src.phase_z2_composition import compose_zone_popup_payload
# ─── Synthetic stubs ─────────────────────────────────────────────────
@dataclass
class _StubUnit:
"""Minimal duck-typed CompositionUnit for u10 preservation tests."""
raw_content: str = "MOCK_ORIGINAL_CONTENT"
has_popup: bool = False
popup_escalation_plan: Optional[dict] = None
def _stub_popup_plan() -> dict:
"""Mirror the plan_details_popup_escalation feasible-escalation shape
(u3). u10 only echoes the plan into the unit so the binder reaches
the popup branch; no field is consumed here."""
return {
"action": "details_popup_escalation",
"stub": True,
"feasible": True,
"category": "structural_major_overflow",
"needs_split_decision": True,
"rationale": "MOCK_RATIONALE",
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
}
# ─── Deterministic structural-element counters ──────────────────────
def _count_markdown_bullet_lines(text: str) -> int:
"""Count leading-``-`` markdown bullet lines (- / * / + at line start)."""
return sum(
1 for line in text.splitlines() if re.match(r"^\s*[-*+]\s+", line)
)
def _count_markdown_table_rows(text: str) -> int:
"""Count markdown table rows (lines with ``|`` somewhere)."""
return sum(1 for line in text.splitlines() if "|" in line)
def _count_markdown_images(text: str) -> int:
"""Count markdown image references ``![...](...)``."""
return len(re.findall(r"!\[[^\]]*\]\([^)]+\)", text))
def _count_details_blocks(text: str) -> int:
"""Count nested ``<details>`` blocks in raw_content (rare — used to
lock the invariant even when MDX already carries native popups)."""
return len(re.findall(r"<details\b", text, flags=re.IGNORECASE))
# ─── Sample MDX content (structural diversity for the count guard) ──
_FULL_MDX_SAMPLE = (
"## MOCK_SECTION_TITLE\n"
"\n"
"Paragraph one explaining the MOCK topic. Lorem ipsum dolor sit amet.\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_A | MOCK_B |\n"
"| MOCK_C | MOCK_D |\n"
"\n"
"![MOCK_ALT](mock/path/to/image_a.png)\n"
"![MOCK_ALT_TWO](mock/path/to/image_b.png)\n"
"\n"
"<details><summary>MOCK_NESTED_TRIGGER</summary>"
"<p>MOCK_NESTED_BODY</p></details>\n"
"\n"
"Paragraph two — closing remarks for the MOCK topic.\n"
)
# ─── Popup body = full raw_content (byte-for-byte) ───────────────────
def test_popup_body_byte_for_byte_equal_to_raw_content():
"""u10 — the end-to-end invariant: popup_html on the rendered payload
is byte-for-byte equal to the unit's raw_content. u6 + u7 already
lock this on their own surface; u10 re-asserts on the payload a
downstream verifier (Selenium / vision gate) would inspect."""
unit = _StubUnit(
raw_content=_FULL_MDX_SAMPLE,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = compose_zone_popup_payload(unit, container_height_px=200)
assert payload["popup_html"] == _FULL_MDX_SAMPLE
assert len(payload["popup_html"]) == len(_FULL_MDX_SAMPLE)
def test_popup_body_preserves_bullet_line_count():
"""u10 — text_block count equality. Every bullet line present in
raw_content MUST also be present in popup_html. A future refactor
that accidentally trims popup body to a summary would drop bullets
and fail this guard."""
unit = _StubUnit(
raw_content=_FULL_MDX_SAMPLE,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = compose_zone_popup_payload(unit, container_height_px=200)
assert _count_markdown_bullet_lines(payload["popup_html"]) == (
_count_markdown_bullet_lines(_FULL_MDX_SAMPLE)
)
def test_popup_body_preserves_markdown_table_row_count():
"""u10 — table count equality. Markdown table rows (header / divider
/ data) MUST all survive the popup wiring."""
unit = _StubUnit(
raw_content=_FULL_MDX_SAMPLE,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = compose_zone_popup_payload(unit, container_height_px=200)
assert _count_markdown_table_rows(payload["popup_html"]) == (
_count_markdown_table_rows(_FULL_MDX_SAMPLE)
)
def test_popup_body_preserves_image_reference_count():
"""u10 — image count equality. Markdown ``![alt](src)`` references
MUST all survive (CLAUDE.md: 이미지는 원본 그대로 사용, 크기만 조절 —
popup escalation must not silently drop image refs)."""
unit = _StubUnit(
raw_content=_FULL_MDX_SAMPLE,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = compose_zone_popup_payload(unit, container_height_px=200)
assert _count_markdown_images(payload["popup_html"]) == (
_count_markdown_images(_FULL_MDX_SAMPLE)
)
def test_popup_body_preserves_nested_details_block_count():
"""u10 — nested ``<details>`` blocks. Even when MDX already carries
a native popup, the u10 popup escalation MUST NOT collapse or drop
nested ``<details>`` markers."""
unit = _StubUnit(
raw_content=_FULL_MDX_SAMPLE,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = compose_zone_popup_payload(unit, container_height_px=200)
assert _count_details_blocks(payload["popup_html"]) == (
_count_details_blocks(_FULL_MDX_SAMPLE)
)
# ─── Preview = deterministic leading-substring CUT ──────────────────
def test_preview_text_is_a_leading_substring_of_raw_content_when_truncated():
"""u10 — preview is a CUT, never a rewrite. When truncation happens,
raw_content MUST start with preview_text verbatim (line-boundary
cut semantics; popup body retains the FULL original)."""
unit = _StubUnit(
raw_content=_FULL_MDX_SAMPLE,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
# 2-line budget — far smaller than the multi-line sample.
payload = compose_zone_popup_payload(unit, container_height_px=36)
preview = payload["preview_text"]
assert preview, "preview_text must be non-empty when truncation fires"
assert _FULL_MDX_SAMPLE.startswith(preview), (
"preview_text must be a leading-substring of raw_content "
"(MDX 원문 무손실 보존 — preview is a CUT, never a rewrite)."
)
# The popup body still holds the FULL original — no information loss.
assert payload["popup_html"] == _FULL_MDX_SAMPLE
def test_no_content_drop_when_preview_is_shorter_than_popup_body():
"""u10 — combined no-drop invariant. preview_text may be a strict
prefix of popup_html (shorter), but the popup body always holds the
full original. The user can always reach every line of the source
via the popup, even when the inline preview shows only the head."""
unit = _StubUnit(
raw_content=_FULL_MDX_SAMPLE,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = compose_zone_popup_payload(unit, container_height_px=36)
preview = payload["preview_text"]
popup_body = payload["popup_html"]
# preview is strictly shorter when truncation fires.
assert len(preview) < len(popup_body)
# popup_body is the FULL original — every line of raw_content is
# present in popup_body regardless of the inline preview budget.
for line in _FULL_MDX_SAMPLE.splitlines():
assert line in popup_body, (
f"MDX preservation guard violated — line {line!r} not present "
f"in popup body."
)
# ─── has_popup=False path: no popup, no escalation, no drop ─────────
def test_no_popup_path_yields_no_popup_html_no_preview_text():
"""u10 — when the Step 17 POPUP gate did not fire, no popup
escalation happens. popup_html and preview_text are both None.
By construction this branch cannot drop content (no escalation),
and the frame's partial_html (rendered separately by slide_base
and not part of u7 popup wiring) holds the inline body."""
unit = _StubUnit(raw_content=_FULL_MDX_SAMPLE, has_popup=False)
payload = compose_zone_popup_payload(unit, container_height_px=200)
assert payload["has_popup"] is False
assert payload["popup_html"] is None
assert payload["preview_text"] is None
# ─── AI isolation contract (structural import lock) ─────────────────
def test_popup_mdx_preservation_module_has_no_ai_imports():
"""u10 — preservation guard MUST stay AI-free. Structural guard:
composition module (where compose_zone_popup_payload lives) is
allowed to consult the catalog and unit state, never the Anthropic
SDK / route_ai_fallback path. Mirrors u6 / u7 import-isolation
pattern (feedback_ai_isolation_contract)."""
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

View File

@@ -0,0 +1,413 @@
"""IMP-35 (#64) u8 — slide_base.html details/summary popup render tests.
Stage 2 wiring contract (unit u8):
u7 (``compose_zone_popup_payload`` in ``src/phase_z2_pipeline.py``) wired
four uniform per-zone render-context keys into every ``zones_data``
entry::
has_popup : bool
popup_html : str | None (FULL ``raw_content`` verbatim when
has_popup=True)
preview_text : str | None
popup_binding : dict | None (u6 binding — includes
``display_strategy``,
``detail_trigger.{placement,label}``)
u8 is the slide_base.html consumer side: it renders a JS-free
``<details>/<summary>`` wrapper inside the zone div when
``zone.has_popup`` is True. The summary acts as the toggle, the body
holds the FULL ``popup_html``. The frame's existing ``partial_html``
remains the zone body (inline preview / FIT-version of content); the
popup body holds the original — never replaces the partial.
Key invariants this file locks:
1. has_popup=False → no ``<details>`` element emitted (byte-identical
contract for non-popup zones, no regression to pre-u8).
2. has_popup=True → exactly one ``<details class="zone__popup-details
zone__popup-details--<placement>">`` per zone with a ``<summary>``
trigger and a ``<div class="zone__popup-body">`` holding the full
popup_html.
3. Popup body content is HTML-escaped (Jinja2 autoescape is ON for
slide_base.html — popup_html is plain MDX text, never raw HTML).
A ``<script>`` literal in raw_content MUST appear escaped, never as
an executable tag.
4. Whitespace inside the popup body is preserved via the
``.zone__popup-body`` CSS contract (``white-space: pre-wrap``).
Locks MDX 원문 무손실 보존 — newline structure of raw_content is
visible verbatim (오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6
line 110).
5. Placement / label / strategy id are READ from
``zone.popup_binding.detail_trigger.{placement,label}`` and
``zone.popup_binding.display_strategy`` — no hardcoded literal
drift from the catalog
(``templates/phase_z2/regions/display_strategies.yaml``).
6. Defensive defaults: a popup zone whose ``popup_binding`` is ``None``
(the unrenderable empty-plan branch of the pipeline composer
stamps ``popup_binding=None``) still renders sane defaults
(``placement=top-right``, ``label=details``,
``display_strategy=inline_preview_with_details``) — no
KeyError/AttributeError on the Jinja2 path.
7. The zone div carries ``data-has-popup="1"`` exactly when
has_popup=True — downstream observability anchor.
Cross-references:
- u5 Step 17 POPUP gate (stamps the marker on the unit):
tests/phase_z2/test_phase_z2_step17_popup_gate.py
- u6 composition popup binding (produces the binding dict u8 reads):
tests/phase_z2/test_composition_popup_strategy.py
- u7 pipeline composer wiring (puts the four keys into zones_data):
tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py
- display strategy catalog (placement / label source of truth):
templates/phase_z2/regions/display_strategies.yaml
"""
from __future__ import annotations
import re
import pytest
from src.phase_z2_pipeline import render_slide
# ─── Test scaffolding ───────────────────────────────────────────────
def _layout_css() -> dict:
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
def _no_popup_zone(**overrides) -> dict:
"""Baseline non-popup zone (matches the four-key wiring from u7
when has_popup=False — popup_binding may be None for the empty plan
branch or the u6 ``inline_full`` echo for renderable no-popup units;
here we exercise the empty-plan branch where popup_binding=None)."""
base = {
"position": "primary",
"template_id": "__empty__",
"slot_payload": {},
"has_popup": False,
"popup_html": None,
"preview_text": None,
"popup_binding": None,
}
base.update(overrides)
return base
def _popup_binding(
*,
placement: str = "top-right",
label: str = "details",
strategy: str = "inline_preview_with_details",
) -> dict:
"""Matches the u6 binding shape (subset relevant to u8 render)."""
return {
"display_strategy": strategy,
"detail_trigger": {"placement": placement, "label": label},
"has_popup": True,
"popup_escalation_plan": {"action": "details_popup_escalation"},
}
def _popup_zone(
*,
popup_html: str = "MOCK_POPUP_BODY_FULL_ORIGINAL",
binding: dict | None = None,
**overrides,
) -> dict:
"""Baseline popup zone (has_popup=True) for u8 rendering tests."""
base = {
"position": "primary",
"template_id": "__empty__",
"slot_payload": {},
"has_popup": True,
"popup_html": popup_html,
"preview_text": "MOCK_PREVIEW",
"popup_binding": binding if binding is not None else _popup_binding(),
}
base.update(overrides)
return base
def _render(zones: list[dict]) -> str:
return render_slide(
slide_title="t",
slide_footer=None,
zones_data=zones,
layout_preset="single",
layout_css=_layout_css(),
gap_px=14,
)
# ─── Invariant 1 — no details on no-popup zone ───────────────────────
def _body_section(html: str) -> str:
"""Return the HTML between </style> and </body> so assertions can
target the rendered body content without false positives on the
in-template CSS block (which legitimately declares the popup CSS
classes regardless of whether any zone emits a popup)."""
end_of_style = html.index("</style>") + len("</style>")
return html[end_of_style:]
def test_zone_without_popup_does_not_render_details_element():
"""has_popup=False → no ``<details class="zone__popup-details">``
element emitted. The CSS class declarations stay in <style> (CSS
contract lives once in the template); what MUST NOT appear is the
element instance in the body."""
body = _body_section(_render([_no_popup_zone()]))
assert "<details" not in body
assert "zone__popup-details" not in body
assert "zone__popup-summary" not in body
assert "zone__popup-body" not in body
assert "data-has-popup" not in body
def test_zone_without_popup_keeps_existing_zone_attrs():
"""No regression on the zone div for non-popup zones — the
data-zone-position + data-template-id contract from pre-u8 stays
intact."""
html = _render([_no_popup_zone()])
assert 'data-zone-position="primary"' in html
assert 'data-template-id="__empty__"' in html
# ─── Invariant 2 — exactly one details on popup zone ────────────────
def test_zone_with_popup_renders_details_summary_body_triple():
"""has_popup=True → exactly one ``<details class="zone__popup-details
...">`` per zone with a ``<summary class="zone__popup-summary">``
trigger AND a ``<div class="zone__popup-body">`` body."""
html = _render([_popup_zone()])
details_matches = re.findall(
r'<details class="zone__popup-details[^"]*"', html
)
assert len(details_matches) == 1
assert 'class="zone__popup-summary"' in html
assert 'class="zone__popup-body"' in html
def test_zone_with_popup_marks_zone_div_with_data_has_popup_attr():
"""The zone div carries ``data-has-popup="1"`` exactly when
has_popup=True (downstream observability anchor)."""
html = _render([_popup_zone()])
assert 'data-has-popup="1"' in html
def test_zone_without_popup_does_not_carry_data_has_popup_attr():
"""has_popup=False zone div MUST NOT carry the data-has-popup
attribute (otherwise the observability anchor lies)."""
html = _render([_no_popup_zone()])
assert "data-has-popup" not in html
# ─── Invariant 3 — escaping (XSS safety + literal preservation) ──────
def test_popup_body_html_special_chars_are_escaped():
"""popup_html is plain MDX text. A literal ``<script>`` in
raw_content MUST appear escaped (Jinja2 autoescape ON), never as an
executable tag. Locks XSS guard + MDX-as-text contract."""
payload = "<script>alert(1)</script>"
html = _render([_popup_zone(popup_html=payload)])
# Raw <script> tag MUST NOT appear inside popup body.
assert "<script>alert(1)</script>" not in html
# Escaped form MUST appear (& -> &amp; lt -> &lt;).
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in html
def test_popup_body_ampersand_and_quotes_are_escaped():
"""Literal ``&`` ``<`` ``>`` ``"`` ``'`` in popup_html are
autoescaped — round-trip safe through the HTML body."""
payload = "A & B < C > D \" E ' F"
html = _render([_popup_zone(popup_html=payload)])
assert "&amp;" in html
assert "&lt;" in html
assert "&gt;" in html
# Raw form of the un-escaped ampersand sequence must not appear.
assert "A & B < C > D" not in html
# ─── Invariant 4 — whitespace preservation contract ──────────────────
def test_popup_body_preserves_newlines_in_content_verbatim():
"""popup_html with newlines is emitted verbatim into the body —
no collapse, no trim. Visual newline preservation is the CSS
contract (.zone__popup-body { white-space: pre-wrap }) but the
underlying text MUST carry the newlines through to the HTML."""
payload = "line one\nline two\nline three"
html = _render([_popup_zone(popup_html=payload)])
# The exact body text appears between the body div tags.
body_match = re.search(
r'<div class="zone__popup-body">(.*?)</div>',
html,
re.DOTALL,
)
assert body_match is not None
assert body_match.group(1) == payload
def test_popup_body_css_class_declares_whitespace_pre_wrap():
"""The CSS contract that makes the preserved newlines actually
visible is ``.zone__popup-body { white-space: pre-wrap }`` in
slide_base.html. Locks the styling axis — without this rule the
preserved newlines collapse in render."""
html = _render([_popup_zone()])
# Compress whitespace before regex match (CSS block formatting
# may vary across edits).
flat = re.sub(r"\s+", " ", html)
assert ".zone__popup-body" in flat
assert "white-space: pre-wrap" in flat
def test_popup_body_holds_full_raw_content_verbatim():
"""popup_html (FULL raw_content from u7 / u6) appears in the body
char-for-char (modulo HTML escape on special chars). No trim, no
summary substitution — MDX 원문 무손실 보존 (오답노트 #5)."""
payload = (
"## MOCK_SECTION_TITLE\n\n"
"- bullet 1\n"
"- bullet 2\n"
"- bullet 3 with **bold**\n"
)
html = _render([_popup_zone(popup_html=payload)])
# Extract the popup body content.
body_match = re.search(
r'<div class="zone__popup-body">(.*?)</div>',
html,
re.DOTALL,
)
assert body_match is not None
# ** stays as ** (autoescape only touches HTML special chars).
assert body_match.group(1) == payload
# ─── Invariant 5 — placement / label / strategy from binding ─────────
def test_popup_placement_class_modifier_reflects_binding_placement():
"""The placement (top-right / top-left / bottom-right / bottom-left)
is READ from zone.popup_binding.detail_trigger.placement and
surfaces as the BEM modifier on the details element."""
for placement in ("top-right", "top-left", "bottom-right", "bottom-left"):
zone = _popup_zone(binding=_popup_binding(placement=placement))
html = _render([zone])
assert f"zone__popup-details--{placement}" in html
assert f'data-popup-placement="{placement}"' in html
def test_popup_summary_label_reflects_binding_label():
"""The summary trigger text is READ from
zone.popup_binding.detail_trigger.label — no hardcoded literal in
the template (catalog drift guard)."""
zone = _popup_zone(binding=_popup_binding(label="자세히"))
html = _render([zone])
assert ">자세히</summary>" in html
def test_popup_data_display_strategy_attr_reflects_binding_strategy_id():
"""The details element carries data-display-strategy=<strategy_id>
from the binding so downstream observability (DOM scrape, test
introspection) can identify which catalog strategy fired."""
zone = _popup_zone(binding=_popup_binding(strategy="details_only"))
html = _render([zone])
assert 'data-display-strategy="details_only"' in html
# ─── Invariant 6 — defensive defaults (binding=None / missing keys) ──
def test_popup_zone_with_binding_none_uses_defensive_defaults():
"""The unrenderable empty-plan branch of the pipeline composer
stamps popup_binding=None (u7 wiring). u8 MUST render sane defaults
rather than KeyError/AttributeError on the Jinja2 path: placement =
top-right, label = 'details', strategy =
inline_preview_with_details."""
zone = _popup_zone(binding=None)
html = _render([zone])
assert "zone__popup-details--top-right" in html
assert ">details</summary>" in html
assert 'data-display-strategy="inline_preview_with_details"' in html
def test_popup_zone_with_partial_binding_falls_back_per_missing_key():
"""A binding dict missing detail_trigger (defensive — should not
happen in normal u6 output, but the template MUST be robust) falls
back to the same defaults as binding=None."""
partial_binding = {
"display_strategy": "inline_preview_with_details",
# detail_trigger intentionally omitted.
}
zone = _popup_zone(binding=partial_binding)
html = _render([zone])
assert "zone__popup-details--top-right" in html
assert ">details</summary>" in html
# ─── Invariant 7 — multi-zone rendering ─────────────────────────────
def test_only_popup_zones_emit_details_in_multi_zone_slide():
"""Mixed slide: zone A has_popup=False, zone B has_popup=True.
Exactly ONE <details> block in the rendered HTML, on zone B only."""
zone_a = _no_popup_zone(position="left")
zone_b = _popup_zone(position="right")
html = _render([
zone_a,
zone_b,
])
matches = re.findall(r'<details class="zone__popup-details', html)
assert len(matches) == 1
# zone B is the right grid-area — popup details should sit within
# the zone whose div carries data-zone-position="right".
right_zone_block = re.search(
r'<div class="zone" data-zone-position="right"[^>]*>(.*?)</div>\s*</div>',
html,
re.DOTALL,
)
# If the regex above doesn't anchor (template HTML evolves), fall
# back to checking the details element appears AFTER the right
# zone marker but BEFORE the next zone marker.
if right_zone_block is None:
right_idx = html.index('data-zone-position="right"')
assert html.find("zone__popup-details", right_idx) > right_idx
# And the left zone block should NOT contain the popup.
left_end = html.index('data-zone-position="right"')
assert "zone__popup-details" not in html[:left_end]
else:
assert "zone__popup-details" in right_zone_block.group(1)
# ─── Determinism + smoke check ──────────────────────────────────────
def test_popup_render_is_deterministic_across_calls():
"""Two calls with identical input produce byte-identical HTML —
no order-dependence on dict iteration, no time-based identifier."""
zone = _popup_zone(popup_html="MOCK\nMULTI\nLINE")
assert _render([zone]) == _render([zone])
def test_popup_emits_no_javascript_on_render_path():
"""CLAUDE.md 자세히보기 contract — HTML-native ``<details>`` ONLY,
no JavaScript hook on the popup render path (print auto-expand is a
separate OOS axis per IMP-35 scope-lock)."""
html = _render([_popup_zone()])
# The slide_base.html embedded-mode <script> is allowed (separate
# axis). What MUST NOT appear is any popup-specific JS handler.
# Search the popup details block for inline JS attributes.
details_block_match = re.search(
r'<details class="zone__popup-details.*?</details>',
html,
re.DOTALL,
)
assert details_block_match is not None
block = details_block_match.group(0)
for js_attr in ("onclick=", "onload=", "onopen=", "ontoggle="):
assert js_attr not in block
# And no <script> tag inside the details body.
assert "<script" not in block

View File

@@ -21,8 +21,10 @@ 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,
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON,
OverflowCascadeStage,
gather_step17_ai_repair_proposals,
gather_step17_popup_split_decisions,
)
@@ -163,6 +165,160 @@ def test_gather_with_empty_units_returns_empty_list():
assert records == []
# ─── IMP-35 u4: POPUP cascade AI split-decision contract (API gated) ─────
def test_popup_split_decision_api_gated_reason_constant_value():
"""u4 binding contract — API-gated skip_reason is a stable, machine-readable
constant that downstream consumers can distinguish from the AI_REPAIR
block reason. Never collide with STEP17_AI_REPAIR_BLOCKED_REASON."""
assert (
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON
== "step17_popup_split_decision_api_gated"
)
assert (
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON
!= STEP17_AI_REPAIR_BLOCKED_REASON
)
def test_popup_split_decision_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_popup_split_decisions(
units, route_for_label=_route_for_label
)
assert len(records) == 3
def test_popup_split_decision_cascade_stage_is_popup():
"""u4 — cascade_stage must mark these records as the POPUP stage, NOT
AI_REPAIR. This lets consumers multiplex POPUP and AI_REPAIR records on
the same retry trace without ambiguity."""
units = [FakeUnit(label="restructure", provisional=True)]
record = gather_step17_popup_split_decisions(
units, route_for_label=_route_for_label
)[0]
assert record["cascade_stage"] == OverflowCascadeStage.POPUP.value
assert record["cascade_stage"] != OverflowCascadeStage.AI_REPAIR.value
def test_popup_split_decision_api_gated_flag_true():
"""u4 — api_gated=True everywhere. The flag is the primary state signal
consumers read to decide whether the AI hook is active."""
units = [FakeUnit(label="restructure", provisional=True)]
record = gather_step17_popup_split_decisions(
units, route_for_label=_route_for_label
)[0]
assert record["api_gated"] is True
def test_popup_split_decision_ai_called_is_false_and_no_proposal():
"""u4 — ai_called=False, split_decision=None, error=None. The hook is the
contract surface only; the Anthropic API is NOT invoked at u4."""
units = [FakeUnit(label="restructure", provisional=True)]
record = gather_step17_popup_split_decisions(
units, route_for_label=_route_for_label
)[0]
assert record["ai_called"] is False
assert record["split_decision"] is None
assert record["error"] is None
def test_popup_split_decision_skip_reason_is_api_gated():
"""u4 — every record carries the API-gated skip_reason regardless of
label / provisional / route_hint."""
units = [
FakeUnit(label="restructure", provisional=True),
FakeUnit(label="reject", provisional=False),
FakeUnit(label="use_as_is", provisional=True),
FakeUnit(label=None, provisional=False),
]
records = gather_step17_popup_split_decisions(
units, route_for_label=_route_for_label
)
for record in records:
assert (
record["skip_reason"]
== STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON
)
def test_popup_split_decision_honors_route_for_label():
"""u4 — route_for_label callable is applied per unit. Verifies the hook
surface accepts the same label→route mapping as the AI_REPAIR path."""
units = [
FakeUnit(label="restructure", provisional=True),
FakeUnit(label="reject", provisional=False),
FakeUnit(label="use_as_is", provisional=True),
FakeUnit(label="light_edit", provisional=False),
FakeUnit(label=None, provisional=False),
]
records = gather_step17_popup_split_decisions(
units, route_for_label=_route_for_label
)
assert [r["route_hint"] for r in records] == [
"ai_adaptation_required",
"design_reference_only",
"direct_render",
"deterministic_minor_adjustment",
None,
]
def test_popup_split_decision_preserves_unit_metadata():
"""u4 — schema mirrors gather_step17_ai_repair_proposals (unit_index,
source_section_ids, frame_template_id, label, provisional)."""
units = [
FakeUnit(
label="restructure",
provisional=True,
frame_template_id="frame_05_overview",
source_section_ids=["s1", "s2"],
)
]
record = gather_step17_popup_split_decisions(
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_popup_split_decision_with_empty_units_returns_empty_list():
records = gather_step17_popup_split_decisions(
[], route_for_label=_route_for_label
)
assert records == []
def test_popup_split_decision_record_schema_disjoint_from_ai_repair_extras():
"""u4 — POPUP record must carry api_gated + split_decision keys; the
AI_REPAIR record carries proposal (not split_decision). This lock keeps
the two contract surfaces machine-distinguishable on the retry trace."""
units = [FakeUnit(label="restructure", provisional=True)]
popup_rec = gather_step17_popup_split_decisions(
units, route_for_label=_route_for_label
)[0]
ai_repair_rec = gather_step17_ai_repair_proposals(
units, route_for_label=_route_for_label
)[0]
# POPUP-specific keys
assert "api_gated" in popup_rec
assert "split_decision" in popup_rec
# AI_REPAIR-specific key
assert "proposal" in ai_repair_rec
# Disjoint payload keys (the two contracts must NOT cross-leak):
assert "proposal" not in popup_rec
assert "split_decision" not in ai_repair_rec
assert "api_gated" not in ai_repair_rec
# ─── Structural guarantee: u9 must NOT import route_ai_fallback / anthropic ─