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 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>
This commit is contained in:
@@ -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}"
|
||||
)
|
||||
|
||||
333
tests/phase_z2/test_composition_popup_strategy.py
Normal file
333
tests/phase_z2/test_composition_popup_strategy.py
Normal 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
|
||||
192
tests/phase_z2/test_display_strategies_popup.py
Normal file
192
tests/phase_z2/test_display_strategies_popup.py
Normal 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."
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
419
tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py
Normal file
419
tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py
Normal 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
|
||||
209
tests/phase_z2/test_phase_z2_router_popup.py
Normal file
209
tests/phase_z2/test_phase_z2_router_popup.py
Normal 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"
|
||||
551
tests/phase_z2/test_phase_z2_step17_popup_gate.py
Normal file
551
tests/phase_z2/test_phase_z2_step17_popup_gate.py
Normal 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
|
||||
305
tests/phase_z2/test_popup_mdx_preservation.py
Normal file
305
tests/phase_z2/test_popup_mdx_preservation.py
Normal 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"
|
||||
"\n"
|
||||
"\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 ```` 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
|
||||
413
tests/phase_z2/test_slide_base_popup_render.py
Normal file
413
tests/phase_z2/test_slide_base_popup_render.py
Normal 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 (& -> & lt -> <).
|
||||
assert "<script>alert(1)</script>" 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 "&" in html
|
||||
assert "<" in html
|
||||
assert ">" 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
|
||||
@@ -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 ─
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user