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