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

Land the production + test surface for the Step 17 cascade POPUP terminal
(DETERMINISTIC -> POPUP -> AI_REPAIR -> USER_OVERRIDE) per Stage 2 plan R2.
u11 (baseline-red invariance gate) was already landed in 7c93031 ahead of
this commit; this commit completes u1~u10 plus the Stage 3 R7 follow-up
anchor re-pin for test_imp17_comment_anchor.py.

Implementation units (Stage 2 R2 contract):
  u1  frame_reselect_insufficient failure_type + post-frame remeasure (q4)
        - src/phase_z2_failure_router.py, src/phase_z2_pipeline.py
  u2  NEXT_ACTION_BY_FAILURE row + impl_status flip
        - src/phase_z2_failure_router.py
  u3  Router details_popup_escalation MISSING->IMPLEMENTED + executor stub
        - src/phase_z2_router.py
  u4  step17.py AI split-decision contract (POPUP cascade_stage +
      route_for_label + skip_reason); API gated
        - src/phase_z2_ai_fallback/step17.py
  u5  Step 17 POPUP gate executor; popup_escalation_plan + has_popup marker
        - src/phase_z2_pipeline.py, src/phase_z2_ai_fallback/step17.py
  u6  Composition popup binding -- yaml strategy -> zone payload
        - src/phase_z2_composition.py
  u7  Pipeline composer -> render_slide wiring
      (popup_html / preview_text / has_popup)
        - src/phase_z2_pipeline.py
  u8  slide_base.html <details>/<summary> popup wrapper
        - templates/phase_z2/slide_base.html
  u9  display_strategies.yaml inline_preview + popup metadata
        - templates/phase_z2/regions/display_strategies.yaml
  u10 MDX preservation invariant: popup=full source / body=summary or subset
        (asserted by tests/phase_z2/test_popup_mdx_preservation.py)
  u11 (already in 7c93031) -- baseline-red invariance gate

Stage 3 R7 follow-up (anchor re-pin, test-only):
  - tests/orchestrator_unit/test_imp17_comment_anchor.py
    Pre-anchor additions in src/phase_z2_pipeline.py (u1 / u5 / u7) shifted
    the restructure/reject route-hint comments 578/579 -> 586/587. Re-pinned
    the two guard tests (and docstring re-pin lineage 564 -> 570 -> 578 ->
    586). Production code untouched.

Verification (Stage 4 R1):
  pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py
    -> 2 passed / 0.02s
  pytest -q <10 IMP-35 unit files in tests/phase_z2 + tests/phase_z2_ai_fallback>
    -> 136 passed / 15.94s
  Baseline-red invariance gate
    (tests/test_imp47b_step12_ai_wiring.py +
     tests/test_phase_z2_ai_fallback_config.py)
    -> 4 failed / 6 passed; FAILED set === IMP35_BASELINE_RED_NODE_IDS
    (frozen registry from 7c93031). Contract holds.
  Codex Stage 4 R1 = YES (independent verify).

Guardrails honored:
  - MDX content preservation: popup carries full source, body holds
    summary or subset only (CLAUDE.md 자세히보기 원칙;
    feedback_phase_z_spacing_direction -- capacity expanded, no margin shrink).
  - AI isolation contract: Step 17 POPUP gate is deterministic; AI hook
    surface is split-decision contract only, API call gated.
  - No hardcoding: escalation thresholds derived from existing overflow
    detector outputs; preview_chars deterministic from container px.
  - 1 commit = 1 decision unit: u1~u10 land together as the planned
    production surface; u11 was deliberately split into 7c93031 as Stage 3
    R7 carve-out, and the R7 anchor re-pin rides with this commit because
    it is the direct shift consequence of the u1/u5/u7 pre-anchor additions.
  - Scope-locked: .claude/settings.json explicitly excluded
    (Stage 4 exit report contract).

