Files
C.E.L_Slide_test2/tests/phase_z2/test_composition_popup_strategy.py
kyeongmin f3ef4d917c 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>
2026-05-23 07:36:57 +09:00

334 lines
14 KiB
Python

"""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