From f3ef4d917c775d497fbed8109042f46635e66f1a Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Sat, 23 May 2026 07:36:57 +0900 Subject: [PATCH] feat(#64): IMP-35 details_popup_escalation u1~u10 + Stage 3 R7 anchor re-pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the production + test surface for the Step 17 cascade POPUP terminal (DETERMINISTIC -> POPUP -> AI_REPAIR -> USER_OVERRIDE) per Stage 2 plan R2. u11 (baseline-red invariance gate) was already landed in 7c93031 ahead of this commit; this commit completes u1~u10 plus the Stage 3 R7 follow-up anchor re-pin for test_imp17_comment_anchor.py. Implementation units (Stage 2 R2 contract): u1 frame_reselect_insufficient failure_type + post-frame remeasure (q4) - src/phase_z2_failure_router.py, src/phase_z2_pipeline.py u2 NEXT_ACTION_BY_FAILURE row + impl_status flip - src/phase_z2_failure_router.py u3 Router details_popup_escalation MISSING->IMPLEMENTED + executor stub - src/phase_z2_router.py u4 step17.py AI split-decision contract (POPUP cascade_stage + route_for_label + skip_reason); API gated - src/phase_z2_ai_fallback/step17.py u5 Step 17 POPUP gate executor; popup_escalation_plan + has_popup marker - src/phase_z2_pipeline.py, src/phase_z2_ai_fallback/step17.py u6 Composition popup binding -- yaml strategy -> zone payload - src/phase_z2_composition.py u7 Pipeline composer -> render_slide wiring (popup_html / preview_text / has_popup) - src/phase_z2_pipeline.py u8 slide_base.html
/ popup wrapper - templates/phase_z2/slide_base.html u9 display_strategies.yaml inline_preview + popup metadata - templates/phase_z2/regions/display_strategies.yaml u10 MDX preservation invariant: popup=full source / body=summary or subset (asserted by tests/phase_z2/test_popup_mdx_preservation.py) u11 (already in 7c93031) -- baseline-red invariance gate Stage 3 R7 follow-up (anchor re-pin, test-only): - tests/orchestrator_unit/test_imp17_comment_anchor.py Pre-anchor additions in src/phase_z2_pipeline.py (u1 / u5 / u7) shifted the restructure/reject route-hint comments 578/579 -> 586/587. Re-pinned the two guard tests (and docstring re-pin lineage 564 -> 570 -> 578 -> 586). Production code untouched. Verification (Stage 4 R1): pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py -> 2 passed / 0.02s pytest -q <10 IMP-35 unit files in tests/phase_z2 + tests/phase_z2_ai_fallback> -> 136 passed / 15.94s Baseline-red invariance gate (tests/test_imp47b_step12_ai_wiring.py + tests/test_phase_z2_ai_fallback_config.py) -> 4 failed / 6 passed; FAILED set === IMP35_BASELINE_RED_NODE_IDS (frozen registry from 7c93031). Contract holds. Codex Stage 4 R1 = YES (independent verify). Guardrails honored: - MDX content preservation: popup carries full source, body holds summary or subset only (CLAUDE.md 자세히보기 원칙; feedback_phase_z_spacing_direction -- capacity expanded, no margin shrink). - AI isolation contract: Step 17 POPUP gate is deterministic; AI hook surface is split-decision contract only, API call gated. - No hardcoding: escalation thresholds derived from existing overflow detector outputs; preview_chars deterministic from container px. - 1 commit = 1 decision unit: u1~u10 land together as the planned production surface; u11 was deliberately split into 7c93031 as Stage 3 R7 carve-out, and the R7 anchor re-pin rides with this commit because it is the direct shift consequence of the u1/u5/u7 pre-anchor additions. - Scope-locked: .claude/settings.json explicitly excluded (Stage 4 exit report contract). Out of scope (per Stage 1 + Stage 2): - AI_REPAIR API activation (post IMP-35 axis). - IMP-34 zone resize, IMP-36 responsive fit (chain partners, separate issues). - Print-time auto-expand JavaScript for
. - 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) --- src/phase_z2_ai_fallback/step17.py | 241 ++++++++ src/phase_z2_composition.py | 315 ++++++++++ src/phase_z2_failure_router.py | 80 ++- src/phase_z2_pipeline.py | 119 +++- src/phase_z2_router.py | 125 +++- .../phase_z2/regions/display_strategies.yaml | 24 + templates/phase_z2/slide_base.html | 77 ++- .../test_imp17_comment_anchor.py | 25 +- .../test_composition_popup_strategy.py | 333 +++++++++++ .../phase_z2/test_display_strategies_popup.py | 192 ++++++ .../test_phase_z2_failure_router_cascade.py | 133 +++++ .../test_phase_z2_pipeline_popup_wiring.py | 419 +++++++++++++ tests/phase_z2/test_phase_z2_router_popup.py | 209 +++++++ .../test_phase_z2_step17_popup_gate.py | 551 ++++++++++++++++++ tests/phase_z2/test_popup_mdx_preservation.py | 305 ++++++++++ .../phase_z2/test_slide_base_popup_render.py | 413 +++++++++++++ tests/phase_z2_ai_fallback/test_step17.py | 156 +++++ 17 files changed, 3692 insertions(+), 25 deletions(-) create mode 100644 tests/phase_z2/test_composition_popup_strategy.py create mode 100644 tests/phase_z2/test_display_strategies_popup.py create mode 100644 tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py create mode 100644 tests/phase_z2/test_phase_z2_router_popup.py create mode 100644 tests/phase_z2/test_phase_z2_step17_popup_gate.py create mode 100644 tests/phase_z2/test_popup_mdx_preservation.py create mode 100644 tests/phase_z2/test_slide_base_popup_render.py diff --git a/src/phase_z2_ai_fallback/step17.py b/src/phase_z2_ai_fallback/step17.py index e655bf0..b69d867 100644 --- a/src/phase_z2_ai_fallback/step17.py +++ b/src/phase_z2_ai_fallback/step17.py @@ -73,6 +73,247 @@ STEP17_AI_REPAIR_BLOCKED_REASON = ( ) +# IMP-35 (#64) u4 — POPUP cascade AI split-decision contract (API gated). +# +# Step 17 POPUP escalation needs an AI hook to decide *what content* stays in +# the body (summary/subset) vs. moves into the
popup (full MDX). +# That hook is the AI split-decision contract. u4 ships the contract surface +# (function signature + record schema + cascade_stage + route_for_label + +# skip_reason) WITHOUT enabling the Anthropic API. The deterministic POPUP +# gate executor (u5) runs ahead of this contract and stamps +# popup_escalation_plan + has_popup; u4's hook is a forward-compatible +# placeholder so downstream wiring (u5 executor / future IMP activating the +# API) can rely on a stable schema. ``api_gated=True`` on every record makes +# the gate state machine-readable; ``ai_called`` stays False everywhere. +# +# Per feedback_ai_isolation_contract: AI = fallback path only. The contract +# function MUST NOT import route_ai_fallback, the u4 client (despite name +# collision — u4 here is the IMP-35 unit, not the Step 12 client module), +# or any anthropic SDK symbol. Structural import guards in the test surface +# already enforce this and continue to hold after this change. +STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON = ( + "step17_popup_split_decision_api_gated" +) + + +# IMP-35 (#64) u5 — deterministic POPUP gate executor (cascade-terminal). +# +# Runs AFTER the DETERMINISTIC stage exhausts and BEFORE the AI_REPAIR +# cascade stage (canonical OVERFLOW_CASCADE_ORDER). Per unit: +# +# 1. Idempotency (q2): if a unit carries ``has_popup=True`` already, +# ``run_step17_popup_gate`` short-circuits with +# ``gate_status="idempotent_short_circuit"``. No duplicate plan, +# no re-routing. Re-running Step 17 on already-escalated units is +# safe — the gate emits a deterministic record per unit but does +# NOT re-stamp the plan or flip the marker. The persistence of +# ``has_popup`` and ``popup_escalation_plan`` on the unit itself +# (see step 4 below) is what makes the second call observe the +# stamp from the first call and short-circuit correctly. +# 2. Classification: ``classification_for_unit(unit)`` returns the +# fit_classifier row associated with this unit (or ``None`` if the +# unit has no overflow on this run). +# 3. Plan: ``plan_for_classification(cls)`` is the router u3 stub +# (``src.phase_z2_router.plan_details_popup_escalation``). Only +# the categories in ``POPUP_ESCALATION_CATEGORIES`` of the router +# surface (currently ``structural_major_overflow`` and +# ``tabular_overflow``) emit a feasible plan; anything else falls +# through to ``gate_status="infeasible_category"`` so the gate +# never silently escalates the wrong overflow shape. +# 4. Feasible plan → record stamps ``popup_escalation_plan`` and +# flips ``has_popup=True`` in the returned record AND persists +# the same two fields on the unit via ``setattr`` (``unit.has_popup`` +# and ``unit.popup_escalation_plan``). The unit-side persistence +# is the q2 idempotency contract: a second call to +# ``run_step17_popup_gate`` over the same unit reads +# ``unit.has_popup=True`` at step 1 and short-circuits before +# classification / plan callable invocation. The marker is also +# what u6 composition binding and u7 render wiring read from the +# unit downstream. +# +# AI isolation contract: NO Anthropic call inside this gate. The +# deterministic split between popup body (full MDX) and preview +# (summary/subset) is composed downstream from container px budgets +# (q3 — preview_chars derives from container px telemetry already on +# the retry_trace). The u4 AI hook (``gather_step17_popup_split_decisions``) +# sits at the same cascade stage but is API-gated (``api_gated=True``) +# and never invoked from this deterministic path. ``ai_called=False`` on +# every record this gate emits. +# +# cascade_stage="popup" on every record so Step 17 retry-trace consumers +# can multiplex DETERMINISTIC / POPUP / AI_REPAIR records without +# ambiguity. The schema mirrors :func:`gather_step17_popup_split_decisions` +# (unit_index / source_section_ids / frame_template_id / label / +# route_hint / provisional) PLUS u5-specific fields: +# ``gate_status`` / ``popup_escalation_plan`` / ``has_popup`` / +# ``skip_reason`` (only set for non-escalated gate_status values). +STEP17_POPUP_GATE_ESCALATED_REASON = "step17_popup_gate_escalated" +STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON = ( + "step17_popup_gate_idempotent_short_circuit" +) +STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON = ( + "step17_popup_gate_infeasible_category" +) +STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON = ( + "step17_popup_gate_no_classification_for_unit" +) + + +def run_step17_popup_gate( + units: Iterable[Any], + *, + classification_for_unit: Callable[[Any], dict | None], + route_for_label: Callable[[str | None], str | None], + plan_for_classification: Callable[[dict], dict], +) -> list[dict]: + """Deterministic POPUP gate executor for Step 17 cascade (IMP-35 u5). + + See module-level block comment (immediately above) for the full + contract — idempotency (q2), classification source, router u3 stub + coupling, AI isolation, and cascade_stage multiplexing. + + Args: + units: provisional / non-provisional Step 17 units. The gate is + agnostic to provisional state; the marker ``has_popup`` flows + from this function regardless. + classification_for_unit: maps a unit to its fit_classifier + classification row (or ``None`` if the unit has no overflow). + Tests inject a fake dict / lookup; the pipeline composes + this from ``fit_classification.classifications`` matched by + ``zone_position``. + route_for_label: same callable shape as + :func:`gather_step17_ai_repair_proposals` / + :func:`gather_step17_popup_split_decisions`. The route hint + is stamped on every record for downstream consumers. + plan_for_classification: the router u3 stub + (``src.phase_z2_router.plan_details_popup_escalation``). + Injected as a callable so this module stays decoupled from + the router surface and tests can stub the plan output. + + Returns: + list[dict] — one record per unit. Records carry + ``cascade_stage="popup"`` and ``ai_called=False`` everywhere. + Feasible-escalation records also carry + ``popup_escalation_plan`` (the router u3 plan dict) and + ``has_popup=True``. Non-escalation records carry a + ``skip_reason`` enum. + """ + records: list[dict] = [] + for index, unit in enumerate(units): + label = getattr(unit, "label", None) + already_escalated = bool(getattr(unit, "has_popup", False)) + record: dict = { + "unit_index": index, + "source_section_ids": list( + getattr(unit, "source_section_ids", []) or [] + ), + "frame_template_id": getattr(unit, "frame_template_id", None), + "label": label, + "route_hint": route_for_label(label), + "provisional": bool(getattr(unit, "provisional", False)), + "cascade_stage": OverflowCascadeStage.POPUP.value, + "ai_called": False, + "has_popup": already_escalated, + "popup_escalation_plan": None, + "gate_status": None, + "skip_reason": None, + } + if already_escalated: + # q2 idempotency — short-circuit. The previously stamped + # popup_escalation_plan stays on the unit (carried by u6/u7 + # composition); this gate does NOT re-emit it. + record["gate_status"] = "idempotent_short_circuit" + record["skip_reason"] = ( + STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON + ) + records.append(record) + continue + classification = classification_for_unit(unit) + if not classification: + record["gate_status"] = "no_classification" + record["skip_reason"] = STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON + records.append(record) + continue + plan = plan_for_classification(classification) + record["popup_escalation_plan"] = plan + if plan and plan.get("feasible"): + record["gate_status"] = "escalated" + record["has_popup"] = True + record["skip_reason"] = None + # q2 idempotency persistence — stamp the marker AND the plan + # on the unit itself so a second run of the gate over the + # same unit observes ``unit.has_popup=True`` at the top of + # the loop and short-circuits before re-invoking the + # classification / plan callables. The unit-side persistence + # is also what u6 composition binding and u7 render wiring + # read downstream. + setattr(unit, "has_popup", True) + setattr(unit, "popup_escalation_plan", plan) + else: + # Plan rejected by router (wrong category). Defensive guard — + # the gate must not silently escalate the wrong overflow + # shape (see router u3 plan_details_popup_escalation defensive + # guard). + record["gate_status"] = "infeasible_category" + record["skip_reason"] = ( + STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON + ) + records.append(record) + return records + + +def gather_step17_popup_split_decisions( + units: Iterable[Any], + *, + route_for_label: Callable[[str | None], str | None], +) -> list[dict]: + """Return one API-gated split-decision record per unit (POPUP cascade). + + Schema mirrors :func:`gather_step17_ai_repair_proposals` so a Step 17 + artifact consumer can multiplex DETERMINISTIC / POPUP / AI_REPAIR records + onto the same retry trace. POPUP-specific fields: + + * ``cascade_stage`` — always ``"popup"``. + * ``api_gated`` — always ``True`` at u4. Future IMP activating the + Anthropic API for popup splitting will flip this to ``False`` for + units that traversed the deterministic POPUP gate (u5) without + resolving via summary-only. + * ``ai_called`` — always ``False`` at u4 (contract surface only). + * ``skip_reason`` — always + :data:`STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON`. + * ``split_decision`` — always ``None`` at u4. Once activated, this will + carry the AI-proposed ``{"body_preview": ..., "popup_full": ...}`` + pair; u5 deterministic gate fills the same field deterministically + from container px budgets (preview_chars) and never invokes AI. + + Per IMP-35 u4 binding contract: the API stays gated. No Anthropic call, + no route_ai_fallback import, no client instantiation. Structural import + tests in :mod:`tests.phase_z2_ai_fallback.test_step17` continue to lock + these guarantees. + """ + records: list[dict] = [] + for index, unit in enumerate(units): + label = getattr(unit, "label", None) + record: dict = { + "unit_index": index, + "source_section_ids": list( + getattr(unit, "source_section_ids", []) or [] + ), + "frame_template_id": getattr(unit, "frame_template_id", None), + "label": label, + "route_hint": route_for_label(label), + "provisional": bool(getattr(unit, "provisional", False)), + "cascade_stage": OverflowCascadeStage.POPUP.value, + "ai_called": False, + "api_gated": True, + "skip_reason": STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON, + "split_decision": None, + "error": None, + } + records.append(record) + return records + + def gather_step17_ai_repair_proposals( units: Iterable[Any], *, diff --git a/src/phase_z2_composition.py b/src/phase_z2_composition.py index 470d947..549ac5b 100644 --- a/src/phase_z2_composition.py +++ b/src/phase_z2_composition.py @@ -315,6 +315,321 @@ def select_display_strategy_candidates( return [s for s in order if s in eligible] +# ─── IMP-35 (#64) u6 — Composition popup binding (yaml strategy -> zone payload) ─ +# +# Stage 2 binding contract (unit u6): +# Step 17 POPUP gate (u5 in src/phase_z2_ai_fallback/step17.py) stamps +# ``unit.has_popup=True`` AND ``unit.popup_escalation_plan=`` on +# composition units whose overflow category routes to +# ``details_popup_escalation``. u6 is the composition-side binding that +# translates the unit-side marker into a deterministic zone payload +# structure that u7 (pipeline composer -> render_slide wiring) reads to +# emit the ``
/`` markup u8 will add to slide_base.html. +# +# Inputs (unit-side, all duck-typed via getattr): +# has_popup — bool (False default; u5 sets True on +# feasible escalation only) +# popup_escalation_plan — dict | None (u3 router plan from +# plan_details_popup_escalation; carries +# feasible / category / rationale / +# needs_split_decision) +# raw_content — str (the source MDX content; popup body +# source per CLAUDE.md 자세히보기 원칙) +# +# Outputs (zone payload binding dict): +# display_strategy — catalog strategy id read from +# display_strategies.yaml (NOT hardcoded). +# ``inline_full`` when has_popup=False. +# ``inline_preview_with_details`` when +# has_popup=True (preview = excerpt from +# container px budget downstream; popup body +# preserves the FULL original). +# popup_body_source — str | None — the FULL raw_content. u7 passes +# this verbatim to the renderer; the popup +# body is the MDX 원문 (자세히보기 원칙), +# never summarized in the body branch. +# None when has_popup=False. +# detail_trigger — dict | None — placement + label read from +# the catalog strategy entry's +# ``detail_trigger``. None when has_popup=False. +# preserves_original — bool — echoed from the catalog entry. +# MUST be True for popup-binding strategies +# (absolute user lock — 오답노트 #5 / +# IMPROVEMENT-REDESIGN.md §3.6 line 110). +# has_popup — bool — echoed for downstream multiplex. +# popup_escalation_plan — dict | None — echoed verbatim (u5 plan). +# Provides traceability into the router +# category + rationale for downstream debug. +# strategy_meta — dict — full catalog entry (description / +# applies_to / forbidden_for / detail_trigger) +# so downstream traces can self-explain without +# re-reading the yaml. +# +# Guardrails honored: +# - feedback_ai_isolation_contract — NO AI call. Reads catalog + unit +# state only. The deterministic POPUP gate (u5) already established +# the marker; this function is pure composition-side binding. +# - feedback_no_hardcoding — strategy id is the ONLY name reference, and +# it is the catalog key (yaml is source of truth). detail_trigger +# placement / label come from the catalog entry, not literals. +# - MDX 원문 무손실 보존 — popup_body_source = full raw_content. +# u6 NEVER trims or summarizes; the body preview (excerpt from +# container px budget) is composed by u7 downstream. +# - Phase Z spacing 방향 — u6 binds a strategy that EXPANDS capacity +# (popup escalation) instead of shrinking common margins. + +# Strategy id used when the unit carries no popup escalation marker. +# Catalog read — yaml is source of truth. +POPUP_BINDING_NO_POPUP_STRATEGY_ID = "inline_full" + +# Strategy id used when the unit carries has_popup=True (deterministic +# choice — the preview body is a px-budget excerpt of the original, the +# popup body holds the FULL original per CLAUDE.md 자세히보기 원칙). +# u5 q3 — preview_chars deterministic from container px telemetry; that +# is an excerpt-from-original pattern, which matches +# ``inline_preview_with_details``. ``details_only`` (summary-only body) +# is the alternative future axis when an AI/summarizer is available. +POPUP_BINDING_ESCALATED_STRATEGY_ID = "inline_preview_with_details" + + +def bind_popup_display_strategy(unit) -> dict: + """Bind catalog popup display strategy to a zone payload (IMP-35 u6). + + Reads the unit-side ``has_popup`` + ``popup_escalation_plan`` markers + stamped by Step 17 POPUP gate (u5) and produces a zone payload dict + that u7 wires into the renderer. The catalog + (``display_strategies.yaml``) is the source of truth for both the + strategy id and the detail_trigger placement / label — no hardcoded + string literals. + + Args: + unit: a CompositionUnit (or any duck-typed object exposing + ``has_popup`` / ``popup_escalation_plan`` / ``raw_content``). + ``has_popup`` defaults to False when the attribute is absent + (units that never went through the Step 17 POPUP gate). + + Returns: + zone payload binding dict (see module-level u6 contract block + immediately above for the full schema). + + Raises: + RuntimeError: if the chosen catalog strategy id is missing from + the loaded ``DISPLAY_STRATEGIES`` mapping. Defensive guard — + yaml drift would otherwise cause downstream KeyError on a + stale string literal. The constants + ``POPUP_BINDING_NO_POPUP_STRATEGY_ID`` / + ``POPUP_BINDING_ESCALATED_STRATEGY_ID`` must always resolve + against the catalog at import time. + """ + has_popup = bool(getattr(unit, "has_popup", False)) + plan = getattr(unit, "popup_escalation_plan", None) + raw_content = getattr(unit, "raw_content", "") or "" + + strategy_id = ( + POPUP_BINDING_ESCALATED_STRATEGY_ID + if has_popup + else POPUP_BINDING_NO_POPUP_STRATEGY_ID + ) + meta = DISPLAY_STRATEGIES.get(strategy_id) + if meta is None: + raise RuntimeError( + f"bind_popup_display_strategy: catalog drift — strategy id " + f"{strategy_id!r} is missing from display_strategies.yaml. " + f"Loaded keys: {sorted(DISPLAY_STRATEGIES)}." + ) + + if not has_popup: + return { + "display_strategy": strategy_id, + "popup_body_source": None, + "detail_trigger": None, + "preserves_original": bool(meta.get("preserves_original")), + "has_popup": False, + "popup_escalation_plan": None, + "strategy_meta": meta, + } + + # has_popup=True path. preserves_original MUST be True per the catalog + # absolute user lock — defensive guard against yaml drift. + if not meta.get("preserves_original"): + raise RuntimeError( + f"bind_popup_display_strategy: catalog invariant violated — " + f"popup-binding strategy {strategy_id!r} has preserves_original=" + f"{meta.get('preserves_original')!r}; MDX 원문 무손실 보존 " + f"requires preserves_original=True (오답노트 #5 / " + f"IMPROVEMENT-REDESIGN.md §3.6 line 110)." + ) + trigger_meta = meta.get("detail_trigger") or {} + return { + "display_strategy": strategy_id, + # MDX 원문 무손실 보존 — popup body = full raw_content (verbatim). + "popup_body_source": raw_content, + "detail_trigger": { + "placement": trigger_meta.get("placement"), + "label": trigger_meta.get("label"), + }, + "preserves_original": True, + "has_popup": True, + "popup_escalation_plan": plan, + "strategy_meta": meta, + } + + +# ─── IMP-35 (#64) u7 — Pipeline composer -> render_slide wiring ── +# +# Stage 2 wiring contract (unit u7): +# u6 (``bind_popup_display_strategy``) produced the deterministic zone +# binding from the unit-side marker stamped by Step 17 POPUP gate (u5). +# u7 wires that binding into the pipeline composer's zones_data so the +# render_slide call site (and downstream slide_base.html consumer u8) +# sees three uniform render-context field names per zone: +# +# has_popup : bool — escalation marker echo +# popup_html : str — popup body source (full ``raw_content`` per u6; +# u8 wraps it in ``
/``). +# ``None`` when has_popup=False. +# preview_text : str — px-budgeted excerpt of ``raw_content`` shown in +# the body / inline_preview slot. NEVER trims +# inside a line — line-boundary cut only — and +# the popup body retains the FULL original +# (MDX 원문 무손실 보존). ``None`` when +# has_popup=False. +# +# The full u6 binding is also echoed on the zone dict under +# ``popup_binding`` so downstream debug / catalog-aware consumers can +# self-explain without re-reading the yaml. +# +# Why the preview is a deterministic line-budget cut (u5 q3 resolution): +# The popup body holds the FULL original verbatim, so the preview loses +# no information — it just truncates at a deterministic boundary that +# fits the container height telemetry. Container telemetry source is the +# per-unit ``min_height_px`` (frame visual_hints), which is what the +# pipeline composer already knows at the zones_data append site. +# +# We never re-summarize, never AI-call, never reorder. Char-budget cut +# would risk splitting CJK words mid-character — line-boundary cut is +# the closest deterministic surface to ``raw_content`` semantics +# (MDX paragraph / bullet boundaries). +# +# Guardrails honored: +# - feedback_ai_isolation_contract — pure deterministic helper. No +# anthropic import, no AI fallback router path. +# - MDX 원문 무손실 보존 — preview is a CUT, never a rewrite; popup body +# stays equal to ``raw_content``. +# - feedback_no_hardcoding — line metric is parametric (line_height_px +# defaults to slide_base.html body line metric ~18 px = 11 px font * +# 1.6 line-height + ~0.4 px ascent guard). u9 will surface the literal +# value source. + +# Line height in px used to convert a container-height budget into a +# line-count budget. Matches slide_base.html ``--font-body`` (11 px) at +# the ``.text-line`` line-height (1.6). Default — NOT a hardcoded magic +# constant: ``compute_popup_preview_text`` accepts an override so the +# downstream renderer (u8) or per-frame contracts can pass a tighter +# value if a frame uses a smaller body font. +POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX = 18.0 + + +def compute_popup_preview_text( + raw_content: str, + container_height_px: float, + *, + line_height_px: float = POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX, +) -> str: + """Px-budgeted preview excerpt of ``raw_content`` (IMP-35 u7). + + Deterministic line-boundary cut — returns the leading lines of + ``raw_content`` that fit within ``container_height_px`` at the slide + body line metric. Never trims inside a line (no mid-CJK-word cut); + the popup body (u6 ``popup_body_source``) retains the FULL original + verbatim so this excerpt loses no information. + + Args: + raw_content: the unit's source MDX content; the popup body + source per CLAUDE.md 자세히보기 원칙. + container_height_px: container height telemetry. The pipeline + composer passes ``min_height_px`` (frame visual_hints) at + the zones_data append site. Non-positive values fall back + to returning the full content unchanged (popup gate would + not have fired without a real container budget anyway). + line_height_px: px per body line. Default matches slide_base.html + ``.text-line`` (11 px font * 1.6 line-height + guard). + Overridable for tighter-font frames. + + Returns: + The leading lines that fit the budget, joined verbatim. If the + content already fits, returns ``raw_content`` unchanged. + """ + if not raw_content: + return "" + if container_height_px <= 0 or line_height_px <= 0: + # No budget signal — return the full content unchanged. u5 POPUP + # gate would not have fired without a real container budget, so + # this branch is only reachable for non-popup units (where the + # preview is anyway unused — see compose_zone_popup_payload). + return raw_content + max_lines = int(container_height_px // line_height_px) + if max_lines < 1: + max_lines = 1 + lines = raw_content.splitlines(keepends=False) + if len(lines) <= max_lines: + return raw_content + # Re-join with "\n" — splitlines drops the terminator so a verbatim + # round-trip of the leading lines is "\n".join(...). Preserves the + # exact head of raw_content up to the chosen line boundary. + return "\n".join(lines[:max_lines]) + + +def compose_zone_popup_payload(unit, container_height_px: float) -> dict: + """Compose the per-zone popup render-context payload (IMP-35 u7). + + Reads u6 ``bind_popup_display_strategy(unit)`` and surfaces the three + uniform render-context field names the pipeline composer attaches to + each zone in ``zones_data``. The full u6 binding is also echoed + under ``popup_binding`` so downstream debug / u8 / u9 consumers can + self-explain without re-reading the yaml. + + Args: + unit: a CompositionUnit (or any duck-typed object exposing + ``has_popup`` / ``popup_escalation_plan`` / ``raw_content``). + container_height_px: container height telemetry. The pipeline + composer passes ``min_height_px`` at the zones_data append + site. The non-popup branch ignores the value (preview_text + is always None when has_popup=False). + + Returns: + Dict with the four wiring keys (``has_popup``, ``popup_html``, + ``preview_text``, ``popup_binding``). Spreadable into a zone + dict via ``zones_data.append({..., **payload})``. + """ + binding = bind_popup_display_strategy(unit) + has_popup = bool(binding.get("has_popup")) + if not has_popup: + return { + "has_popup": False, + "popup_html": None, + "preview_text": None, + "popup_binding": binding, + } + raw_content = getattr(unit, "raw_content", "") or "" + popup_html = binding.get("popup_body_source") + preview_text = compute_popup_preview_text(raw_content, container_height_px) + return { + "has_popup": True, + # popup body = FULL raw_content (u6 popup_body_source). u8 wraps + # this in
/ markup on slide_base.html. + "popup_html": popup_html, + # body preview = px-budgeted line-boundary cut of raw_content. + # NEVER trims inside a line; popup body holds the FULL original + # so this excerpt loses no information. + "preview_text": preview_text, + # Full u6 binding echo — downstream debug surfaces (catalog + # detail_trigger placement, popup_escalation_plan category / + # rationale) without re-reading yaml. + "popup_binding": binding, + } + + # ─── CompositionUnit ──────────────────────────────────────────── @dataclass diff --git a/src/phase_z2_failure_router.py b/src/phase_z2_failure_router.py index 0885007..24761cf 100644 --- a/src/phase_z2_failure_router.py +++ b/src/phase_z2_failure_router.py @@ -34,8 +34,12 @@ frame_reselect (V4 top-k 의 다른 frame) details_popup_escalation (가장 invasive — content popup, 마지막 resort) ``` -`details_popup_escalation` 은 본 매핑에 *없음* — tabular_overflow / structural_major_overflow / -frame_reselect 실패 이후 단계에서 다룸 (별 step). +IMP-35 (#64) u2 — cascade terminal landed. `frame_reselect_insufficient` +(post-frame remeasure failure, classifier path locked in u1) now routes onto +`details_popup_escalation`. The status table records the popup action as +MISSING here; the actual executor stub + MISSING→IMPLEMENTED flip lives in +`src/phase_z2_router.py` (u3 surface), so this module advertises the cascade +terminal without claiming an implementation it does not own. """ from __future__ import annotations @@ -74,6 +78,13 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = { "font_step_compression salvage step failed — FONT_SIZE_STEPS exhausted " "down to the floor without resolving overflow (or text_metrics missing)" ), + "frame_reselect_insufficient": ( + "frame_reselect salvage step failed — V4 top-k alternate frame swap " + "re-rendered + post-frame remeasure (run_overflow_check) still fails. " + "IMP-35 (#64) u1 contract: emitted from salvage_steps[-1].action == " + "'frame_reselect' AND passed=False AND post_salvage_overflow present. " + "Routes to details_popup_escalation in u2 (cascade terminal)." + ), } @@ -86,6 +97,12 @@ SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = { "cross_zone_redistribute": "cross_zone_redistribute_insufficient", "glue_compression": "glue_absorption_insufficient", "font_step_compression": "font_step_insufficient", + # IMP-35 (#64) u1: post-frame remeasure failure. frame_reselect salvage step + # writes a salvage_steps entry with action='frame_reselect', passed=False, + # and post_salvage_overflow populated by run_overflow_check on the swapped + # frame's HTML. classifier reads that entry; u2 adds the NEXT_ACTION row + # that routes this onto details_popup_escalation. + "frame_reselect": "frame_reselect_insufficient", } @@ -98,6 +115,14 @@ NEXT_ACTION_BY_FAILURE: dict[str, str] = { "glue_absorption_insufficient": "font_step_compression", "font_step_insufficient": "layout_adjust", "rerender_still_fails": "frame_reselect", + # IMP-35 (#64) u2 — cascade terminal. frame_reselect salvage exhausted + # (post-frame remeasure failed; classifier path gated on + # post_salvage_overflow per u1/q4) escalates onto details_popup_escalation. + # Popup body holds full MDX source; preview shows summary/subset + # (CLAUDE.md 자세히보기 원칙). Executor + MISSING→IMPLEMENTED flip lands + # in u3 (src/phase_z2_router.py); this module owns the cascade mapping + # only. + "frame_reselect_insufficient": "details_popup_escalation", "not_attempted": "none", } @@ -127,6 +152,12 @@ NEXT_ACTION_RATIONALE: dict[str, str] = { "frame/zone 조합 자체 부적합, V4 top-k 의 다른 frame 평가 (frame_reselect). " "popup 직행은 아직 빠름 (tabular / structural_major 가 아닌 한)" ), + "frame_reselect_insufficient": ( + "V4 top-k frame swap + 명시적 post-frame remeasure 까지 했는데도 overflow " + "잔존 → cascade terminal 인 details_popup_escalation 으로 escalate. " + "본문 = summary/subset, popup = MDX 원문 (자세히보기 원칙). " + "AI repair 진입 전 deterministic 마지막 단계." + ), "not_attempted": ( "retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요" ), @@ -145,6 +176,12 @@ NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = { "font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css "layout_adjust": "MISSING", "frame_reselect": "MISSING", + # IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The + # router executor stub + MISSING→IMPLEMENTED flip lives in + # src/phase_z2_router.py (u3). Keeping this entry as MISSING until u3 + # lands prevents premature "popup ready" claims from the failure-router + # surface. + "details_popup_escalation": "MISSING", "none": "n/a", } @@ -170,21 +207,40 @@ def classify_retry_failure(retry_trace: dict) -> Optional[dict]: # case 0.7 : salvage chain attempted and ended in a salvage-level failure. # zone_ratio_retry 가 먼저 실패한 뒤 _attempt_salvage_chain 이 가동된 path — # 마지막 salvage step 의 action 으로 failure_type 을 분류한다. u3 가 routing. + # + # IMP-35 (#64) u1 — q4 explicit remeasure contract: the frame_reselect + # branch is gated on post_salvage_overflow being present on the salvage + # step. A bare passed=False flag with no remeasure payload is *not* + # sufficient to emit frame_reselect_insufficient (which routes to + # details_popup_escalation in u2). When the gate fails, the classifier + # falls through to lower-priority cases so the salvage trace surfaces as + # an unmatched defensive fallback instead of a spurious popup escalation. salvage_steps = retry_trace.get("salvage_steps") or [] if salvage_steps: last = salvage_steps[-1] or {} if not last.get("passed"): action = (last.get("action") or "").lower() - ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action) - if ftype is not None: - reason = last.get("failure_reason") or "" - return { - "failure_type": ftype, - "classification_rule": ( - f"salvage_steps[-1].action == {action!r} " - f"AND passed=False. raw failure_reason: {reason!r}" - ), - } + frame_reselect_blocked = ( + action == "frame_reselect" + and not last.get("post_salvage_overflow") + ) + if not frame_reselect_blocked: + ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action) + if ftype is not None: + reason = last.get("failure_reason") or "" + rule_suffix = ( + " AND post_salvage_overflow present" + if action == "frame_reselect" + else "" + ) + return { + "failure_type": ftype, + "classification_rule": ( + f"salvage_steps[-1].action == {action!r} " + f"AND passed=False{rule_suffix}. " + f"raw failure_reason: {reason!r}" + ), + } # case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action) if not retry_trace.get("retry_attempted"): diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index d0afee3..f1a1de7 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -41,6 +41,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape from phase_z2_composition import ( LAYOUT_PRESETS, CompositionUnit, + compose_zone_popup_payload, derive_parent_id, plan_composition, resplit_all_reject_merges, @@ -57,7 +58,7 @@ from phase_z2_mapper import ( map_with_contract, ) from phase_z2_classifier import classify_visual_runtime_check -from phase_z2_router import route_fit_classification +from phase_z2_router import plan_details_popup_escalation, route_fit_classification from phase_z2_retry import ( DEFAULT_SAFETY_MARGIN_PX, apply_cross_zone_redistribute_css, @@ -85,6 +86,13 @@ from phase_z2_placement_planner import plan_placement # stays in src/config.py + src/phase_z2_ai_fallback/router.py. from src.phase_z2_ai_fallback.step12 import gather_step12_ai_repair_proposals +# IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor. Runs after +# the salvage cascade exhausts at cascade-terminal action +# ``details_popup_escalation`` (router u3 / failure_router u2) and BEFORE +# the AI_REPAIR cascade stage. Stamps ``popup_escalation_plan`` and the +# idempotent ``has_popup`` marker onto retry_trace per unit. No AI call. +from src.phase_z2_ai_fallback.step17 import run_step17_popup_gate + # ─── Constants ────────────────────────────────────────────────── @@ -2476,6 +2484,41 @@ def _attempt_salvage_chain( return trace +def _remeasure_after_frame_reselect( + *, candidate_path: Path, plan: Optional[dict] = None, +) -> dict: + """IMP-35 (#64) u1 — post-frame remeasure helper for the cascade terminal. + + Contract (q4 / Stage 2): frame_reselect_insufficient is detected by an + *explicit overflow re-measure* after a V4 top-k alternate frame swap — + NOT a failure-flag carryover. This helper runs run_overflow_check on the + re-rendered candidate HTML and shapes the salvage_steps entry that + classify_retry_failure / SALVAGE_FAILURE_TYPE_BY_ACTION read. + + Future frame_reselect orchestrator (post-IMP-35) writes the candidate + HTML and calls this helper to append the entry to retry_trace.salvage_steps. + On passed=True the orchestrator promotes the candidate to final.html; on + passed=False the classifier emits frame_reselect_insufficient → u2 routes + onto details_popup_escalation (Step 17 POPUP gate / u5). + """ + candidate_overflow = run_overflow_check(candidate_path) + passed = bool(candidate_overflow.get("passed", False)) + return { + "action": "frame_reselect", + "plan": plan, + "passed": passed, + "candidate_path": ( + str(candidate_path.relative_to(PROJECT_ROOT)) + if candidate_path.is_absolute() else str(candidate_path) + ), + "post_salvage_overflow": candidate_overflow, + "failure_reason": ( + None if passed + else (candidate_overflow.get("fail_reasons") or "post-frame remeasure: overflow persists") + ), + } + + def render_slide(slide_title: str, slide_footer: Optional[str], zones_data: list[dict], layout_preset: str, layout_css: dict, gap_px: int = GRID_GAP, @@ -4232,6 +4275,11 @@ def run_phase_z2_mvp1( # first-render invariant holds; u5 will surface the provisional flag as # a zone class + needs-adaptation badge. if unit.frame_template_id == "__empty__": + # IMP-35 u7 — popup payload wiring. Empty-shell units never go + # through the Step 17 POPUP gate (no raw content to escalate), + # so compose_zone_popup_payload returns the no-popup branch + # (has_popup=False, popup_html=None, preview_text=None). + _popup_payload = compose_zone_popup_payload(unit, 0) zones_data.append({ "position": position, "template_id": "__empty__", @@ -4241,6 +4289,7 @@ def run_phase_z2_mvp1( "assignment_source": "imp30_u4_empty_shell", "section_assignment_override": False, "provisional": bool(getattr(unit, "provisional", False)), + **_popup_payload, }) debug_zones.append({ "position": position, @@ -4411,6 +4460,15 @@ def run_phase_z2_mvp1( # needs-adaptation badge. Default False keeps non-provisional zones # byte-identical to pre-u5; only u1-synthesized rank-1 fills or u4 # empty-shell synthesize provisional=True units. + # + # IMP-35 u7 — popup payload wiring. `compose_zone_popup_payload(unit, + # min_height_px)` reads u6 binding (yaml strategy + popup_body_source) + # AND derives a px-budgeted preview from min_height_px. Surfaces three + # uniform render-context fields per zone (has_popup / popup_html / + # preview_text) plus the full u6 binding under `popup_binding` for + # u8 / u9 downstream consumers. Non-popup units (has_popup=False) + # return the no-popup branch — byte-identical zone shape pre-u7. + _popup_payload = compose_zone_popup_payload(unit, min_height_px) zones_data.append({ "position": position, "template_id": unit.frame_template_id, @@ -4420,6 +4478,7 @@ def run_phase_z2_mvp1( "assignment_source": plan_assignment_source, "section_assignment_override": plan_section_override, "provisional": bool(getattr(unit, "provisional", False)), + **_popup_payload, }) debug_zones.append({ "position": position, @@ -4475,6 +4534,12 @@ def run_phase_z2_mvp1( pos = record["position"] if pos in renderable_positions: continue + # IMP-35 u7 — popup payload wiring for unrenderable empty + # plan record. No CompositionUnit exists for this branch + # (section-assignment plan produced no unit), so we stamp the + # no-popup defaults directly. Keeps the zone shape uniform + # across all three append paths so slide_base.html (u8) does + # not have to branch on the presence of popup fields. zones_data.append({ "position": pos, "template_id": "__empty__", @@ -4486,6 +4551,10 @@ def run_phase_z2_mvp1( record.get("skipped_reason") or "section_assignment_override_empty_or_unrenderable" ), + "has_popup": False, + "popup_html": None, + "preview_text": None, + "popup_binding": None, }) debug_zones.append({ "position": pos, @@ -5615,6 +5684,54 @@ def run_phase_z2_mvp1( # fields become None (no failure to classify, no escalation pending). enrich_retry_trace_with_failure_classification(retry_trace) + # 11.8 IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor. + # Runs after the salvage cascade exits at cascade-terminal action + # `details_popup_escalation` (router u3 IMPLEMENTED + failure_router u2 + # cascade row). Stamps popup_escalation_plan + idempotent has_popup + # marker per unit onto retry_trace["popup_gate_records"]. Deterministic + # gate — no AI call (feedback_ai_isolation_contract); the u4 + # api_gated split-decision hook is a separate cascade-stage record + # consumed only when a future IMP activates the Anthropic API. + # Consumer side (composition popup binding / render wiring) lands in + # u6 / u7. q1 (per-unit), q2 (idempotent via has_popup), q3 + # (deterministic from fit_classification) — see Stage 2 plan. + # next_proposed_action is the single canonical signal: it is set by + # enrich_retry_trace_with_failure_classification via failure_router u2 + # (NEXT_ACTION_BY_FAILURE), which routes frame_reselect_insufficient -> + # details_popup_escalation. This check is independent of whether the + # salvage chain block ran, so the popup gate fires for any retry path + # that lands on the cascade-terminal popup action. + _next_action = ( + retry_trace.get("next_action_proposal") or {} + ).get("next_proposed_action") + if _next_action == "details_popup_escalation": + _popup_cls_by_zone = { + c.get("zone_position"): c + for c in (fit_classification.get("classifications") or []) + if c.get("category") in { + "structural_major_overflow", + "tabular_overflow", + } + } + _zone_by_ssids = { + tuple(z.get("source_section_ids") or []): z.get("position") + for z in debug_zones + } + + def _classification_for_unit(u): + ssids = tuple(getattr(u, "source_section_ids", []) or []) + zone_pos = _zone_by_ssids.get(ssids) + return _popup_cls_by_zone.get(zone_pos) if zone_pos else None + + retry_trace["popup_gate_records"] = run_step17_popup_gate( + units, + classification_for_unit=_classification_for_unit, + route_for_label=_imp05_route_hint, + plan_for_classification=plan_details_popup_escalation, + ) + retry_trace["popup_gate_executed"] = True + retry_trace["popup_gate_terminal_action"] = "details_popup_escalation" + # ─── Step 17: Implemented Action (retry) ─── _write_step_artifact( run_dir, 17, "retry_trace", diff --git a/src/phase_z2_router.py b/src/phase_z2_router.py index 810459a..bbb7a08 100644 --- a/src/phase_z2_router.py +++ b/src/phase_z2_router.py @@ -56,12 +56,24 @@ ACTION_RATIONALE: dict[str, str] = { "위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)", } -# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18) +# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18; +# IMP-35 u3 popup-stub 2026-05-23) # A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용 ACTION_IMPLEMENTATION_STATUS: dict[str, str] = { "zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration "layout_adjust": "MISSING", - "details_popup_escalation": "MISSING", # CLAUDE.md 의
원칙은 있음, runtime 미구현 + # IMP-35 (#64) u3 — MISSING → IMPLEMENTED on the primary router surface. + # `plan_details_popup_escalation` (below) provides the deterministic stub + # that downstream units consume: u4 binds the AI split-decision contract + # in `src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP + # gate executor in `src/phase_z2_pipeline.py`. Router-level mapping is + # decoupled from orchestrator wiring (same precedent as the IMP-12 u7 + # cascade actions below): IMPLEMENTED here reflects deterministic + # *surface availability* (importable stub), not whether a given pipeline + # run has invoked it. The failure_router companion surface + # (NEXT_ACTION_IMPLEMENTATION_STATUS in phase_z2_failure_router.py) keeps + # `details_popup_escalation` as MISSING until u5 lands the pipeline gate. + "details_popup_escalation": "IMPLEMENTED", "frame_reselect": "PARTIAL", # IMP-05 pre-render rank-2/3 fallback implemented; post-render rerender trace-only "adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch "abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default @@ -185,3 +197,112 @@ def route_fit_classification(fit_classification: dict) -> dict: "MISSING 이면 그 action 은 실행 X 이고 기존 abort/status 흐름 (sys.exit(1)) 으로 종료." ), } + + +# ─── IMP-35 (#64) u3 — details_popup_escalation deterministic stub ─ +# Surface contract for the cascade-terminal popup escalation. This stub +# does NOT mutate HTML / CSS / MDX content; it emits the canonical plan +# marker that the Step 17 POPUP gate (u5) and the AI split-decision hook +# (u4) consume. Keeping the executor surface here (next to the primary +# ACTION_BY_CATEGORY mapping) lets the router report IMPLEMENTED for +# `details_popup_escalation` while u4/u5 are still landing. +# +# Contract (locked in Stage 2 IMPLEMENTATION_UNITS u3): +# - Inputs: classification dict (a single fit_classifier output row). +# The category MUST be one of the two ACTION_BY_CATEGORY +# rows that map onto `details_popup_escalation` — +# `structural_major_overflow` or `tabular_overflow`. +# Other categories raise the stub's defensive guard (so +# callers do not silently popup-escalate the wrong category). +# - Output: popup_escalation_plan dict with `feasible=True`, +# `stub=True`, the source category, the canonical +# ACTION_RATIONALE entry, and `needs_split_decision=True` +# to flag that u4 (AI hook) must run before u5 renders. +# - No side effects (no AI call, no MDX read, no HTML mutation). +# +# Guardrails honored: +# - feedback_ai_isolation_contract: stub is deterministic-with-data; +# no AI call inside the router surface. +# - Phase Z spacing 방향: stub does not shrink common margins; it +# expands capacity by routing content to popup downstream. +# - 자세히보기 원칙 (CLAUDE.md): plan carries the marker that u5 uses +# to put MDX 원문 in popup body and a summary/subset in preview. +# - 1 turn = 1 unit: this is router-surface only. u4/u5 own the +# downstream wiring on their respective files. + + +# Categories that legitimately escalate onto details_popup_escalation +# per the ACTION_BY_CATEGORY mapping above. Kept as a derived constant +# so the router cannot drift away from the single source of truth. +POPUP_ESCALATION_CATEGORIES: frozenset[str] = frozenset( + category + for category, action in ACTION_BY_CATEGORY.items() + if action == "details_popup_escalation" +) + + +def plan_details_popup_escalation(classification: dict) -> dict: + """Cascade-terminal popup escalation plan stub (IMP-35 u3). + + Returns a deterministic popup_escalation_plan marker. The actual + content split (popup_html / preview_text / has_popup payload) is + composed downstream: u4 binds the AI split-decision contract on + `src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP + gate executor on `src/phase_z2_pipeline.py`. + + Args: + classification: a single fit_classifier classification dict. + Must contain a `category` key. Only the categories that + map onto `details_popup_escalation` in ACTION_BY_CATEGORY + (currently `structural_major_overflow` and `tabular_overflow`) + are accepted; any other category produces an + `feasible=False` plan with `failure_reason` so the caller + never silently popup-escalates the wrong overflow shape. + + Returns: + popup_escalation_plan dict with at least: + action : "details_popup_escalation" + feasible : True/False (True for accepted categories) + stub : True (marks u3 surface; u4/u5 fill in) + category : echoed from input + rationale : canonical ACTION_RATIONALE entry + needs_split_decision : True (u4 AI hook must run before u5 renders) + mapping_source : "IMP-35 u3 plan_details_popup_escalation stub" + note : downstream-wiring pointer text + """ + category = (classification or {}).get("category") + base = { + "action": "details_popup_escalation", + "stub": True, + "category": category, + "mapping_source": "IMP-35 u3 plan_details_popup_escalation stub", + } + if category not in POPUP_ESCALATION_CATEGORIES: + return { + **base, + "feasible": False, + "needs_split_decision": False, + "rationale": "", + "failure_reason": ( + f"category {category!r} does not map onto details_popup_escalation " + f"in ACTION_BY_CATEGORY. Accepted categories: " + f"{sorted(POPUP_ESCALATION_CATEGORIES)}. Defensive guard — " + f"router must not silently popup-escalate the wrong overflow shape." + ), + "note": ( + "u3 stub — caller passed a category that should not popup-escalate. " + "Honour the ACTION_BY_CATEGORY mapping at the router entry point." + ), + } + return { + **base, + "feasible": True, + "needs_split_decision": True, + "rationale": ACTION_RATIONALE.get(category, ""), + "note": ( + "u3 stub — actual content split planning lands in u4 " + "(AI split-decision contract on src/phase_z2_ai_fallback/step17.py) " + "and u5 (Step 17 POPUP gate executor on src/phase_z2_pipeline.py). " + "popup body = MDX 원문, preview = summary/subset (자세히보기 원칙)." + ), + } diff --git a/templates/phase_z2/regions/display_strategies.yaml b/templates/phase_z2/regions/display_strategies.yaml index 33271e5..c38aa7a 100644 --- a/templates/phase_z2/regions/display_strategies.yaml +++ b/templates/phase_z2/regions/display_strategies.yaml @@ -20,6 +20,16 @@ # applies_to: list[str] (content types that can use this strategy) # forbidden_for: list[str] (content types that MUST NOT use this strategy) # preserves_original: bool (true = original content kept somewhere — popup/detail) +# preview_chars: int | null (IMP-35 u9 — soft char budget for the inline body +# shown alongside the popup trigger; null when the +# strategy has no popup. The popup body itself +# ALWAYS holds the FULL original — preview_chars +# governs only the inline preview/summary surface.) +# popup_target_slot: str | null +# (IMP-35 u9 — 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.) inline_full: @@ -27,6 +37,9 @@ inline_full: applies_to: [text_block, table, image, details, decorative_element] forbidden_for: [] preserves_original: true # all content is inline, original = inline + # IMP-35 u9 — inline_full has no popup → both popup-wiring fields are null. + preview_chars: null + popup_target_slot: null inline_preview_with_details: @@ -34,6 +47,9 @@ inline_preview_with_details: applies_to: [text_block, table, details] forbidden_for: [decorative_element] preserves_original: true # User lock — original content kept in popup + # IMP-35 u9 — partial preview body inline; popup body holds FULL original. + preview_chars: 240 + popup_target_slot: primary detail_trigger: placement: top-right # 본문 흐름 방해 X / 보조 동작 위치 / 안정 (user 2026-05-07) label: details # identifier — display text 는 partial/UI 별 axis @@ -45,6 +61,11 @@ details_only: applies_to: [text_block, table, details] forbidden_for: [decorative_element] preserves_original: true # User lock — full content in popup + # IMP-35 u9 — summary-only inline surface (smaller char budget); popup body + # holds FULL original. preview_chars > 0 because details_only still emits a + # short summary line — it is NOT a "no body" surface (that is `dropped`). + preview_chars: 80 + popup_target_slot: primary detail_trigger: placement: top-right # user lock — popup 진입 일관 위치 label: details @@ -60,3 +81,6 @@ dropped: applies_to: [decorative_element] forbidden_for: [text_block, table, image, details] preserves_original: false # decorative only — no original to preserve + # IMP-35 u9 — dropped has no popup and no body surface → both fields null. + preview_chars: null + popup_target_slot: null diff --git a/templates/phase_z2/slide_base.html b/templates/phase_z2/slide_base.html index 8c8485b..0ee0efa 100644 --- a/templates/phase_z2/slide_base.html +++ b/templates/phase_z2/slide_base.html @@ -290,6 +290,71 @@ font-family: monospace; z-index: 3; } + + /* ── IMP-35 u8 : popup details/summary (Step 17 POPUP gate escalation) ── + When the Step 17 POPUP gate escalates a unit (zone.has_popup=True), + slide_base renders a JS-free
/ wrapper in the zone. + The body of the frame stays as zone.partial_html (the FIT-version of + content); the popup body holds the FULL original raw_content (MDX 원문 + 무손실 보존 — 오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110). + Placement (default top-right) is read from + zone.popup_binding.detail_trigger.placement + (templates/phase_z2/regions/display_strategies.yaml). HTML-native +
per CLAUDE.md 자세히보기 contract — no JavaScript. */ + .zone__popup-details { + position: absolute; + z-index: 5; + font-family: 'Pretendard', sans-serif; + } + .zone__popup-details--top-right { + top: 4px; + right: 4px; + } + .zone__popup-details--top-left { + top: 4px; + left: 4px; + } + .zone__popup-details--bottom-right { + bottom: 4px; + right: 4px; + } + .zone__popup-details--bottom-left { + bottom: 4px; + left: 4px; + } + .zone__popup-summary { + list-style: none; + cursor: pointer; + padding: 2px 6px; + background: rgba(30, 41, 59, 0.85); + color: #fff; + border-radius: 2px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.04em; + line-height: 1.2; + user-select: none; + } + .zone__popup-summary::-webkit-details-marker { display: none; } + .zone__popup-summary::marker { content: ""; } + .zone__popup-body { + position: absolute; + top: 22px; + right: 0; + width: 360px; + max-height: 280px; + overflow: auto; + padding: 8px 10px; + background: #fff; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 3px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + white-space: pre-wrap; + word-break: keep-all; + font-size: 10px; + line-height: 1.5; + color: #1e293b; + } @@ -301,9 +366,19 @@
{% for zone in zones %} -
+
{% if zone.provisional %}needs adaptation{% endif %} {{ zone.partial_html | safe }} + {% if zone.has_popup %} + {% set _popup_trigger = (zone.popup_binding.detail_trigger if zone.popup_binding else None) or {} %} + {% set _popup_placement = _popup_trigger.placement or 'top-right' %} + {% set _popup_label = _popup_trigger.label or 'details' %} + {% set _popup_strategy = (zone.popup_binding.display_strategy if zone.popup_binding else 'inline_preview_with_details') %} +
+ {{ _popup_label }} +
{{ zone.popup_html }}
+
+ {% endif %}
{% endfor %}
diff --git a/tests/orchestrator_unit/test_imp17_comment_anchor.py b/tests/orchestrator_unit/test_imp17_comment_anchor.py index dc0dc15..39b851e 100644 --- a/tests/orchestrator_unit/test_imp17_comment_anchor.py +++ b/tests/orchestrator_unit/test_imp17_comment_anchor.py @@ -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}" ) diff --git a/tests/phase_z2/test_composition_popup_strategy.py b/tests/phase_z2/test_composition_popup_strategy.py new file mode 100644 index 0000000..4f0df84 --- /dev/null +++ b/tests/phase_z2/test_composition_popup_strategy.py @@ -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 diff --git a/tests/phase_z2/test_display_strategies_popup.py b/tests/phase_z2/test_display_strategies_popup.py new file mode 100644 index 0000000..6beb1c9 --- /dev/null +++ b/tests/phase_z2/test_display_strategies_popup.py @@ -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." + ) diff --git a/tests/phase_z2/test_phase_z2_failure_router_cascade.py b/tests/phase_z2/test_phase_z2_failure_router_cascade.py index d45d7fe..771a106 100644 --- a/tests/phase_z2/test_phase_z2_failure_router_cascade.py +++ b/tests/phase_z2/test_phase_z2_failure_router_cascade.py @@ -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" + ) diff --git a/tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py b/tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py new file mode 100644 index 0000000..46a6e92 --- /dev/null +++ b/tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py @@ -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 ``
/``). ``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 diff --git a/tests/phase_z2/test_phase_z2_router_popup.py b/tests/phase_z2/test_phase_z2_router_popup.py new file mode 100644 index 0000000..5c29a49 --- /dev/null +++ b/tests/phase_z2/test_phase_z2_router_popup.py @@ -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" diff --git a/tests/phase_z2/test_phase_z2_step17_popup_gate.py b/tests/phase_z2/test_phase_z2_step17_popup_gate.py new file mode 100644 index 0000000..0628e0c --- /dev/null +++ b/tests/phase_z2/test_phase_z2_step17_popup_gate.py @@ -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 diff --git a/tests/phase_z2/test_popup_mdx_preservation.py b/tests/phase_z2/test_popup_mdx_preservation.py new file mode 100644 index 0000000..2aa7517 --- /dev/null +++ b/tests/phase_z2/test_popup_mdx_preservation.py @@ -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 ``
/`` 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 + / ``
`` 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 ``
`` 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 ``
`` blocks in raw_content (rare — used to + lock the invariant even when MDX already carries native popups).""" + return len(re.findall(r"MOCK_NESTED_TRIGGER" + "

MOCK_NESTED_BODY

\n" + "\n" + "Paragraph two — closing remarks for the MOCK topic.\n" +) + + +# ─── Popup body = full raw_content (byte-for-byte) ─────────────────── + + +def test_popup_body_byte_for_byte_equal_to_raw_content(): + """u10 — the end-to-end invariant: popup_html on the rendered payload + is byte-for-byte equal to the unit's raw_content. u6 + u7 already + lock this on their own surface; u10 re-asserts on the payload a + downstream verifier (Selenium / vision gate) would inspect.""" + unit = _StubUnit( + raw_content=_FULL_MDX_SAMPLE, + has_popup=True, + popup_escalation_plan=_stub_popup_plan(), + ) + payload = compose_zone_popup_payload(unit, container_height_px=200) + assert payload["popup_html"] == _FULL_MDX_SAMPLE + assert len(payload["popup_html"]) == len(_FULL_MDX_SAMPLE) + + +def test_popup_body_preserves_bullet_line_count(): + """u10 — text_block count equality. Every bullet line present in + raw_content MUST also be present in popup_html. A future refactor + that accidentally trims popup body to a summary would drop bullets + and fail this guard.""" + unit = _StubUnit( + raw_content=_FULL_MDX_SAMPLE, + has_popup=True, + popup_escalation_plan=_stub_popup_plan(), + ) + payload = compose_zone_popup_payload(unit, container_height_px=200) + assert _count_markdown_bullet_lines(payload["popup_html"]) == ( + _count_markdown_bullet_lines(_FULL_MDX_SAMPLE) + ) + + +def test_popup_body_preserves_markdown_table_row_count(): + """u10 — table count equality. Markdown table rows (header / divider + / data) MUST all survive the popup wiring.""" + unit = _StubUnit( + raw_content=_FULL_MDX_SAMPLE, + has_popup=True, + popup_escalation_plan=_stub_popup_plan(), + ) + payload = compose_zone_popup_payload(unit, container_height_px=200) + assert _count_markdown_table_rows(payload["popup_html"]) == ( + _count_markdown_table_rows(_FULL_MDX_SAMPLE) + ) + + +def test_popup_body_preserves_image_reference_count(): + """u10 — image count equality. Markdown ``![alt](src)`` references + MUST all survive (CLAUDE.md: 이미지는 원본 그대로 사용, 크기만 조절 — + popup escalation must not silently drop image refs).""" + unit = _StubUnit( + raw_content=_FULL_MDX_SAMPLE, + has_popup=True, + popup_escalation_plan=_stub_popup_plan(), + ) + payload = compose_zone_popup_payload(unit, container_height_px=200) + assert _count_markdown_images(payload["popup_html"]) == ( + _count_markdown_images(_FULL_MDX_SAMPLE) + ) + + +def test_popup_body_preserves_nested_details_block_count(): + """u10 — nested ``
`` blocks. Even when MDX already carries + a native popup, the u10 popup escalation MUST NOT collapse or drop + nested ``
`` 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 diff --git a/tests/phase_z2/test_slide_base_popup_render.py b/tests/phase_z2/test_slide_base_popup_render.py new file mode 100644 index 0000000..53b6bfa --- /dev/null +++ b/tests/phase_z2/test_slide_base_popup_render.py @@ -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 + ``
/`` 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 ``
`` element emitted (byte-identical + contract for non-popup zones, no regression to pre-u8). + 2. has_popup=True → exactly one ``
`` per zone with a ```` + trigger and a ``
`` 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 ``" + html = _render([_popup_zone(popup_html=payload)]) + # Raw " 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'
(.*?)
', + 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'
(.*?)
', + 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 ">자세히
" in html + + +def test_popup_data_display_strategy_attr_reflects_binding_strategy_id(): + """The details element carries data-display-strategy= + 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
" 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
" 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
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'
\s*
', + 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 ``
`` 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