Out of scope (per Stage 1 + Stage 2):
  - AI_REPAIR API activation (post IMP-35 axis).
  - IMP-34 zone resize, IMP-36 responsive fit (chain partners,
    separate issues).
  - Print-time auto-expand JavaScript for <details>.
  - Popup escalation in stages other than Step 17.
  - Baseline-red body repair (4 frozen failures) -- separate follow-up
    issue; u11 only guards the count.
  - frame_reselect algorithm changes (entry point only).
  - templates/phase_z2/slide_base.html path rename.

source_comment_ids:
  Stage 1: claude_stage1_problem_review_imp35, codex_stage1_verification_imp35_yes
  Stage 2: Claude #4 R2 plan, Codex #5 R2 YES
  Stage 3: Claude #86 (R7 anchor re-pin), Codex #87 YES
  Stage 4: Claude #88 R1, Codex #89 R1 YES

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 07:36:57 +09:00
parent 7c93031f9b
commit f3ef4d917c
17 changed files with 3692 additions and 25 deletions

View File

@@ -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 <details> 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],
*,

View File

@@ -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=<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 ``<details>/<summary>`` 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 ``<details>/<summary>``).
# ``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 <details>/<summary> 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

View File

@@ -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"):

View File

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

View File

@@ -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 의 <details> 원칙은 있음, 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 (자세히보기 원칙)."
),
}

View File

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

View File

@@ -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 <details>/<summary> 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
<details> 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;
}
</style>
</head>
<body>
@@ -301,9 +366,19 @@
<div class="slide-body">
<div class="layout-{{ layout_preset }}">
{% for zone in zones %}
<div class="zone{% if zone.provisional %} zone--provisional{% endif %}" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}"{% if zone.provisional %} data-provisional="1"{% endif %} style="grid-area: {{ zone.position }};">
<div class="zone{% if zone.provisional %} zone--provisional{% endif %}" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}"{% if zone.provisional %} data-provisional="1"{% endif %}{% if zone.has_popup %} data-has-popup="1"{% endif %} style="grid-area: {{ zone.position }};">
{% if zone.provisional %}<span class="zone__needs-adaptation-badge" aria-label="needs user or AI adaptation">needs adaptation</span>{% 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') %}
<details class="zone__popup-details zone__popup-details--{{ _popup_placement }}" data-display-strategy="{{ _popup_strategy }}" data-popup-placement="{{ _popup_placement }}">
<summary class="zone__popup-summary">{{ _popup_label }}</summary>
<div class="zone__popup-body">{{ zone.popup_html }}</div>
</details>
{% endif %}
</div>
{% endfor %}
</div>

View File

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

View File

@@ -0,0 +1,333 @@
"""IMP-35 (#64) u6 — Composition popup binding tests.
Stage 2 binding contract (unit u6):
``bind_popup_display_strategy`` in ``src/phase_z2_composition.py`` is
the composition-side binding that translates the unit-side marker
(``has_popup`` + ``popup_escalation_plan``) stamped by the Step 17
POPUP gate (u5 in ``src/phase_z2_ai_fallback/step17.py``) into a
deterministic zone payload structure that u7 wires into the renderer.
Key invariants this file locks:
1. Strategy id is the catalog key (yaml is source of truth) — no
hardcoded literal string drift between code and
``display_strategies.yaml``.
2. ``has_popup=False`` units bind to ``inline_full`` (no popup).
3. ``has_popup=True`` units bind to ``inline_preview_with_details``
(preview = excerpt from container px budget downstream; popup
body holds the FULL original per CLAUDE.md 자세히보기 원칙).
4. ``popup_body_source`` is the FULL ``raw_content``, verbatim —
u6 NEVER trims or summarizes (MDX 원문 무손실 보존, 오답노트 #5,
IMPROVEMENT-REDESIGN.md §3.6 line 110).
5. ``detail_trigger.placement`` / ``label`` come from the catalog
entry's ``detail_trigger`` block, not from code constants.
6. The popup-binding strategy MUST have ``preserves_original=True``
in the catalog (defensive yaml-drift guard).
7. No AI call. ``bind_popup_display_strategy`` is pure composition-
side binding — feedback_ai_isolation_contract.
Cross-references:
- u3 router stub (``plan_details_popup_escalation``):
tests/phase_z2/test_phase_z2_router_popup.py
- u4 api_gated split-decision contract:
tests/phase_z2_ai_fallback/test_step17.py
- u5 Step 17 POPUP gate (stamps the marker u6 reads):
tests/phase_z2/test_phase_z2_step17_popup_gate.py
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import pytest
from src.phase_z2_composition import (
DISPLAY_STRATEGIES,
POPUP_BINDING_ESCALATED_STRATEGY_ID,
POPUP_BINDING_NO_POPUP_STRATEGY_ID,
bind_popup_display_strategy,
)
# ─── Synthetic stubs ─────────────────────────────────────────────────
@dataclass
class _StubUnit:
"""Minimal duck-typed CompositionUnit for u6 binding tests.
Mirrors only the fields ``bind_popup_display_strategy`` reads via
getattr — keeps the test independent of the full CompositionUnit
dataclass evolution (e.g., IMP-30 / IMP-48 axis additions).
"""
raw_content: str = "MOCK_ORIGINAL_CONTENT"
has_popup: bool = False
popup_escalation_plan: Optional[dict] = None
def _stub_popup_plan(category: str = "structural_major_overflow") -> dict:
"""Mirror the shape ``plan_details_popup_escalation`` returns on a
feasible escalation. u6 echoes this verbatim — no field is consumed
here other than as a traceable payload."""
return {
"action": "details_popup_escalation",
"stub": True,
"feasible": True,
"category": category,
"needs_split_decision": True,
"rationale": "MOCK_RATIONALE",
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
}
# ─── Catalog constants are catalog keys (no hardcoded drift) ─────────
def test_popup_binding_strategy_ids_are_catalog_keys():
"""u6 — both constants used by the binder must resolve against the
yaml catalog. Defensive guard against catalog rename / removal."""
assert POPUP_BINDING_NO_POPUP_STRATEGY_ID in DISPLAY_STRATEGIES
assert POPUP_BINDING_ESCALATED_STRATEGY_ID in DISPLAY_STRATEGIES
def test_popup_binding_escalated_strategy_preserves_original_in_catalog():
"""u6 — the escalated-path strategy MUST preserve original content
in the catalog (yaml lock — MDX 원문 무손실 보존). If yaml drift ever
flips this to False, the binder must surface the violation; this
test locks the catalog side of that invariant."""
meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
assert meta.get("preserves_original") is True, (
"Catalog entry for the popup-binding strategy must declare "
"preserves_original=True (오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6)."
)
def test_popup_binding_escalated_strategy_has_detail_trigger_in_catalog():
"""u6 — the escalated-path strategy MUST declare a detail_trigger
block with placement + label in the catalog. The binder reads from
the yaml — no code-side string literal drift."""
meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
trigger = meta.get("detail_trigger")
assert isinstance(trigger, dict)
assert trigger.get("placement"), (
"Catalog detail_trigger.placement must be non-empty so the binder "
"can stamp a deterministic trigger position on the zone payload."
)
assert trigger.get("label"), (
"Catalog detail_trigger.label must be non-empty so the binder "
"can stamp a deterministic trigger identifier on the zone payload."
)
# ─── has_popup=False path ────────────────────────────────────────────
def test_bind_returns_inline_full_when_unit_has_no_popup_marker():
"""u6 — units that never went through the Step 17 POPUP gate carry
has_popup=False. The binder returns the catalog ``inline_full``
strategy with no popup body / no detail trigger."""
unit = _StubUnit(raw_content="MOCK_BODY", has_popup=False)
payload = bind_popup_display_strategy(unit)
assert payload["display_strategy"] == POPUP_BINDING_NO_POPUP_STRATEGY_ID
assert payload["popup_body_source"] is None
assert payload["detail_trigger"] is None
assert payload["has_popup"] is False
assert payload["popup_escalation_plan"] is None
# preserves_original mirrors the catalog inline_full entry.
expected_preserves = bool(
DISPLAY_STRATEGIES[POPUP_BINDING_NO_POPUP_STRATEGY_ID].get(
"preserves_original"
)
)
assert payload["preserves_original"] is expected_preserves
def test_bind_default_when_unit_has_no_has_popup_attr_at_all():
"""u6 — defensive default. Units that lack the ``has_popup`` attr
entirely (e.g., third-party duck-typed stubs that don't carry the
Step 17 marker) bind to the no-popup path. The getattr() default
branch must hold."""
class _BareUnit:
raw_content = "MOCK_BODY"
payload = bind_popup_display_strategy(_BareUnit())
assert payload["display_strategy"] == POPUP_BINDING_NO_POPUP_STRATEGY_ID
assert payload["has_popup"] is False
assert payload["popup_body_source"] is None
# ─── has_popup=True path ─────────────────────────────────────────────
def test_bind_returns_inline_preview_with_details_when_has_popup_true():
"""u6 — feasible POPUP gate escalation flips the binder onto the
``inline_preview_with_details`` strategy (preview = px-budget
excerpt downstream; popup body holds FULL original)."""
plan = _stub_popup_plan()
unit = _StubUnit(
raw_content="MOCK_BODY",
has_popup=True,
popup_escalation_plan=plan,
)
payload = bind_popup_display_strategy(unit)
assert payload["display_strategy"] == POPUP_BINDING_ESCALATED_STRATEGY_ID
assert payload["has_popup"] is True
assert payload["popup_escalation_plan"] is plan
def test_bind_popup_body_source_is_full_raw_content_verbatim():
"""u6 — popup body MUST be the FULL raw_content, byte-for-byte.
The binder NEVER trims or summarizes (MDX 원문 무손실 보존 —
오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110). u7 composes
the body preview from container px telemetry downstream."""
full_text = (
"## MOCK_SECTION_TITLE\n\n"
"- bullet one with **bold** marker\n"
"- bullet two with *italic* marker\n"
"- bullet three trailing\n"
"\n"
"| col_a | col_b |\n| --- | --- |\n| MOCK | DATA |\n"
)
unit = _StubUnit(
raw_content=full_text,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = bind_popup_display_strategy(unit)
assert payload["popup_body_source"] == full_text
# Verbatim guarantee — no length-trimming side channel.
assert len(payload["popup_body_source"]) == len(full_text)
def test_bind_detail_trigger_placement_and_label_come_from_catalog():
"""u6 — detail_trigger.placement / label MUST be read from the yaml
catalog entry's detail_trigger block, not from code constants. This
test compares the binder output against a fresh catalog read so a
catalog rename (e.g., placement: top-right → top-left) propagates
automatically."""
catalog_trigger = (
DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
.get("detail_trigger") or {}
)
unit = _StubUnit(
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = bind_popup_display_strategy(unit)
assert payload["detail_trigger"] == {
"placement": catalog_trigger.get("placement"),
"label": catalog_trigger.get("label"),
}
def test_bind_preserves_original_is_true_on_popup_path():
"""u6 — the popup-binding strategy MUST surface preserves_original=
True so downstream consumers can rely on the absolute user lock
(오답노트 #5). The binder mirrors the catalog value (which the
catalog-side test already locks)."""
unit = _StubUnit(
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = bind_popup_display_strategy(unit)
assert payload["preserves_original"] is True
def test_bind_strategy_meta_is_the_full_catalog_entry():
"""u6 — strategy_meta echoes the full catalog entry so downstream
debug traces can self-explain without re-reading the yaml. Tests
that the binder does not strip / re-shape the catalog dict."""
expected_meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
unit = _StubUnit(
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = bind_popup_display_strategy(unit)
assert payload["strategy_meta"] is expected_meta
def test_bind_popup_escalation_plan_is_echoed_verbatim():
"""u6 — the popup_escalation_plan from u5 is echoed verbatim onto
the zone payload so downstream debug surfaces can trace WHICH router
category triggered the escalation (structural_major_overflow vs
tabular_overflow). Object identity is preserved (no dict copy)."""
plan = _stub_popup_plan("tabular_overflow")
unit = _StubUnit(
has_popup=True,
popup_escalation_plan=plan,
)
payload = bind_popup_display_strategy(unit)
assert payload["popup_escalation_plan"] is plan
assert payload["popup_escalation_plan"]["category"] == "tabular_overflow"
# ─── Defensive guards ───────────────────────────────────────────────
def test_bind_raises_when_strategy_id_missing_from_catalog(monkeypatch):
"""u6 defensive guard — if catalog drift removes the escalated
strategy id, the binder must raise RuntimeError rather than silently
falling back to a wrong strategy. Locks the "yaml is source of
truth" invariant against accidental rename."""
drifted_catalog = {
k: v for k, v in DISPLAY_STRATEGIES.items()
if k != POPUP_BINDING_ESCALATED_STRATEGY_ID
}
monkeypatch.setattr(
"src.phase_z2_composition.DISPLAY_STRATEGIES",
drifted_catalog,
)
unit = _StubUnit(
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
with pytest.raises(RuntimeError, match="catalog drift"):
bind_popup_display_strategy(unit)
def test_bind_raises_when_escalated_strategy_loses_preserves_original(
monkeypatch,
):
"""u6 defensive guard — if the catalog entry for the escalated
strategy ever flips preserves_original to False (yaml drift), the
binder must raise RuntimeError. The absolute user lock — MDX 원문
무손실 보존 — must NOT silently degrade through the binding layer."""
drifted_meta = {
**DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID],
"preserves_original": False,
}
drifted_catalog = {
**DISPLAY_STRATEGIES,
POPUP_BINDING_ESCALATED_STRATEGY_ID: drifted_meta,
}
monkeypatch.setattr(
"src.phase_z2_composition.DISPLAY_STRATEGIES",
drifted_catalog,
)
unit = _StubUnit(
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
with pytest.raises(RuntimeError, match="preserves_original"):
bind_popup_display_strategy(unit)
# ─── AI isolation contract (structural import lock) ─────────────────
def test_composition_module_does_not_import_anthropic_or_route_ai_fallback():
"""u6 — bind_popup_display_strategy MUST stay AI-free. Structural
guard — composition module is allowed to consult the catalog and
unit state, never the Anthropic SDK / route_ai_fallback path. This
mirrors the import-isolation pattern locked by u5 tests in
tests/phase_z2_ai_fallback/test_step17.py."""
import src.phase_z2_composition as composition_module
source = composition_module.__file__
assert source is not None
with open(source, encoding="utf-8") as f:
text = f.read()
assert "import anthropic" not in text
assert "from anthropic" not in text
assert "route_ai_fallback" not in text

View File

@@ -0,0 +1,192 @@
"""IMP-35 (#64) u9 — display_strategies.yaml popup-wiring catalog tests.
Stage 2 binding contract (unit u9):
``templates/phase_z2/regions/display_strategies.yaml`` is the source of
truth for the popup-wiring axis. u9 adds two strategy-level fields:
preview_chars : int | null
Soft char budget for the inline body shown alongside the popup
trigger. ``null`` when the strategy has no popup (``inline_full``,
``dropped``). For popup-bearing strategies the value is the soft
budget for the INLINE preview / summary surface only — the popup
body itself ALWAYS holds the FULL original (MDX 원문 무손실 보존,
오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110).
popup_target_slot : str | null
Frame Layer B slot identifier the popup trigger anchors to.
``null`` when the strategy has no popup. See CLAUDE.md
"위계 + 용어""Frame Slot" / "Layer B" for the slot vocabulary.
Invariants this file locks (catalog side only — u9 is "data only"):
1. Both fields exist on every catalog entry (no missing keys).
2. ``preview_chars`` is ``int >= 0`` for popup-bearing strategies
(``inline_preview_with_details``, ``details_only``) and ``None`` for
non-popup strategies (``inline_full``, ``dropped``).
3. ``popup_target_slot`` is a non-empty ``str`` for popup-bearing
strategies and ``None`` for non-popup strategies.
4. The two fields are mutually consistent — both null OR both populated
within a single strategy entry (no half-wired strategy).
5. The popup-bearing strategies still preserve original content
(popup body = full original; preview_chars governs only the inline
surface, never the popup body).
Cross-references:
- u6 binder (consumes ``DISPLAY_STRATEGIES`` via catalog key):
src/phase_z2_composition.py:bind_popup_display_strategy
- u6 binding tests (existing — must still pass with u9 fields added):
tests/phase_z2/test_composition_popup_strategy.py
- u7 preview text helper (line-budget cut; the char-budget axis u9
introduces is forward config the future wiring will honor):
src/phase_z2_composition.py:compute_popup_preview_text
"""
from __future__ import annotations
import pytest
from src.phase_z2_composition import (
DISPLAY_STRATEGIES,
POPUP_BINDING_ESCALATED_STRATEGY_ID,
POPUP_BINDING_NO_POPUP_STRATEGY_ID,
)
# Catalog keys grouped by popup capability. Sourced from the loaded
# DISPLAY_STRATEGIES so a yaml-side rename surfaces immediately (no
# hardcoded duplicate of catalog keys outside the binder constants).
_POPUP_BEARING_STRATEGY_IDS = (
"inline_preview_with_details",
"details_only",
)
_NON_POPUP_STRATEGY_IDS = (
"inline_full",
"dropped",
)
def test_all_strategies_declare_preview_chars_field():
"""Every catalog entry MUST declare ``preview_chars`` (int or null).
Missing key = yaml drift; the binder + future wiring need a present
field to read deterministically."""
for name, meta in DISPLAY_STRATEGIES.items():
assert "preview_chars" in meta, (
f"display_strategies.yaml entry {name!r} is missing the u9 "
f"`preview_chars` field. Every entry must declare it (int >= 0 "
f"for popup-bearing strategies, null otherwise)."
)
def test_all_strategies_declare_popup_target_slot_field():
"""Every catalog entry MUST declare ``popup_target_slot`` (str or
null). Missing key = yaml drift."""
for name, meta in DISPLAY_STRATEGIES.items():
assert "popup_target_slot" in meta, (
f"display_strategies.yaml entry {name!r} is missing the u9 "
f"`popup_target_slot` field. Every entry must declare it "
f"(non-empty str for popup-bearing strategies, null otherwise)."
)
@pytest.mark.parametrize("strategy_id", _POPUP_BEARING_STRATEGY_IDS)
def test_popup_bearing_strategies_have_nonnegative_int_preview_chars(strategy_id):
"""Popup-bearing strategies declare ``preview_chars`` as ``int >= 0``.
The popup body itself always holds the FULL original (user lock), so
this budget governs only the INLINE preview / summary surface."""
meta = DISPLAY_STRATEGIES[strategy_id]
value = meta.get("preview_chars")
assert isinstance(value, int) and not isinstance(value, bool), (
f"display_strategies.yaml {strategy_id!r} preview_chars must be an "
f"int (got {type(value).__name__}={value!r}). The future wiring "
f"reads it as a deterministic budget — bool / float / str would "
f"silently break downstream comparisons."
)
assert value >= 0, (
f"display_strategies.yaml {strategy_id!r} preview_chars must be "
f">= 0 (got {value!r}). Negative budgets are not a valid surface."
)
@pytest.mark.parametrize("strategy_id", _POPUP_BEARING_STRATEGY_IDS)
def test_popup_bearing_strategies_have_nonempty_string_popup_target_slot(strategy_id):
"""Popup-bearing strategies declare ``popup_target_slot`` as a
non-empty ``str`` — the frame Layer B slot identifier the popup
trigger anchors to."""
meta = DISPLAY_STRATEGIES[strategy_id]
value = meta.get("popup_target_slot")
assert isinstance(value, str), (
f"display_strategies.yaml {strategy_id!r} popup_target_slot must "
f"be a str (got {type(value).__name__}={value!r})."
)
assert value, (
f"display_strategies.yaml {strategy_id!r} popup_target_slot must "
f"be a non-empty string identifying a frame Layer B slot."
)
@pytest.mark.parametrize("strategy_id", _NON_POPUP_STRATEGY_IDS)
def test_non_popup_strategies_have_null_preview_chars(strategy_id):
"""Non-popup strategies (``inline_full`` / ``dropped``) declare
``preview_chars`` as null — they have no popup-side budget axis."""
meta = DISPLAY_STRATEGIES[strategy_id]
assert meta.get("preview_chars") is None, (
f"display_strategies.yaml {strategy_id!r} has no popup; "
f"preview_chars must be null (got {meta.get('preview_chars')!r})."
)
@pytest.mark.parametrize("strategy_id", _NON_POPUP_STRATEGY_IDS)
def test_non_popup_strategies_have_null_popup_target_slot(strategy_id):
"""Non-popup strategies declare ``popup_target_slot`` as null —
nothing for the popup trigger to anchor to."""
meta = DISPLAY_STRATEGIES[strategy_id]
assert meta.get("popup_target_slot") is None, (
f"display_strategies.yaml {strategy_id!r} has no popup; "
f"popup_target_slot must be null (got {meta.get('popup_target_slot')!r})."
)
def test_popup_wiring_fields_are_mutually_consistent_per_strategy():
"""For every catalog entry, ``preview_chars`` and ``popup_target_slot``
must be either BOTH null OR BOTH populated. A half-wired strategy
(one null, one populated) is a yaml-drift bug — surfaces here."""
for name, meta in DISPLAY_STRATEGIES.items():
preview = meta.get("preview_chars")
slot = meta.get("popup_target_slot")
both_null = preview is None and slot is None
both_set = preview is not None and slot is not None
assert both_null or both_set, (
f"display_strategies.yaml {name!r} has inconsistent popup "
f"wiring fields — preview_chars={preview!r}, "
f"popup_target_slot={slot!r}. Must be both null OR both set."
)
def test_binder_constants_point_to_popup_bearing_strategies():
"""The u6 binder constants must continue to resolve against the
catalog entries that carry u9 popup-wiring fields. Cross-axis lock
between the binder (u6) and the catalog (u9) — drift on either side
breaks the popup path silently."""
assert POPUP_BINDING_ESCALATED_STRATEGY_ID in _POPUP_BEARING_STRATEGY_IDS, (
f"u6 binder POPUP_BINDING_ESCALATED_STRATEGY_ID points to "
f"{POPUP_BINDING_ESCALATED_STRATEGY_ID!r} which is NOT a popup-"
f"bearing strategy per the u9 catalog axis."
)
assert POPUP_BINDING_NO_POPUP_STRATEGY_ID in _NON_POPUP_STRATEGY_IDS, (
f"u6 binder POPUP_BINDING_NO_POPUP_STRATEGY_ID points to "
f"{POPUP_BINDING_NO_POPUP_STRATEGY_ID!r} which IS popup-bearing "
f"per the u9 catalog axis — wiring would be miscategorised."
)
def test_popup_bearing_strategies_still_preserve_original():
"""u9 does not alter the existing absolute user lock: popup-bearing
strategies have ``preserves_original=True`` (popup body == full
original). u9 only adds inline-surface budget fields — must NOT
silently degrade the existing invariant."""
for strategy_id in _POPUP_BEARING_STRATEGY_IDS:
meta = DISPLAY_STRATEGIES[strategy_id]
assert meta.get("preserves_original") is True, (
f"display_strategies.yaml {strategy_id!r} must preserve "
f"original content even after u9 — preview_chars governs "
f"the inline surface only, never the popup body."
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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