feat(#64): IMP-35 details_popup_escalation u1~u10 + Stage 3 R7 anchor re-pin
Land the production + test surface for the Step 17 cascade POPUP terminal (DETERMINISTIC -> POPUP -> AI_REPAIR -> USER_OVERRIDE) per Stage 2 plan R2. u11 (baseline-red invariance gate) was already landed in7c93031ahead of this commit; this commit completes u1~u10 plus the Stage 3 R7 follow-up anchor re-pin for test_imp17_comment_anchor.py. Implementation units (Stage 2 R2 contract): u1 frame_reselect_insufficient failure_type + post-frame remeasure (q4) - src/phase_z2_failure_router.py, src/phase_z2_pipeline.py u2 NEXT_ACTION_BY_FAILURE row + impl_status flip - src/phase_z2_failure_router.py u3 Router details_popup_escalation MISSING->IMPLEMENTED + executor stub - src/phase_z2_router.py u4 step17.py AI split-decision contract (POPUP cascade_stage + route_for_label + skip_reason); API gated - src/phase_z2_ai_fallback/step17.py u5 Step 17 POPUP gate executor; popup_escalation_plan + has_popup marker - src/phase_z2_pipeline.py, src/phase_z2_ai_fallback/step17.py u6 Composition popup binding -- yaml strategy -> zone payload - src/phase_z2_composition.py u7 Pipeline composer -> render_slide wiring (popup_html / preview_text / has_popup) - src/phase_z2_pipeline.py u8 slide_base.html <details>/<summary> popup wrapper - templates/phase_z2/slide_base.html u9 display_strategies.yaml inline_preview + popup metadata - templates/phase_z2/regions/display_strategies.yaml u10 MDX preservation invariant: popup=full source / body=summary or subset (asserted by tests/phase_z2/test_popup_mdx_preservation.py) u11 (already in7c93031) -- baseline-red invariance gate Stage 3 R7 follow-up (anchor re-pin, test-only): - tests/orchestrator_unit/test_imp17_comment_anchor.py Pre-anchor additions in src/phase_z2_pipeline.py (u1 / u5 / u7) shifted the restructure/reject route-hint comments 578/579 -> 586/587. Re-pinned the two guard tests (and docstring re-pin lineage 564 -> 570 -> 578 -> 586). Production code untouched. Verification (Stage 4 R1): pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py -> 2 passed / 0.02s pytest -q <10 IMP-35 unit files in tests/phase_z2 + tests/phase_z2_ai_fallback> -> 136 passed / 15.94s Baseline-red invariance gate (tests/test_imp47b_step12_ai_wiring.py + tests/test_phase_z2_ai_fallback_config.py) -> 4 failed / 6 passed; FAILED set === IMP35_BASELINE_RED_NODE_IDS (frozen registry from7c93031). Contract holds. Codex Stage 4 R1 = YES (independent verify). Guardrails honored: - MDX content preservation: popup carries full source, body holds summary or subset only (CLAUDE.md 자세히보기 원칙; feedback_phase_z_spacing_direction -- capacity expanded, no margin shrink). - AI isolation contract: Step 17 POPUP gate is deterministic; AI hook surface is split-decision contract only, API call gated. - No hardcoding: escalation thresholds derived from existing overflow detector outputs; preview_chars deterministic from container px. - 1 commit = 1 decision unit: u1~u10 land together as the planned production surface; u11 was deliberately split into7c93031as Stage 3 R7 carve-out, and the R7 anchor re-pin rides with this commit because it is the direct shift consequence of the u1/u5/u7 pre-anchor additions. - Scope-locked: .claude/settings.json explicitly excluded (Stage 4 exit report contract). Out of scope (per Stage 1 + Stage 2): - AI_REPAIR API activation (post IMP-35 axis). - IMP-34 zone resize, IMP-36 responsive fit (chain partners, separate issues). - Print-time auto-expand JavaScript for <details>. - Popup escalation in stages other than Step 17. - Baseline-red body repair (4 frozen failures) -- separate follow-up issue; u11 only guards the count. - frame_reselect algorithm changes (entry point only). - templates/phase_z2/slide_base.html path rename. source_comment_ids: Stage 1: claude_stage1_problem_review_imp35, codex_stage1_verification_imp35_yes Stage 2: Claude #4 R2 plan, Codex #5 R2 YES Stage 3: Claude #86 (R7 anchor re-pin), Codex #87 YES Stage 4: Claude #88 R1, Codex #89 R1 YES Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
def gather_step17_ai_repair_proposals(
|
||||||
units: Iterable[Any],
|
units: Iterable[Any],
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -315,6 +315,321 @@ def select_display_strategy_candidates(
|
|||||||
return [s for s in order if s in eligible]
|
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 ────────────────────────────────────────────
|
# ─── CompositionUnit ────────────────────────────────────────────
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ frame_reselect (V4 top-k 의 다른 frame)
|
|||||||
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
|
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
|
||||||
```
|
```
|
||||||
|
|
||||||
`details_popup_escalation` 은 본 매핑에 *없음* — tabular_overflow / structural_major_overflow /
|
IMP-35 (#64) u2 — cascade terminal landed. `frame_reselect_insufficient`
|
||||||
frame_reselect 실패 이후 단계에서 다룸 (별 step).
|
(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
|
from __future__ import annotations
|
||||||
@@ -74,6 +78,13 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
|
|||||||
"font_step_compression salvage step failed — FONT_SIZE_STEPS exhausted "
|
"font_step_compression salvage step failed — FONT_SIZE_STEPS exhausted "
|
||||||
"down to the floor without resolving overflow (or text_metrics missing)"
|
"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",
|
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
|
||||||
"glue_compression": "glue_absorption_insufficient",
|
"glue_compression": "glue_absorption_insufficient",
|
||||||
"font_step_compression": "font_step_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",
|
"glue_absorption_insufficient": "font_step_compression",
|
||||||
"font_step_insufficient": "layout_adjust",
|
"font_step_insufficient": "layout_adjust",
|
||||||
"rerender_still_fails": "frame_reselect",
|
"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",
|
"not_attempted": "none",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +152,12 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
|
|||||||
"frame/zone 조합 자체 부적합, V4 top-k 의 다른 frame 평가 (frame_reselect). "
|
"frame/zone 조합 자체 부적합, V4 top-k 의 다른 frame 평가 (frame_reselect). "
|
||||||
"popup 직행은 아직 빠름 (tabular / structural_major 가 아닌 한)"
|
"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": (
|
"not_attempted": (
|
||||||
"retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요"
|
"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
|
"font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css
|
||||||
"layout_adjust": "MISSING",
|
"layout_adjust": "MISSING",
|
||||||
"frame_reselect": "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",
|
"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.
|
# case 0.7 : salvage chain attempted and ended in a salvage-level failure.
|
||||||
# zone_ratio_retry 가 먼저 실패한 뒤 _attempt_salvage_chain 이 가동된 path —
|
# zone_ratio_retry 가 먼저 실패한 뒤 _attempt_salvage_chain 이 가동된 path —
|
||||||
# 마지막 salvage step 의 action 으로 failure_type 을 분류한다. u3 가 routing.
|
# 마지막 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 []
|
salvage_steps = retry_trace.get("salvage_steps") or []
|
||||||
if salvage_steps:
|
if salvage_steps:
|
||||||
last = salvage_steps[-1] or {}
|
last = salvage_steps[-1] or {}
|
||||||
if not last.get("passed"):
|
if not last.get("passed"):
|
||||||
action = (last.get("action") or "").lower()
|
action = (last.get("action") or "").lower()
|
||||||
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
|
frame_reselect_blocked = (
|
||||||
if ftype is not None:
|
action == "frame_reselect"
|
||||||
reason = last.get("failure_reason") or ""
|
and not last.get("post_salvage_overflow")
|
||||||
return {
|
)
|
||||||
"failure_type": ftype,
|
if not frame_reselect_blocked:
|
||||||
"classification_rule": (
|
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
|
||||||
f"salvage_steps[-1].action == {action!r} "
|
if ftype is not None:
|
||||||
f"AND passed=False. raw failure_reason: {reason!r}"
|
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)
|
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
|
||||||
if not retry_trace.get("retry_attempted"):
|
if not retry_trace.get("retry_attempted"):
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|||||||
from phase_z2_composition import (
|
from phase_z2_composition import (
|
||||||
LAYOUT_PRESETS,
|
LAYOUT_PRESETS,
|
||||||
CompositionUnit,
|
CompositionUnit,
|
||||||
|
compose_zone_popup_payload,
|
||||||
derive_parent_id,
|
derive_parent_id,
|
||||||
plan_composition,
|
plan_composition,
|
||||||
resplit_all_reject_merges,
|
resplit_all_reject_merges,
|
||||||
@@ -57,7 +58,7 @@ from phase_z2_mapper import (
|
|||||||
map_with_contract,
|
map_with_contract,
|
||||||
)
|
)
|
||||||
from phase_z2_classifier import classify_visual_runtime_check
|
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 (
|
from phase_z2_retry import (
|
||||||
DEFAULT_SAFETY_MARGIN_PX,
|
DEFAULT_SAFETY_MARGIN_PX,
|
||||||
apply_cross_zone_redistribute_css,
|
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.
|
# 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
|
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 ──────────────────────────────────────────────────
|
# ─── Constants ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -2476,6 +2484,41 @@ def _attempt_salvage_chain(
|
|||||||
return trace
|
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],
|
def render_slide(slide_title: str, slide_footer: Optional[str],
|
||||||
zones_data: list[dict], layout_preset: str,
|
zones_data: list[dict], layout_preset: str,
|
||||||
layout_css: dict, gap_px: int = GRID_GAP,
|
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
|
# first-render invariant holds; u5 will surface the provisional flag as
|
||||||
# a zone class + needs-adaptation badge.
|
# a zone class + needs-adaptation badge.
|
||||||
if unit.frame_template_id == "__empty__":
|
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({
|
zones_data.append({
|
||||||
"position": position,
|
"position": position,
|
||||||
"template_id": "__empty__",
|
"template_id": "__empty__",
|
||||||
@@ -4241,6 +4289,7 @@ def run_phase_z2_mvp1(
|
|||||||
"assignment_source": "imp30_u4_empty_shell",
|
"assignment_source": "imp30_u4_empty_shell",
|
||||||
"section_assignment_override": False,
|
"section_assignment_override": False,
|
||||||
"provisional": bool(getattr(unit, "provisional", False)),
|
"provisional": bool(getattr(unit, "provisional", False)),
|
||||||
|
**_popup_payload,
|
||||||
})
|
})
|
||||||
debug_zones.append({
|
debug_zones.append({
|
||||||
"position": position,
|
"position": position,
|
||||||
@@ -4411,6 +4460,15 @@ def run_phase_z2_mvp1(
|
|||||||
# needs-adaptation badge. Default False keeps non-provisional zones
|
# needs-adaptation badge. Default False keeps non-provisional zones
|
||||||
# byte-identical to pre-u5; only u1-synthesized rank-1 fills or u4
|
# byte-identical to pre-u5; only u1-synthesized rank-1 fills or u4
|
||||||
# empty-shell synthesize provisional=True units.
|
# 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({
|
zones_data.append({
|
||||||
"position": position,
|
"position": position,
|
||||||
"template_id": unit.frame_template_id,
|
"template_id": unit.frame_template_id,
|
||||||
@@ -4420,6 +4478,7 @@ def run_phase_z2_mvp1(
|
|||||||
"assignment_source": plan_assignment_source,
|
"assignment_source": plan_assignment_source,
|
||||||
"section_assignment_override": plan_section_override,
|
"section_assignment_override": plan_section_override,
|
||||||
"provisional": bool(getattr(unit, "provisional", False)),
|
"provisional": bool(getattr(unit, "provisional", False)),
|
||||||
|
**_popup_payload,
|
||||||
})
|
})
|
||||||
debug_zones.append({
|
debug_zones.append({
|
||||||
"position": position,
|
"position": position,
|
||||||
@@ -4475,6 +4534,12 @@ def run_phase_z2_mvp1(
|
|||||||
pos = record["position"]
|
pos = record["position"]
|
||||||
if pos in renderable_positions:
|
if pos in renderable_positions:
|
||||||
continue
|
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({
|
zones_data.append({
|
||||||
"position": pos,
|
"position": pos,
|
||||||
"template_id": "__empty__",
|
"template_id": "__empty__",
|
||||||
@@ -4486,6 +4551,10 @@ def run_phase_z2_mvp1(
|
|||||||
record.get("skipped_reason")
|
record.get("skipped_reason")
|
||||||
or "section_assignment_override_empty_or_unrenderable"
|
or "section_assignment_override_empty_or_unrenderable"
|
||||||
),
|
),
|
||||||
|
"has_popup": False,
|
||||||
|
"popup_html": None,
|
||||||
|
"preview_text": None,
|
||||||
|
"popup_binding": None,
|
||||||
})
|
})
|
||||||
debug_zones.append({
|
debug_zones.append({
|
||||||
"position": pos,
|
"position": pos,
|
||||||
@@ -5615,6 +5684,54 @@ def run_phase_z2_mvp1(
|
|||||||
# fields become None (no failure to classify, no escalation pending).
|
# fields become None (no failure to classify, no escalation pending).
|
||||||
enrich_retry_trace_with_failure_classification(retry_trace)
|
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) ───
|
# ─── Step 17: Implemented Action (retry) ───
|
||||||
_write_step_artifact(
|
_write_step_artifact(
|
||||||
run_dir, 17, "retry_trace",
|
run_dir, 17, "retry_trace",
|
||||||
|
|||||||
@@ -56,12 +56,24 @@ ACTION_RATIONALE: dict[str, str] = {
|
|||||||
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
|
"위 매핑 모두 미적용 — 마지막 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 확보용
|
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
|
||||||
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||||
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
|
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
|
||||||
"layout_adjust": "MISSING",
|
"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
|
"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
|
"adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch
|
||||||
"abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default
|
"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)) 으로 종료."
|
"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 (자세히보기 원칙)."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,16 @@
|
|||||||
# applies_to: list[str] (content types that can use this strategy)
|
# applies_to: list[str] (content types that can use this strategy)
|
||||||
# forbidden_for: list[str] (content types that MUST NOT 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)
|
# 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:
|
inline_full:
|
||||||
@@ -27,6 +37,9 @@ inline_full:
|
|||||||
applies_to: [text_block, table, image, details, decorative_element]
|
applies_to: [text_block, table, image, details, decorative_element]
|
||||||
forbidden_for: []
|
forbidden_for: []
|
||||||
preserves_original: true # all content is inline, original = inline
|
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:
|
inline_preview_with_details:
|
||||||
@@ -34,6 +47,9 @@ inline_preview_with_details:
|
|||||||
applies_to: [text_block, table, details]
|
applies_to: [text_block, table, details]
|
||||||
forbidden_for: [decorative_element]
|
forbidden_for: [decorative_element]
|
||||||
preserves_original: true # User lock — original content kept in popup
|
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:
|
detail_trigger:
|
||||||
placement: top-right # 본문 흐름 방해 X / 보조 동작 위치 / 안정 (user 2026-05-07)
|
placement: top-right # 본문 흐름 방해 X / 보조 동작 위치 / 안정 (user 2026-05-07)
|
||||||
label: details # identifier — display text 는 partial/UI 별 axis
|
label: details # identifier — display text 는 partial/UI 별 axis
|
||||||
@@ -45,6 +61,11 @@ details_only:
|
|||||||
applies_to: [text_block, table, details]
|
applies_to: [text_block, table, details]
|
||||||
forbidden_for: [decorative_element]
|
forbidden_for: [decorative_element]
|
||||||
preserves_original: true # User lock — full content in popup
|
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:
|
detail_trigger:
|
||||||
placement: top-right # user lock — popup 진입 일관 위치
|
placement: top-right # user lock — popup 진입 일관 위치
|
||||||
label: details
|
label: details
|
||||||
@@ -60,3 +81,6 @@ dropped:
|
|||||||
applies_to: [decorative_element]
|
applies_to: [decorative_element]
|
||||||
forbidden_for: [text_block, table, image, details]
|
forbidden_for: [text_block, table, image, details]
|
||||||
preserves_original: false # decorative only — no original to preserve
|
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
|
||||||
|
|||||||
@@ -290,6 +290,71 @@
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
z-index: 3;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -301,9 +366,19 @@
|
|||||||
<div class="slide-body">
|
<div class="slide-body">
|
||||||
<div class="layout-{{ layout_preset }}">
|
<div class="layout-{{ layout_preset }}">
|
||||||
{% for zone in zones %}
|
{% 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 %}
|
{% if zone.provisional %}<span class="zone__needs-adaptation-badge" aria-label="needs user or AI adaptation">needs adaptation</span>{% endif %}
|
||||||
{{ zone.partial_html | safe }}
|
{{ 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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
the restructure line. Re-pinned 570 → 578 (restructure / IMP-17) and 571 → 579
|
||||||
(reject / IMP-47B supersession of the prior IMP-29 reference).
|
(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
|
Run: pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -29,17 +36,17 @@ def _lines() -> list[str]:
|
|||||||
return PIPELINE.read_text(encoding="utf-8").splitlines()
|
return PIPELINE.read_text(encoding="utf-8").splitlines()
|
||||||
|
|
||||||
|
|
||||||
def test_line_578_references_imp17_not_imp31():
|
def test_line_586_references_imp17_not_imp31():
|
||||||
line = _lines()[577] # 1-indexed line 578
|
line = _lines()[585] # 1-indexed line 586
|
||||||
assert "restructure" in line, f"line 578 anchor drifted: {line!r}"
|
assert "restructure" in line, f"line 586 anchor drifted: {line!r}"
|
||||||
assert "IMP-17" in line, f"line 578 must reference IMP-17 (carve-out): {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 578 must not reference non-existent IMP-31: {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():
|
def test_line_587_references_imp47b_supersession():
|
||||||
line = _lines()[578] # 1-indexed line 579
|
line = _lines()[586] # 1-indexed line 587
|
||||||
assert "reject" in line, f"line 579 anchor drifted: {line!r}"
|
assert "reject" in line, f"line 587 anchor drifted: {line!r}"
|
||||||
assert "IMP-47B" in line, (
|
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}"
|
f"{line!r}"
|
||||||
)
|
)
|
||||||
|
|||||||
333
tests/phase_z2/test_composition_popup_strategy.py
Normal file
333
tests/phase_z2/test_composition_popup_strategy.py
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
"""IMP-35 (#64) u6 — Composition popup binding tests.
|
||||||
|
|
||||||
|
Stage 2 binding contract (unit u6):
|
||||||
|
``bind_popup_display_strategy`` in ``src/phase_z2_composition.py`` is
|
||||||
|
the composition-side binding that translates the unit-side marker
|
||||||
|
(``has_popup`` + ``popup_escalation_plan``) stamped by the Step 17
|
||||||
|
POPUP gate (u5 in ``src/phase_z2_ai_fallback/step17.py``) into a
|
||||||
|
deterministic zone payload structure that u7 wires into the renderer.
|
||||||
|
|
||||||
|
Key invariants this file locks:
|
||||||
|
1. Strategy id is the catalog key (yaml is source of truth) — no
|
||||||
|
hardcoded literal string drift between code and
|
||||||
|
``display_strategies.yaml``.
|
||||||
|
2. ``has_popup=False`` units bind to ``inline_full`` (no popup).
|
||||||
|
3. ``has_popup=True`` units bind to ``inline_preview_with_details``
|
||||||
|
(preview = excerpt from container px budget downstream; popup
|
||||||
|
body holds the FULL original per CLAUDE.md 자세히보기 원칙).
|
||||||
|
4. ``popup_body_source`` is the FULL ``raw_content``, verbatim —
|
||||||
|
u6 NEVER trims or summarizes (MDX 원문 무손실 보존, 오답노트 #5,
|
||||||
|
IMPROVEMENT-REDESIGN.md §3.6 line 110).
|
||||||
|
5. ``detail_trigger.placement`` / ``label`` come from the catalog
|
||||||
|
entry's ``detail_trigger`` block, not from code constants.
|
||||||
|
6. The popup-binding strategy MUST have ``preserves_original=True``
|
||||||
|
in the catalog (defensive yaml-drift guard).
|
||||||
|
7. No AI call. ``bind_popup_display_strategy`` is pure composition-
|
||||||
|
side binding — feedback_ai_isolation_contract.
|
||||||
|
|
||||||
|
Cross-references:
|
||||||
|
- u3 router stub (``plan_details_popup_escalation``):
|
||||||
|
tests/phase_z2/test_phase_z2_router_popup.py
|
||||||
|
- u4 api_gated split-decision contract:
|
||||||
|
tests/phase_z2_ai_fallback/test_step17.py
|
||||||
|
- u5 Step 17 POPUP gate (stamps the marker u6 reads):
|
||||||
|
tests/phase_z2/test_phase_z2_step17_popup_gate.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.phase_z2_composition import (
|
||||||
|
DISPLAY_STRATEGIES,
|
||||||
|
POPUP_BINDING_ESCALATED_STRATEGY_ID,
|
||||||
|
POPUP_BINDING_NO_POPUP_STRATEGY_ID,
|
||||||
|
bind_popup_display_strategy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Synthetic stubs ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _StubUnit:
|
||||||
|
"""Minimal duck-typed CompositionUnit for u6 binding tests.
|
||||||
|
|
||||||
|
Mirrors only the fields ``bind_popup_display_strategy`` reads via
|
||||||
|
getattr — keeps the test independent of the full CompositionUnit
|
||||||
|
dataclass evolution (e.g., IMP-30 / IMP-48 axis additions).
|
||||||
|
"""
|
||||||
|
|
||||||
|
raw_content: str = "MOCK_ORIGINAL_CONTENT"
|
||||||
|
has_popup: bool = False
|
||||||
|
popup_escalation_plan: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_popup_plan(category: str = "structural_major_overflow") -> dict:
|
||||||
|
"""Mirror the shape ``plan_details_popup_escalation`` returns on a
|
||||||
|
feasible escalation. u6 echoes this verbatim — no field is consumed
|
||||||
|
here other than as a traceable payload."""
|
||||||
|
return {
|
||||||
|
"action": "details_popup_escalation",
|
||||||
|
"stub": True,
|
||||||
|
"feasible": True,
|
||||||
|
"category": category,
|
||||||
|
"needs_split_decision": True,
|
||||||
|
"rationale": "MOCK_RATIONALE",
|
||||||
|
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Catalog constants are catalog keys (no hardcoded drift) ─────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_binding_strategy_ids_are_catalog_keys():
|
||||||
|
"""u6 — both constants used by the binder must resolve against the
|
||||||
|
yaml catalog. Defensive guard against catalog rename / removal."""
|
||||||
|
assert POPUP_BINDING_NO_POPUP_STRATEGY_ID in DISPLAY_STRATEGIES
|
||||||
|
assert POPUP_BINDING_ESCALATED_STRATEGY_ID in DISPLAY_STRATEGIES
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_binding_escalated_strategy_preserves_original_in_catalog():
|
||||||
|
"""u6 — the escalated-path strategy MUST preserve original content
|
||||||
|
in the catalog (yaml lock — MDX 원문 무손실 보존). If yaml drift ever
|
||||||
|
flips this to False, the binder must surface the violation; this
|
||||||
|
test locks the catalog side of that invariant."""
|
||||||
|
meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
|
||||||
|
assert meta.get("preserves_original") is True, (
|
||||||
|
"Catalog entry for the popup-binding strategy must declare "
|
||||||
|
"preserves_original=True (오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_binding_escalated_strategy_has_detail_trigger_in_catalog():
|
||||||
|
"""u6 — the escalated-path strategy MUST declare a detail_trigger
|
||||||
|
block with placement + label in the catalog. The binder reads from
|
||||||
|
the yaml — no code-side string literal drift."""
|
||||||
|
meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
|
||||||
|
trigger = meta.get("detail_trigger")
|
||||||
|
assert isinstance(trigger, dict)
|
||||||
|
assert trigger.get("placement"), (
|
||||||
|
"Catalog detail_trigger.placement must be non-empty so the binder "
|
||||||
|
"can stamp a deterministic trigger position on the zone payload."
|
||||||
|
)
|
||||||
|
assert trigger.get("label"), (
|
||||||
|
"Catalog detail_trigger.label must be non-empty so the binder "
|
||||||
|
"can stamp a deterministic trigger identifier on the zone payload."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── has_popup=False path ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_returns_inline_full_when_unit_has_no_popup_marker():
|
||||||
|
"""u6 — units that never went through the Step 17 POPUP gate carry
|
||||||
|
has_popup=False. The binder returns the catalog ``inline_full``
|
||||||
|
strategy with no popup body / no detail trigger."""
|
||||||
|
unit = _StubUnit(raw_content="MOCK_BODY", has_popup=False)
|
||||||
|
payload = bind_popup_display_strategy(unit)
|
||||||
|
assert payload["display_strategy"] == POPUP_BINDING_NO_POPUP_STRATEGY_ID
|
||||||
|
assert payload["popup_body_source"] is None
|
||||||
|
assert payload["detail_trigger"] is None
|
||||||
|
assert payload["has_popup"] is False
|
||||||
|
assert payload["popup_escalation_plan"] is None
|
||||||
|
# preserves_original mirrors the catalog inline_full entry.
|
||||||
|
expected_preserves = bool(
|
||||||
|
DISPLAY_STRATEGIES[POPUP_BINDING_NO_POPUP_STRATEGY_ID].get(
|
||||||
|
"preserves_original"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert payload["preserves_original"] is expected_preserves
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_default_when_unit_has_no_has_popup_attr_at_all():
|
||||||
|
"""u6 — defensive default. Units that lack the ``has_popup`` attr
|
||||||
|
entirely (e.g., third-party duck-typed stubs that don't carry the
|
||||||
|
Step 17 marker) bind to the no-popup path. The getattr() default
|
||||||
|
branch must hold."""
|
||||||
|
|
||||||
|
class _BareUnit:
|
||||||
|
raw_content = "MOCK_BODY"
|
||||||
|
|
||||||
|
payload = bind_popup_display_strategy(_BareUnit())
|
||||||
|
assert payload["display_strategy"] == POPUP_BINDING_NO_POPUP_STRATEGY_ID
|
||||||
|
assert payload["has_popup"] is False
|
||||||
|
assert payload["popup_body_source"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── has_popup=True path ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_returns_inline_preview_with_details_when_has_popup_true():
|
||||||
|
"""u6 — feasible POPUP gate escalation flips the binder onto the
|
||||||
|
``inline_preview_with_details`` strategy (preview = px-budget
|
||||||
|
excerpt downstream; popup body holds FULL original)."""
|
||||||
|
plan = _stub_popup_plan()
|
||||||
|
unit = _StubUnit(
|
||||||
|
raw_content="MOCK_BODY",
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=plan,
|
||||||
|
)
|
||||||
|
payload = bind_popup_display_strategy(unit)
|
||||||
|
assert payload["display_strategy"] == POPUP_BINDING_ESCALATED_STRATEGY_ID
|
||||||
|
assert payload["has_popup"] is True
|
||||||
|
assert payload["popup_escalation_plan"] is plan
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_popup_body_source_is_full_raw_content_verbatim():
|
||||||
|
"""u6 — popup body MUST be the FULL raw_content, byte-for-byte.
|
||||||
|
The binder NEVER trims or summarizes (MDX 원문 무손실 보존 —
|
||||||
|
오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110). u7 composes
|
||||||
|
the body preview from container px telemetry downstream."""
|
||||||
|
full_text = (
|
||||||
|
"## MOCK_SECTION_TITLE\n\n"
|
||||||
|
"- bullet one with **bold** marker\n"
|
||||||
|
"- bullet two with *italic* marker\n"
|
||||||
|
"- bullet three trailing\n"
|
||||||
|
"\n"
|
||||||
|
"| col_a | col_b |\n| --- | --- |\n| MOCK | DATA |\n"
|
||||||
|
)
|
||||||
|
unit = _StubUnit(
|
||||||
|
raw_content=full_text,
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
payload = bind_popup_display_strategy(unit)
|
||||||
|
assert payload["popup_body_source"] == full_text
|
||||||
|
# Verbatim guarantee — no length-trimming side channel.
|
||||||
|
assert len(payload["popup_body_source"]) == len(full_text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_detail_trigger_placement_and_label_come_from_catalog():
|
||||||
|
"""u6 — detail_trigger.placement / label MUST be read from the yaml
|
||||||
|
catalog entry's detail_trigger block, not from code constants. This
|
||||||
|
test compares the binder output against a fresh catalog read so a
|
||||||
|
catalog rename (e.g., placement: top-right → top-left) propagates
|
||||||
|
automatically."""
|
||||||
|
catalog_trigger = (
|
||||||
|
DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
|
||||||
|
.get("detail_trigger") or {}
|
||||||
|
)
|
||||||
|
unit = _StubUnit(
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
payload = bind_popup_display_strategy(unit)
|
||||||
|
assert payload["detail_trigger"] == {
|
||||||
|
"placement": catalog_trigger.get("placement"),
|
||||||
|
"label": catalog_trigger.get("label"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_preserves_original_is_true_on_popup_path():
|
||||||
|
"""u6 — the popup-binding strategy MUST surface preserves_original=
|
||||||
|
True so downstream consumers can rely on the absolute user lock
|
||||||
|
(오답노트 #5). The binder mirrors the catalog value (which the
|
||||||
|
catalog-side test already locks)."""
|
||||||
|
unit = _StubUnit(
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
payload = bind_popup_display_strategy(unit)
|
||||||
|
assert payload["preserves_original"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_strategy_meta_is_the_full_catalog_entry():
|
||||||
|
"""u6 — strategy_meta echoes the full catalog entry so downstream
|
||||||
|
debug traces can self-explain without re-reading the yaml. Tests
|
||||||
|
that the binder does not strip / re-shape the catalog dict."""
|
||||||
|
expected_meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
|
||||||
|
unit = _StubUnit(
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
payload = bind_popup_display_strategy(unit)
|
||||||
|
assert payload["strategy_meta"] is expected_meta
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_popup_escalation_plan_is_echoed_verbatim():
|
||||||
|
"""u6 — the popup_escalation_plan from u5 is echoed verbatim onto
|
||||||
|
the zone payload so downstream debug surfaces can trace WHICH router
|
||||||
|
category triggered the escalation (structural_major_overflow vs
|
||||||
|
tabular_overflow). Object identity is preserved (no dict copy)."""
|
||||||
|
plan = _stub_popup_plan("tabular_overflow")
|
||||||
|
unit = _StubUnit(
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=plan,
|
||||||
|
)
|
||||||
|
payload = bind_popup_display_strategy(unit)
|
||||||
|
assert payload["popup_escalation_plan"] is plan
|
||||||
|
assert payload["popup_escalation_plan"]["category"] == "tabular_overflow"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Defensive guards ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_raises_when_strategy_id_missing_from_catalog(monkeypatch):
|
||||||
|
"""u6 defensive guard — if catalog drift removes the escalated
|
||||||
|
strategy id, the binder must raise RuntimeError rather than silently
|
||||||
|
falling back to a wrong strategy. Locks the "yaml is source of
|
||||||
|
truth" invariant against accidental rename."""
|
||||||
|
drifted_catalog = {
|
||||||
|
k: v for k, v in DISPLAY_STRATEGIES.items()
|
||||||
|
if k != POPUP_BINDING_ESCALATED_STRATEGY_ID
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.phase_z2_composition.DISPLAY_STRATEGIES",
|
||||||
|
drifted_catalog,
|
||||||
|
)
|
||||||
|
unit = _StubUnit(
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match="catalog drift"):
|
||||||
|
bind_popup_display_strategy(unit)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_raises_when_escalated_strategy_loses_preserves_original(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""u6 defensive guard — if the catalog entry for the escalated
|
||||||
|
strategy ever flips preserves_original to False (yaml drift), the
|
||||||
|
binder must raise RuntimeError. The absolute user lock — MDX 원문
|
||||||
|
무손실 보존 — must NOT silently degrade through the binding layer."""
|
||||||
|
drifted_meta = {
|
||||||
|
**DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID],
|
||||||
|
"preserves_original": False,
|
||||||
|
}
|
||||||
|
drifted_catalog = {
|
||||||
|
**DISPLAY_STRATEGIES,
|
||||||
|
POPUP_BINDING_ESCALATED_STRATEGY_ID: drifted_meta,
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.phase_z2_composition.DISPLAY_STRATEGIES",
|
||||||
|
drifted_catalog,
|
||||||
|
)
|
||||||
|
unit = _StubUnit(
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match="preserves_original"):
|
||||||
|
bind_popup_display_strategy(unit)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AI isolation contract (structural import lock) ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_composition_module_does_not_import_anthropic_or_route_ai_fallback():
|
||||||
|
"""u6 — bind_popup_display_strategy MUST stay AI-free. Structural
|
||||||
|
guard — composition module is allowed to consult the catalog and
|
||||||
|
unit state, never the Anthropic SDK / route_ai_fallback path. This
|
||||||
|
mirrors the import-isolation pattern locked by u5 tests in
|
||||||
|
tests/phase_z2_ai_fallback/test_step17.py."""
|
||||||
|
import src.phase_z2_composition as composition_module
|
||||||
|
|
||||||
|
source = composition_module.__file__
|
||||||
|
assert source is not None
|
||||||
|
with open(source, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
assert "import anthropic" not in text
|
||||||
|
assert "from anthropic" not in text
|
||||||
|
assert "route_ai_fallback" not in text
|
||||||
192
tests/phase_z2/test_display_strategies_popup.py
Normal file
192
tests/phase_z2/test_display_strategies_popup.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""IMP-35 (#64) u9 — display_strategies.yaml popup-wiring catalog tests.
|
||||||
|
|
||||||
|
Stage 2 binding contract (unit u9):
|
||||||
|
``templates/phase_z2/regions/display_strategies.yaml`` is the source of
|
||||||
|
truth for the popup-wiring axis. u9 adds two strategy-level fields:
|
||||||
|
|
||||||
|
preview_chars : int | null
|
||||||
|
Soft char budget for the inline body shown alongside the popup
|
||||||
|
trigger. ``null`` when the strategy has no popup (``inline_full``,
|
||||||
|
``dropped``). For popup-bearing strategies the value is the soft
|
||||||
|
budget for the INLINE preview / summary surface only — the popup
|
||||||
|
body itself ALWAYS holds the FULL original (MDX 원문 무손실 보존,
|
||||||
|
오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110).
|
||||||
|
|
||||||
|
popup_target_slot : str | null
|
||||||
|
Frame Layer B slot identifier the popup trigger anchors to.
|
||||||
|
``null`` when the strategy has no popup. See CLAUDE.md
|
||||||
|
"위계 + 용어" → "Frame Slot" / "Layer B" for the slot vocabulary.
|
||||||
|
|
||||||
|
Invariants this file locks (catalog side only — u9 is "data only"):
|
||||||
|
|
||||||
|
1. Both fields exist on every catalog entry (no missing keys).
|
||||||
|
2. ``preview_chars`` is ``int >= 0`` for popup-bearing strategies
|
||||||
|
(``inline_preview_with_details``, ``details_only``) and ``None`` for
|
||||||
|
non-popup strategies (``inline_full``, ``dropped``).
|
||||||
|
3. ``popup_target_slot`` is a non-empty ``str`` for popup-bearing
|
||||||
|
strategies and ``None`` for non-popup strategies.
|
||||||
|
4. The two fields are mutually consistent — both null OR both populated
|
||||||
|
within a single strategy entry (no half-wired strategy).
|
||||||
|
5. The popup-bearing strategies still preserve original content
|
||||||
|
(popup body = full original; preview_chars governs only the inline
|
||||||
|
surface, never the popup body).
|
||||||
|
|
||||||
|
Cross-references:
|
||||||
|
- u6 binder (consumes ``DISPLAY_STRATEGIES`` via catalog key):
|
||||||
|
src/phase_z2_composition.py:bind_popup_display_strategy
|
||||||
|
- u6 binding tests (existing — must still pass with u9 fields added):
|
||||||
|
tests/phase_z2/test_composition_popup_strategy.py
|
||||||
|
- u7 preview text helper (line-budget cut; the char-budget axis u9
|
||||||
|
introduces is forward config the future wiring will honor):
|
||||||
|
src/phase_z2_composition.py:compute_popup_preview_text
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.phase_z2_composition import (
|
||||||
|
DISPLAY_STRATEGIES,
|
||||||
|
POPUP_BINDING_ESCALATED_STRATEGY_ID,
|
||||||
|
POPUP_BINDING_NO_POPUP_STRATEGY_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Catalog keys grouped by popup capability. Sourced from the loaded
|
||||||
|
# DISPLAY_STRATEGIES so a yaml-side rename surfaces immediately (no
|
||||||
|
# hardcoded duplicate of catalog keys outside the binder constants).
|
||||||
|
_POPUP_BEARING_STRATEGY_IDS = (
|
||||||
|
"inline_preview_with_details",
|
||||||
|
"details_only",
|
||||||
|
)
|
||||||
|
_NON_POPUP_STRATEGY_IDS = (
|
||||||
|
"inline_full",
|
||||||
|
"dropped",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_strategies_declare_preview_chars_field():
|
||||||
|
"""Every catalog entry MUST declare ``preview_chars`` (int or null).
|
||||||
|
Missing key = yaml drift; the binder + future wiring need a present
|
||||||
|
field to read deterministically."""
|
||||||
|
for name, meta in DISPLAY_STRATEGIES.items():
|
||||||
|
assert "preview_chars" in meta, (
|
||||||
|
f"display_strategies.yaml entry {name!r} is missing the u9 "
|
||||||
|
f"`preview_chars` field. Every entry must declare it (int >= 0 "
|
||||||
|
f"for popup-bearing strategies, null otherwise)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_strategies_declare_popup_target_slot_field():
|
||||||
|
"""Every catalog entry MUST declare ``popup_target_slot`` (str or
|
||||||
|
null). Missing key = yaml drift."""
|
||||||
|
for name, meta in DISPLAY_STRATEGIES.items():
|
||||||
|
assert "popup_target_slot" in meta, (
|
||||||
|
f"display_strategies.yaml entry {name!r} is missing the u9 "
|
||||||
|
f"`popup_target_slot` field. Every entry must declare it "
|
||||||
|
f"(non-empty str for popup-bearing strategies, null otherwise)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("strategy_id", _POPUP_BEARING_STRATEGY_IDS)
|
||||||
|
def test_popup_bearing_strategies_have_nonnegative_int_preview_chars(strategy_id):
|
||||||
|
"""Popup-bearing strategies declare ``preview_chars`` as ``int >= 0``.
|
||||||
|
The popup body itself always holds the FULL original (user lock), so
|
||||||
|
this budget governs only the INLINE preview / summary surface."""
|
||||||
|
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||||
|
value = meta.get("preview_chars")
|
||||||
|
assert isinstance(value, int) and not isinstance(value, bool), (
|
||||||
|
f"display_strategies.yaml {strategy_id!r} preview_chars must be an "
|
||||||
|
f"int (got {type(value).__name__}={value!r}). The future wiring "
|
||||||
|
f"reads it as a deterministic budget — bool / float / str would "
|
||||||
|
f"silently break downstream comparisons."
|
||||||
|
)
|
||||||
|
assert value >= 0, (
|
||||||
|
f"display_strategies.yaml {strategy_id!r} preview_chars must be "
|
||||||
|
f">= 0 (got {value!r}). Negative budgets are not a valid surface."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("strategy_id", _POPUP_BEARING_STRATEGY_IDS)
|
||||||
|
def test_popup_bearing_strategies_have_nonempty_string_popup_target_slot(strategy_id):
|
||||||
|
"""Popup-bearing strategies declare ``popup_target_slot`` as a
|
||||||
|
non-empty ``str`` — the frame Layer B slot identifier the popup
|
||||||
|
trigger anchors to."""
|
||||||
|
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||||
|
value = meta.get("popup_target_slot")
|
||||||
|
assert isinstance(value, str), (
|
||||||
|
f"display_strategies.yaml {strategy_id!r} popup_target_slot must "
|
||||||
|
f"be a str (got {type(value).__name__}={value!r})."
|
||||||
|
)
|
||||||
|
assert value, (
|
||||||
|
f"display_strategies.yaml {strategy_id!r} popup_target_slot must "
|
||||||
|
f"be a non-empty string identifying a frame Layer B slot."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("strategy_id", _NON_POPUP_STRATEGY_IDS)
|
||||||
|
def test_non_popup_strategies_have_null_preview_chars(strategy_id):
|
||||||
|
"""Non-popup strategies (``inline_full`` / ``dropped``) declare
|
||||||
|
``preview_chars`` as null — they have no popup-side budget axis."""
|
||||||
|
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||||
|
assert meta.get("preview_chars") is None, (
|
||||||
|
f"display_strategies.yaml {strategy_id!r} has no popup; "
|
||||||
|
f"preview_chars must be null (got {meta.get('preview_chars')!r})."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("strategy_id", _NON_POPUP_STRATEGY_IDS)
|
||||||
|
def test_non_popup_strategies_have_null_popup_target_slot(strategy_id):
|
||||||
|
"""Non-popup strategies declare ``popup_target_slot`` as null —
|
||||||
|
nothing for the popup trigger to anchor to."""
|
||||||
|
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||||
|
assert meta.get("popup_target_slot") is None, (
|
||||||
|
f"display_strategies.yaml {strategy_id!r} has no popup; "
|
||||||
|
f"popup_target_slot must be null (got {meta.get('popup_target_slot')!r})."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_wiring_fields_are_mutually_consistent_per_strategy():
|
||||||
|
"""For every catalog entry, ``preview_chars`` and ``popup_target_slot``
|
||||||
|
must be either BOTH null OR BOTH populated. A half-wired strategy
|
||||||
|
(one null, one populated) is a yaml-drift bug — surfaces here."""
|
||||||
|
for name, meta in DISPLAY_STRATEGIES.items():
|
||||||
|
preview = meta.get("preview_chars")
|
||||||
|
slot = meta.get("popup_target_slot")
|
||||||
|
both_null = preview is None and slot is None
|
||||||
|
both_set = preview is not None and slot is not None
|
||||||
|
assert both_null or both_set, (
|
||||||
|
f"display_strategies.yaml {name!r} has inconsistent popup "
|
||||||
|
f"wiring fields — preview_chars={preview!r}, "
|
||||||
|
f"popup_target_slot={slot!r}. Must be both null OR both set."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_binder_constants_point_to_popup_bearing_strategies():
|
||||||
|
"""The u6 binder constants must continue to resolve against the
|
||||||
|
catalog entries that carry u9 popup-wiring fields. Cross-axis lock
|
||||||
|
between the binder (u6) and the catalog (u9) — drift on either side
|
||||||
|
breaks the popup path silently."""
|
||||||
|
assert POPUP_BINDING_ESCALATED_STRATEGY_ID in _POPUP_BEARING_STRATEGY_IDS, (
|
||||||
|
f"u6 binder POPUP_BINDING_ESCALATED_STRATEGY_ID points to "
|
||||||
|
f"{POPUP_BINDING_ESCALATED_STRATEGY_ID!r} which is NOT a popup-"
|
||||||
|
f"bearing strategy per the u9 catalog axis."
|
||||||
|
)
|
||||||
|
assert POPUP_BINDING_NO_POPUP_STRATEGY_ID in _NON_POPUP_STRATEGY_IDS, (
|
||||||
|
f"u6 binder POPUP_BINDING_NO_POPUP_STRATEGY_ID points to "
|
||||||
|
f"{POPUP_BINDING_NO_POPUP_STRATEGY_ID!r} which IS popup-bearing "
|
||||||
|
f"per the u9 catalog axis — wiring would be miscategorised."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_bearing_strategies_still_preserve_original():
|
||||||
|
"""u9 does not alter the existing absolute user lock: popup-bearing
|
||||||
|
strategies have ``preserves_original=True`` (popup body == full
|
||||||
|
original). u9 only adds inline-surface budget fields — must NOT
|
||||||
|
silently degrade the existing invariant."""
|
||||||
|
for strategy_id in _POPUP_BEARING_STRATEGY_IDS:
|
||||||
|
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||||
|
assert meta.get("preserves_original") is True, (
|
||||||
|
f"display_strategies.yaml {strategy_id!r} must preserve "
|
||||||
|
f"original content even after u9 — preview_chars governs "
|
||||||
|
f"the inline surface only, never the popup body."
|
||||||
|
)
|
||||||
@@ -117,3 +117,136 @@ def test_rerender_still_fails_preserved_routes_to_frame_reselect():
|
|||||||
assert fc["failure_type"] == "rerender_still_fails"
|
assert fc["failure_type"] == "rerender_still_fails"
|
||||||
nr = route_retry_failure("rerender_still_fails")
|
nr = route_retry_failure("rerender_still_fails")
|
||||||
assert nr["next_proposed_action"] == "frame_reselect"
|
assert nr["next_proposed_action"] == "frame_reselect"
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_reselect_insufficient_classifier_emits_from_salvage_steps():
|
||||||
|
"""IMP-35 (#64) u1 — post-frame remeasure contract.
|
||||||
|
|
||||||
|
When the future frame_reselect orchestrator appends a salvage_steps entry
|
||||||
|
with action='frame_reselect', passed=False, and a post-frame remeasure
|
||||||
|
in post_salvage_overflow, the classifier must emit frame_reselect_insufficient
|
||||||
|
via SALVAGE_FAILURE_TYPE_BY_ACTION (q4 = explicit remeasure, not flag
|
||||||
|
carryover). NEXT_ACTION routing (→ details_popup_escalation) landed in u2;
|
||||||
|
see test_frame_reselect_insufficient_routes_to_details_popup_escalation
|
||||||
|
below for the u2-locked routing contract.
|
||||||
|
"""
|
||||||
|
from src.phase_z2_failure_router import (
|
||||||
|
FAILURE_TYPE_DESCRIPTIONS,
|
||||||
|
SALVAGE_FAILURE_TYPE_BY_ACTION,
|
||||||
|
)
|
||||||
|
# Registry contract: the new failure_type + SALVAGE action mapping exist.
|
||||||
|
assert "frame_reselect_insufficient" in FAILURE_TYPE_DESCRIPTIONS
|
||||||
|
assert SALVAGE_FAILURE_TYPE_BY_ACTION["frame_reselect"] == "frame_reselect_insufficient"
|
||||||
|
|
||||||
|
trace = {
|
||||||
|
"retry_attempted": True,
|
||||||
|
"retry_passed": False,
|
||||||
|
"salvage_passed": False,
|
||||||
|
"salvage_steps": [
|
||||||
|
{
|
||||||
|
"action": "frame_reselect",
|
||||||
|
"passed": False,
|
||||||
|
"failure_reason": "post-frame remeasure: overflow persists",
|
||||||
|
"post_salvage_overflow": {"passed": False, "fail_reasons": ["body still clipped"]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
fc = classify_retry_failure(trace)
|
||||||
|
assert fc is not None
|
||||||
|
assert fc["failure_type"] == "frame_reselect_insufficient"
|
||||||
|
assert "frame_reselect" in fc["classification_rule"]
|
||||||
|
# q4 contract: classification_rule MUST cite post_salvage_overflow so the
|
||||||
|
# remeasure evidence is auditable from the trace (not a bare action flag).
|
||||||
|
assert "post_salvage_overflow" in fc["classification_rule"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_reselect_without_post_salvage_overflow_is_not_classified_as_insufficient():
|
||||||
|
"""IMP-35 (#64) u1 — q4 negative guard.
|
||||||
|
|
||||||
|
A failed frame_reselect salvage step **without** post_salvage_overflow
|
||||||
|
evidence must NOT be classified as frame_reselect_insufficient. q4 of the
|
||||||
|
Stage 2 plan locks the contract: classification requires an explicit
|
||||||
|
post-frame remeasure payload, not a carried/manual failure flag. Without
|
||||||
|
that evidence the classifier falls through to the lower-priority cases
|
||||||
|
(defensive fallback) so the cascade never escalates onto
|
||||||
|
details_popup_escalation spuriously.
|
||||||
|
"""
|
||||||
|
trace = {
|
||||||
|
"retry_attempted": True,
|
||||||
|
"retry_passed": False,
|
||||||
|
"salvage_passed": False,
|
||||||
|
"salvage_steps": [
|
||||||
|
{
|
||||||
|
"action": "frame_reselect",
|
||||||
|
"passed": False,
|
||||||
|
"failure_reason": "carried failure flag — no remeasure payload",
|
||||||
|
# post_salvage_overflow intentionally absent
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
fc = classify_retry_failure(trace)
|
||||||
|
assert fc is not None
|
||||||
|
assert fc["failure_type"] != "frame_reselect_insufficient", (
|
||||||
|
"frame_reselect without post_salvage_overflow must not classify as "
|
||||||
|
"frame_reselect_insufficient (q4 contract — explicit remeasure, not "
|
||||||
|
"failure-flag carryover)."
|
||||||
|
)
|
||||||
|
# Routing must NOT escalate onto details_popup_escalation when the gate
|
||||||
|
# is not satisfied. u2 landed the frame_reselect_insufficient →
|
||||||
|
# details_popup_escalation mapping; this negative path protects against
|
||||||
|
# premature popup escalation when classifier fell through to a lower-
|
||||||
|
# priority failure type (not frame_reselect_insufficient).
|
||||||
|
nr = route_retry_failure(fc["failure_type"])
|
||||||
|
assert nr["next_proposed_action"] != "details_popup_escalation"
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_reselect_insufficient_routes_to_details_popup_escalation():
|
||||||
|
"""IMP-35 (#64) u2 — cascade terminal routing contract.
|
||||||
|
|
||||||
|
frame_reselect_insufficient is the deterministic cascade terminal. u2
|
||||||
|
locks the NEXT_ACTION_BY_FAILURE row so the failure_router escalates onto
|
||||||
|
details_popup_escalation when (and only when) u1's q4-gated classifier
|
||||||
|
has emitted the insufficient verdict. Implementation status is reported
|
||||||
|
as MISSING here because the executor stub + MISSING→IMPLEMENTED flip
|
||||||
|
live in src/phase_z2_router.py (u3); the failure_router surface must not
|
||||||
|
claim implementation it does not own.
|
||||||
|
"""
|
||||||
|
# Direct mapping (u2 lock)
|
||||||
|
assert NEXT_ACTION_BY_FAILURE["frame_reselect_insufficient"] == (
|
||||||
|
"details_popup_escalation"
|
||||||
|
)
|
||||||
|
# u2 advertises cascade terminal as MISSING; u3 flips it on the router
|
||||||
|
# surface (separate file). Until u3 lands, failure_router must report
|
||||||
|
# MISSING to avoid premature "popup ready" claims.
|
||||||
|
assert NEXT_ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"] == "MISSING"
|
||||||
|
|
||||||
|
nr = route_retry_failure("frame_reselect_insufficient")
|
||||||
|
assert nr["next_proposed_action"] == "details_popup_escalation"
|
||||||
|
assert nr["next_action_implementation_status"] == "MISSING"
|
||||||
|
assert "details_popup_escalation" in (nr["next_action_rationale"] or "")
|
||||||
|
|
||||||
|
# End-to-end via the classifier path: q4 contract satisfied →
|
||||||
|
# enrichment composes the cascade terminal proposal onto the trace.
|
||||||
|
trace = {
|
||||||
|
"retry_attempted": True,
|
||||||
|
"retry_passed": False,
|
||||||
|
"salvage_passed": False,
|
||||||
|
"salvage_steps": [
|
||||||
|
{
|
||||||
|
"action": "frame_reselect",
|
||||||
|
"passed": False,
|
||||||
|
"failure_reason": "post-frame remeasure: overflow persists",
|
||||||
|
"post_salvage_overflow": {
|
||||||
|
"passed": False,
|
||||||
|
"fail_reasons": ["body still clipped"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
enrich_retry_trace_with_failure_classification(trace)
|
||||||
|
assert trace["failure_classification"]["failure_type"] == (
|
||||||
|
"frame_reselect_insufficient"
|
||||||
|
)
|
||||||
|
assert trace["next_action_proposal"]["next_proposed_action"] == (
|
||||||
|
"details_popup_escalation"
|
||||||
|
)
|
||||||
|
|||||||
419
tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py
Normal file
419
tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
"""IMP-35 (#64) u7 — Pipeline composer -> render_slide wiring tests.
|
||||||
|
|
||||||
|
Stage 2 wiring contract (unit u7):
|
||||||
|
u6 (``bind_popup_display_strategy`` in ``src/phase_z2_composition.py``)
|
||||||
|
produced the composition-side binding from the unit-side marker stamped
|
||||||
|
by Step 17 POPUP gate (u5). u7 is the pipeline composer side: it
|
||||||
|
surfaces three uniform render-context field names per zone in
|
||||||
|
``zones_data`` so slide_base.html (u8) sees the same shape on every
|
||||||
|
zone regardless of whether the unit went through the POPUP gate:
|
||||||
|
|
||||||
|
has_popup : bool — escalation marker echo
|
||||||
|
popup_html : str — popup body source (FULL ``raw_content``
|
||||||
|
per u6 ``popup_body_source``; u8 wraps
|
||||||
|
it in ``<details>/<summary>``). ``None``
|
||||||
|
when has_popup=False.
|
||||||
|
preview_text : str — px-budgeted line-boundary excerpt of
|
||||||
|
``raw_content`` shown in the body /
|
||||||
|
inline_preview slot. ``None`` when
|
||||||
|
has_popup=False. Popup body retains
|
||||||
|
the FULL original so the excerpt loses
|
||||||
|
no information.
|
||||||
|
|
||||||
|
Key invariants this file locks:
|
||||||
|
1. ``compose_zone_popup_payload`` returns the three uniform field
|
||||||
|
names plus the full u6 binding under ``popup_binding`` for
|
||||||
|
downstream debug.
|
||||||
|
2. has_popup=False units bind to the no-popup branch — popup_html
|
||||||
|
and preview_text are both ``None``, popup_binding echoes u6
|
||||||
|
``inline_full`` strategy.
|
||||||
|
3. has_popup=True units bind to the popup branch — popup_html ==
|
||||||
|
u6 ``popup_body_source`` == FULL ``raw_content`` (MDX 원문
|
||||||
|
무손실 보존, 오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110),
|
||||||
|
and preview_text is a deterministic line-boundary excerpt.
|
||||||
|
4. ``compute_popup_preview_text`` is a CUT, never a rewrite —
|
||||||
|
``raw_content.startswith(preview_text)`` when the content
|
||||||
|
exceeds the container budget; otherwise preview == full content.
|
||||||
|
5. Line-boundary cut never trims inside a line (no mid-CJK-word cut).
|
||||||
|
6. Non-positive container budget falls back to the full content
|
||||||
|
(no spurious truncation when telemetry is missing — popup gate
|
||||||
|
would not have fired without a real budget anyway).
|
||||||
|
7. AI isolation contract — pure deterministic helpers; no anthropic
|
||||||
|
import, no route_ai_fallback path.
|
||||||
|
|
||||||
|
Cross-references:
|
||||||
|
- u3 router stub (``plan_details_popup_escalation``):
|
||||||
|
tests/phase_z2/test_phase_z2_router_popup.py
|
||||||
|
- u4 api_gated split-decision contract:
|
||||||
|
tests/phase_z2_ai_fallback/test_step17.py
|
||||||
|
- u5 Step 17 POPUP gate (stamps the marker u7 reads via u6):
|
||||||
|
tests/phase_z2/test_phase_z2_step17_popup_gate.py
|
||||||
|
- u6 composition popup binding (input to u7):
|
||||||
|
tests/phase_z2/test_composition_popup_strategy.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.phase_z2_composition import (
|
||||||
|
DISPLAY_STRATEGIES,
|
||||||
|
POPUP_BINDING_ESCALATED_STRATEGY_ID,
|
||||||
|
POPUP_BINDING_NO_POPUP_STRATEGY_ID,
|
||||||
|
POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX,
|
||||||
|
compose_zone_popup_payload,
|
||||||
|
compute_popup_preview_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Synthetic stubs ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _StubUnit:
|
||||||
|
"""Minimal duck-typed CompositionUnit for u7 wiring tests.
|
||||||
|
|
||||||
|
Mirrors only the fields ``compose_zone_popup_payload`` reads via
|
||||||
|
getattr — keeps the test independent of CompositionUnit dataclass
|
||||||
|
evolution (IMP-30 / IMP-48 axis additions).
|
||||||
|
"""
|
||||||
|
|
||||||
|
raw_content: str = "MOCK_ORIGINAL_CONTENT"
|
||||||
|
has_popup: bool = False
|
||||||
|
popup_escalation_plan: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_popup_plan(category: str = "structural_major_overflow") -> dict:
|
||||||
|
"""Mirror the shape ``plan_details_popup_escalation`` returns on a
|
||||||
|
feasible escalation. u7 echoes this verbatim via u6 binding — no
|
||||||
|
field is consumed here other than as a traceable payload."""
|
||||||
|
return {
|
||||||
|
"action": "details_popup_escalation",
|
||||||
|
"stub": True,
|
||||||
|
"feasible": True,
|
||||||
|
"category": category,
|
||||||
|
"needs_split_decision": True,
|
||||||
|
"rationale": "MOCK_RATIONALE",
|
||||||
|
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── compose_zone_popup_payload — uniform render-context surface ─────
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_returns_uniform_field_names():
|
||||||
|
"""u7 — every payload (popup or not) MUST surface the same four
|
||||||
|
field names so slide_base.html (u8) does not have to branch on the
|
||||||
|
presence of popup fields. Field uniformity is the wiring contract."""
|
||||||
|
payload = compose_zone_popup_payload(_StubUnit(has_popup=False), 200)
|
||||||
|
assert set(payload.keys()) == {
|
||||||
|
"has_popup",
|
||||||
|
"popup_html",
|
||||||
|
"preview_text",
|
||||||
|
"popup_binding",
|
||||||
|
}
|
||||||
|
payload_popup = compose_zone_popup_payload(
|
||||||
|
_StubUnit(
|
||||||
|
raw_content="MOCK_BODY",
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
),
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
assert set(payload_popup.keys()) == {
|
||||||
|
"has_popup",
|
||||||
|
"popup_html",
|
||||||
|
"preview_text",
|
||||||
|
"popup_binding",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_has_popup_false_returns_no_popup_branch():
|
||||||
|
"""u7 — has_popup=False units bind to the no-popup branch: both
|
||||||
|
popup_html and preview_text are None, popup_binding echoes the u6
|
||||||
|
``inline_full`` strategy."""
|
||||||
|
unit = _StubUnit(raw_content="MOCK_BODY", has_popup=False)
|
||||||
|
payload = compose_zone_popup_payload(unit, 200)
|
||||||
|
assert payload["has_popup"] is False
|
||||||
|
assert payload["popup_html"] is None
|
||||||
|
assert payload["preview_text"] is None
|
||||||
|
binding = payload["popup_binding"]
|
||||||
|
assert isinstance(binding, dict)
|
||||||
|
assert binding["display_strategy"] == POPUP_BINDING_NO_POPUP_STRATEGY_ID
|
||||||
|
assert binding["has_popup"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_default_when_unit_lacks_has_popup_attr_at_all():
|
||||||
|
"""u7 defensive default — units that lack the has_popup attribute
|
||||||
|
entirely (e.g., third-party duck-typed stubs) bind to the no-popup
|
||||||
|
path through the getattr() default branch (mirrors u6 test)."""
|
||||||
|
|
||||||
|
class _BareUnit:
|
||||||
|
raw_content = "MOCK_BODY"
|
||||||
|
|
||||||
|
payload = compose_zone_popup_payload(_BareUnit(), 200)
|
||||||
|
assert payload["has_popup"] is False
|
||||||
|
assert payload["popup_html"] is None
|
||||||
|
assert payload["preview_text"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_has_popup_true_popup_html_is_full_raw_content_verbatim():
|
||||||
|
"""u7 — popup_html MUST be the FULL raw_content verbatim. u6
|
||||||
|
popup_body_source already locks this at the binding layer; u7
|
||||||
|
must NOT re-shape, trim, or HTML-escape on the way to the zone
|
||||||
|
dict. MDX 원문 무손실 보존 (오답노트 #5)."""
|
||||||
|
full_text = (
|
||||||
|
"## MOCK_SECTION_TITLE\n\n"
|
||||||
|
"- bullet one with **bold** marker\n"
|
||||||
|
"- bullet two with *italic* marker\n"
|
||||||
|
"- bullet three trailing\n"
|
||||||
|
"\n"
|
||||||
|
"| col_a | col_b |\n| --- | --- |\n| MOCK | DATA |\n"
|
||||||
|
)
|
||||||
|
unit = _StubUnit(
|
||||||
|
raw_content=full_text,
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||||
|
assert payload["popup_html"] == full_text
|
||||||
|
assert len(payload["popup_html"]) == len(full_text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_has_popup_true_preview_text_is_deterministic_line_cut():
|
||||||
|
"""u7 — preview_text MUST be a deterministic line-boundary excerpt
|
||||||
|
of raw_content. With container_height_px=36 and the default
|
||||||
|
line metric (18 px), the budget = 2 lines."""
|
||||||
|
full_text = "line1\nline2\nline3\nline4\nline5"
|
||||||
|
unit = _StubUnit(
|
||||||
|
raw_content=full_text,
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
payload = compose_zone_popup_payload(unit, container_height_px=36)
|
||||||
|
assert payload["preview_text"] == "line1\nline2"
|
||||||
|
# popup body still holds the FULL original — no information loss.
|
||||||
|
assert payload["popup_html"] == full_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_popup_binding_echoes_full_u6_output():
|
||||||
|
"""u7 — popup_binding MUST echo the full u6 output so debug
|
||||||
|
consumers can read display_strategy / detail_trigger / strategy_meta
|
||||||
|
/ popup_escalation_plan without re-reading the yaml."""
|
||||||
|
plan = _stub_popup_plan("tabular_overflow")
|
||||||
|
unit = _StubUnit(
|
||||||
|
raw_content="MOCK_BODY",
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=plan,
|
||||||
|
)
|
||||||
|
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||||
|
binding = payload["popup_binding"]
|
||||||
|
assert binding["display_strategy"] == POPUP_BINDING_ESCALATED_STRATEGY_ID
|
||||||
|
assert binding["has_popup"] is True
|
||||||
|
assert binding["popup_escalation_plan"] is plan
|
||||||
|
# detail_trigger comes from the catalog entry's detail_trigger block.
|
||||||
|
catalog_trigger = (
|
||||||
|
DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
|
||||||
|
.get("detail_trigger") or {}
|
||||||
|
)
|
||||||
|
assert binding["detail_trigger"] == {
|
||||||
|
"placement": catalog_trigger.get("placement"),
|
||||||
|
"label": catalog_trigger.get("label"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── compute_popup_preview_text — deterministic line-budget cut ──────
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_returns_empty_string_when_raw_content_is_empty():
|
||||||
|
"""u7 — empty raw_content returns empty preview; no IndexError /
|
||||||
|
TypeError on the splitlines path."""
|
||||||
|
assert compute_popup_preview_text("", container_height_px=200) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_returns_full_content_when_it_fits_budget():
|
||||||
|
"""u7 — when the content already fits the container budget, the
|
||||||
|
preview equals the full content (no spurious truncation)."""
|
||||||
|
full_text = "line1\nline2\nline3"
|
||||||
|
# budget = 200 / 18 = 11 lines → fits 3 lines easily.
|
||||||
|
assert (
|
||||||
|
compute_popup_preview_text(full_text, container_height_px=200)
|
||||||
|
== full_text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_truncates_to_line_budget_when_content_overflows():
|
||||||
|
"""u7 — when the content exceeds the budget, the preview is the
|
||||||
|
leading N lines that fit, joined verbatim with '\\n'. Never trims
|
||||||
|
inside a line (no mid-CJK-word cut)."""
|
||||||
|
full_text = "L1\nL2\nL3\nL4\nL5\nL6"
|
||||||
|
# budget = 54 / 18 = 3 lines.
|
||||||
|
assert (
|
||||||
|
compute_popup_preview_text(full_text, container_height_px=54)
|
||||||
|
== "L1\nL2\nL3"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_is_a_prefix_of_raw_content_when_truncated():
|
||||||
|
"""u7 — invariant: a truncated preview is a CUT, never a rewrite.
|
||||||
|
raw_content.startswith(preview_text) MUST hold when truncation
|
||||||
|
happened. Locks the line-boundary semantics — preview is always a
|
||||||
|
leading-substring of raw_content (modulo \\n re-join, which matches
|
||||||
|
splitlines round-trip)."""
|
||||||
|
full_text = (
|
||||||
|
"- 첫 번째 항목 (CJK)\n"
|
||||||
|
"- 두 번째 항목 (CJK)\n"
|
||||||
|
"- 세 번째 항목 (CJK)\n"
|
||||||
|
"- 네 번째 항목 (CJK)\n"
|
||||||
|
"- 다섯 번째 항목 (CJK)\n"
|
||||||
|
)
|
||||||
|
preview = compute_popup_preview_text(full_text, container_height_px=54)
|
||||||
|
# 3 lines budget. preview ends at the third line boundary.
|
||||||
|
assert preview == "- 첫 번째 항목 (CJK)\n- 두 번째 항목 (CJK)\n- 세 번째 항목 (CJK)"
|
||||||
|
# Leading-substring guarantee — raw_content starts with preview verbatim.
|
||||||
|
assert full_text.startswith(preview)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_never_returns_empty_string_when_budget_floors_to_zero():
|
||||||
|
"""u7 — if container_height_px is positive but smaller than one
|
||||||
|
line, the floor would yield 0 lines. The helper clamps max_lines
|
||||||
|
to at least 1 so the preview always contains at least the first
|
||||||
|
line (otherwise the popup wrapper would have an empty preview
|
||||||
|
slot — UX degradation)."""
|
||||||
|
full_text = "first line\nsecond line"
|
||||||
|
# budget = 5 / 18 = 0 floor → clamp to 1.
|
||||||
|
assert (
|
||||||
|
compute_popup_preview_text(full_text, container_height_px=5)
|
||||||
|
== "first line"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_falls_back_to_full_content_when_budget_non_positive():
|
||||||
|
"""u7 — non-positive container_height_px (0 or negative) returns
|
||||||
|
the full content unchanged. u5 POPUP gate would not have fired
|
||||||
|
without a real budget, so this branch is only reachable for
|
||||||
|
non-popup units (where preview is unused). No spurious truncation."""
|
||||||
|
full_text = "line1\nline2\nline3"
|
||||||
|
assert (
|
||||||
|
compute_popup_preview_text(full_text, container_height_px=0)
|
||||||
|
== full_text
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
compute_popup_preview_text(full_text, container_height_px=-100)
|
||||||
|
== full_text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_falls_back_to_full_content_when_line_height_non_positive():
|
||||||
|
"""u7 defensive guard — non-positive line_height_px override would
|
||||||
|
divide-by-zero. Helper falls back to the full content unchanged
|
||||||
|
(no spurious truncation, no exception)."""
|
||||||
|
full_text = "line1\nline2\nline3"
|
||||||
|
assert (
|
||||||
|
compute_popup_preview_text(
|
||||||
|
full_text, container_height_px=200, line_height_px=0
|
||||||
|
)
|
||||||
|
== full_text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_default_line_height_constant_matches_slide_base_body_metric():
|
||||||
|
"""u7 no-hardcoding lock — the default line height constant is a
|
||||||
|
parametric default (not a magic literal). Locked at 18 px to match
|
||||||
|
slide_base.html ``--font-body`` (11 px) * line-height (1.6) + guard.
|
||||||
|
If slide_base.html body metric changes, this test should fail and
|
||||||
|
force an explicit re-derivation."""
|
||||||
|
assert POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX == 18.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_accepts_line_height_override():
|
||||||
|
"""u7 — line_height_px is overridable so a tighter-font frame can
|
||||||
|
pass a smaller line metric. Locks the parametric contract."""
|
||||||
|
full_text = "L1\nL2\nL3\nL4\nL5\nL6"
|
||||||
|
# budget = 30 / 10 = 3 lines under override.
|
||||||
|
assert (
|
||||||
|
compute_popup_preview_text(
|
||||||
|
full_text, container_height_px=30, line_height_px=10.0
|
||||||
|
)
|
||||||
|
== "L1\nL2\nL3"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Integration: pipeline composer attaches popup payload to zone ────
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_zone_dict_includes_popup_fields():
|
||||||
|
"""u7 — the pipeline composer (src/phase_z2_pipeline.py) calls
|
||||||
|
``compose_zone_popup_payload(unit, min_height_px)`` per-unit and
|
||||||
|
spreads the four wiring keys into the zone dict via
|
||||||
|
``zones_data.append({..., **payload})``. This test rebuilds the
|
||||||
|
spread surface against a synthetic unit + container budget to lock
|
||||||
|
the integration contract without booting the entire pipeline."""
|
||||||
|
unit = _StubUnit(
|
||||||
|
raw_content="line1\nline2\nline3\nline4\nline5\nline6",
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
base_zone = {
|
||||||
|
"position": "single",
|
||||||
|
"template_id": "MOCK_FRAME",
|
||||||
|
"slot_payload": {},
|
||||||
|
"content_weight": {"score": 0},
|
||||||
|
"min_height_px": 54, # 3 lines budget at default metric.
|
||||||
|
"assignment_source": "MOCK",
|
||||||
|
"section_assignment_override": False,
|
||||||
|
"provisional": False,
|
||||||
|
}
|
||||||
|
popup_payload = compose_zone_popup_payload(unit, base_zone["min_height_px"])
|
||||||
|
zone = {**base_zone, **popup_payload}
|
||||||
|
assert zone["has_popup"] is True
|
||||||
|
assert zone["popup_html"] == unit.raw_content
|
||||||
|
assert zone["preview_text"] == "line1\nline2\nline3"
|
||||||
|
assert isinstance(zone["popup_binding"], dict)
|
||||||
|
# Spread MUST NOT clobber the pre-existing zone fields — popup
|
||||||
|
# payload keys are disjoint from the base zone dict keys.
|
||||||
|
assert zone["position"] == "single"
|
||||||
|
assert zone["template_id"] == "MOCK_FRAME"
|
||||||
|
assert zone["min_height_px"] == 54
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_zone_dict_no_popup_keys_are_uniform_across_branches():
|
||||||
|
"""u7 — the pipeline composer has three zones_data.append sites
|
||||||
|
(empty-shell unit, main renderable unit, unrenderable empty plan
|
||||||
|
record). All three MUST stamp the same four wiring keys with
|
||||||
|
consistent shape so slide_base.html (u8) does not have to branch
|
||||||
|
on key presence. This test locks the no-popup defaults stamped by
|
||||||
|
the unrenderable empty plan branch."""
|
||||||
|
no_popup_defaults = {
|
||||||
|
"has_popup": False,
|
||||||
|
"popup_html": None,
|
||||||
|
"preview_text": None,
|
||||||
|
"popup_binding": None, # unrenderable branch — no unit, no u6 binding.
|
||||||
|
}
|
||||||
|
# And compose_zone_popup_payload for a no-popup unit MUST surface
|
||||||
|
# the same three render-context keys (popup_binding differs — it
|
||||||
|
# holds the u6 ``inline_full`` echo when there IS a unit).
|
||||||
|
payload = compose_zone_popup_payload(_StubUnit(has_popup=False), 200)
|
||||||
|
for k in ("has_popup", "popup_html", "preview_text"):
|
||||||
|
assert no_popup_defaults[k] == payload[k]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AI isolation contract (structural import lock) ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_composition_module_does_not_import_anthropic_or_route_ai_fallback():
|
||||||
|
"""u7 — compose_zone_popup_payload + compute_popup_preview_text MUST
|
||||||
|
stay AI-free. Mirrors the import-isolation pattern locked by u4/u5
|
||||||
|
tests. composition module is allowed to consult the catalog and
|
||||||
|
unit state, never the Anthropic SDK / route_ai_fallback path."""
|
||||||
|
import src.phase_z2_composition as composition_module
|
||||||
|
|
||||||
|
source = composition_module.__file__
|
||||||
|
assert source is not None
|
||||||
|
with open(source, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
assert "import anthropic" not in text
|
||||||
|
assert "from anthropic" not in text
|
||||||
|
assert "route_ai_fallback" not in text
|
||||||
209
tests/phase_z2/test_phase_z2_router_popup.py
Normal file
209
tests/phase_z2/test_phase_z2_router_popup.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""IMP-35 (#64) u3 — router popup escalation stub tests.
|
||||||
|
|
||||||
|
Stage 2 binding contract (unit u3):
|
||||||
|
- `details_popup_escalation` MISSING → IMPLEMENTED on the *primary* router
|
||||||
|
surface (`src/phase_z2_router.py`). Downstream surfaces remain decoupled:
|
||||||
|
* `src/phase_z2_failure_router.py` keeps the cascade-terminal entry as
|
||||||
|
MISSING until u5 wires the Step 17 POPUP gate executor.
|
||||||
|
* `src/phase_z2_ai_fallback/step17.py` (u4) binds the AI split-decision
|
||||||
|
contract that the stub flags via `needs_split_decision=True`.
|
||||||
|
- `plan_details_popup_escalation(classification)` stub is the deterministic
|
||||||
|
executor surface — no AI call, no HTML/CSS/MDX mutation. It emits the
|
||||||
|
canonical popup_escalation_plan marker that u4/u5 consume.
|
||||||
|
- The two ACTION_BY_CATEGORY rows that map onto `details_popup_escalation`
|
||||||
|
— `structural_major_overflow` and `tabular_overflow` — must route to the
|
||||||
|
cascade terminal via `route_action` / `route_fit_classification`.
|
||||||
|
|
||||||
|
Cross-references:
|
||||||
|
- u1 (frame_reselect_insufficient classifier gate, q4 contract):
|
||||||
|
tests/phase_z2/test_phase_z2_failure_router_cascade.py::
|
||||||
|
test_frame_reselect_insufficient_classifier_emits_from_salvage_steps
|
||||||
|
tests/phase_z2/test_phase_z2_failure_router_cascade.py::
|
||||||
|
test_frame_reselect_without_post_salvage_overflow_is_not_classified_as_insufficient
|
||||||
|
- u2 (failure_router cascade terminal row + MISSING status lock):
|
||||||
|
tests/phase_z2/test_phase_z2_failure_router_cascade.py::
|
||||||
|
test_frame_reselect_insufficient_routes_to_details_popup_escalation
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.phase_z2_router import (
|
||||||
|
ACTION_BY_CATEGORY,
|
||||||
|
ACTION_IMPLEMENTATION_STATUS,
|
||||||
|
ACTION_RATIONALE,
|
||||||
|
POPUP_ESCALATION_CATEGORIES,
|
||||||
|
plan_details_popup_escalation,
|
||||||
|
route_action,
|
||||||
|
route_fit_classification,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_implementation_status_details_popup_escalation_flipped_to_implemented():
|
||||||
|
"""IMP-35 u3 — primary router surface flip.
|
||||||
|
|
||||||
|
`ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"]` was MISSING
|
||||||
|
prior to u3 (Stage 2 binding). u3 lands the deterministic
|
||||||
|
`plan_details_popup_escalation` stub on the router surface, so the
|
||||||
|
status must read IMPLEMENTED here. u5 owns the matching flip on the
|
||||||
|
failure_router surface — until u5 lands, the failure_router still
|
||||||
|
reports MISSING (locked by the u2 test).
|
||||||
|
"""
|
||||||
|
assert (
|
||||||
|
ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"] == "IMPLEMENTED"
|
||||||
|
), (
|
||||||
|
"u3 must flip the primary router surface from MISSING to IMPLEMENTED. "
|
||||||
|
"The failure_router companion surface stays MISSING until u5 (see u2 "
|
||||||
|
"test test_frame_reselect_insufficient_routes_to_details_popup_escalation)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_structural_major_overflow_routes_to_details_popup_escalation_implemented():
|
||||||
|
"""IMP-35 u3 — `structural_major_overflow` is one of the two
|
||||||
|
ACTION_BY_CATEGORY rows that map onto the cascade terminal. After u3
|
||||||
|
flips the status, `route_action` must report IMPLEMENTED for that
|
||||||
|
routing.
|
||||||
|
"""
|
||||||
|
assert ACTION_BY_CATEGORY["structural_major_overflow"] == "details_popup_escalation"
|
||||||
|
routing = route_action("structural_major_overflow")
|
||||||
|
assert routing["proposed_action"] == "details_popup_escalation"
|
||||||
|
assert routing["implementation_status"] == "IMPLEMENTED"
|
||||||
|
assert routing["mapping_source"] == "spec §4 ACTION_BY_CATEGORY"
|
||||||
|
# rationale text must remain non-empty so trace explains *why* this
|
||||||
|
# category escalates (downstream debugging hinges on it).
|
||||||
|
assert (routing["rationale"] or "").strip(), (
|
||||||
|
"rationale text must be present so the router trace explains why "
|
||||||
|
"structural_major_overflow escalates onto the popup terminal."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tabular_overflow_routes_to_details_popup_escalation_implemented():
|
||||||
|
"""IMP-35 u3 — `tabular_overflow` is the second ACTION_BY_CATEGORY row
|
||||||
|
that maps onto the cascade terminal. Same surface flip applies.
|
||||||
|
"""
|
||||||
|
assert ACTION_BY_CATEGORY["tabular_overflow"] == "details_popup_escalation"
|
||||||
|
routing = route_action("tabular_overflow")
|
||||||
|
assert routing["proposed_action"] == "details_popup_escalation"
|
||||||
|
assert routing["implementation_status"] == "IMPLEMENTED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_escalation_categories_is_derived_from_action_by_category():
|
||||||
|
"""IMP-35 u3 — POPUP_ESCALATION_CATEGORIES must be the *derived*
|
||||||
|
projection of ACTION_BY_CATEGORY (single source of truth). If a future
|
||||||
|
edit changes which categories map onto details_popup_escalation, this
|
||||||
|
constant must follow automatically; the stub guard relies on it.
|
||||||
|
"""
|
||||||
|
expected = frozenset(
|
||||||
|
category
|
||||||
|
for category, action in ACTION_BY_CATEGORY.items()
|
||||||
|
if action == "details_popup_escalation"
|
||||||
|
)
|
||||||
|
assert POPUP_ESCALATION_CATEGORIES == expected
|
||||||
|
# Sanity: at u3 landing time, the two locked categories are present.
|
||||||
|
assert "structural_major_overflow" in POPUP_ESCALATION_CATEGORIES
|
||||||
|
assert "tabular_overflow" in POPUP_ESCALATION_CATEGORIES
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_details_popup_escalation_returns_feasible_plan_for_structural_major():
|
||||||
|
"""IMP-35 u3 — accepted category produces a feasible popup escalation
|
||||||
|
plan with the canonical stub shape. u4 (AI hook) reads
|
||||||
|
`needs_split_decision=True`; u5 (POPUP gate executor) reads
|
||||||
|
`feasible=True` + `category` + `rationale` to compose the
|
||||||
|
popup_html / preview_text / has_popup payload.
|
||||||
|
"""
|
||||||
|
plan = plan_details_popup_escalation({"category": "structural_major_overflow"})
|
||||||
|
assert plan["action"] == "details_popup_escalation"
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["stub"] is True
|
||||||
|
assert plan["needs_split_decision"] is True
|
||||||
|
assert plan["category"] == "structural_major_overflow"
|
||||||
|
assert plan["rationale"] == ACTION_RATIONALE["structural_major_overflow"]
|
||||||
|
assert plan["mapping_source"] == "IMP-35 u3 plan_details_popup_escalation stub"
|
||||||
|
# No side-effect markers: stub must not pretend to have done downstream work.
|
||||||
|
for forbidden_key in ("popup_html", "preview_text", "has_popup", "ai_decision"):
|
||||||
|
assert forbidden_key not in plan, (
|
||||||
|
f"u3 stub must NOT carry {forbidden_key!r} — that payload is "
|
||||||
|
f"composed downstream (u4 AI hook + u5 POPUP gate executor)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_details_popup_escalation_returns_feasible_plan_for_tabular():
|
||||||
|
"""IMP-35 u3 — tabular_overflow is the second accepted category."""
|
||||||
|
plan = plan_details_popup_escalation({"category": "tabular_overflow"})
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["stub"] is True
|
||||||
|
assert plan["needs_split_decision"] is True
|
||||||
|
assert plan["category"] == "tabular_overflow"
|
||||||
|
assert plan["rationale"] == ACTION_RATIONALE["tabular_overflow"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_details_popup_escalation_rejects_non_popup_category():
|
||||||
|
"""IMP-35 u3 — defensive guard. Calling the stub with a category that
|
||||||
|
does not map onto `details_popup_escalation` in ACTION_BY_CATEGORY must
|
||||||
|
NOT silently popup-escalate. The stub returns `feasible=False` with a
|
||||||
|
`failure_reason` citing the accepted categories so the caller can
|
||||||
|
surface the misuse in trace.
|
||||||
|
"""
|
||||||
|
plan = plan_details_popup_escalation({"category": "minor_overflow"})
|
||||||
|
assert plan["action"] == "details_popup_escalation"
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["stub"] is True
|
||||||
|
assert plan["needs_split_decision"] is False
|
||||||
|
assert plan["category"] == "minor_overflow"
|
||||||
|
assert "failure_reason" in plan
|
||||||
|
assert "ACTION_BY_CATEGORY" in plan["failure_reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_details_popup_escalation_rejects_missing_category():
|
||||||
|
"""IMP-35 u3 — defensive guard for malformed classification dict
|
||||||
|
(no `category` key). Stub must not raise; it must return a
|
||||||
|
`feasible=False` plan so the caller never crashes the cascade.
|
||||||
|
"""
|
||||||
|
plan = plan_details_popup_escalation({})
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["needs_split_decision"] is False
|
||||||
|
assert plan["category"] is None
|
||||||
|
assert "failure_reason" in plan
|
||||||
|
|
||||||
|
plan_none = plan_details_popup_escalation(None) # type: ignore[arg-type]
|
||||||
|
assert plan_none["feasible"] is False
|
||||||
|
assert plan_none["category"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_fit_classification_carries_popup_escalation_to_implemented_summary():
|
||||||
|
"""IMP-35 u3 — end-to-end via the fit_classification → router path.
|
||||||
|
|
||||||
|
When a fit_classification reports a `structural_major_overflow` row,
|
||||||
|
`route_fit_classification` must:
|
||||||
|
- attach `proposed_action == "details_popup_escalation"` onto the
|
||||||
|
classification entry
|
||||||
|
- report IMPLEMENTED in `implementation_status_summary`
|
||||||
|
- NOT list `details_popup_escalation` in
|
||||||
|
`missing_actions_pending_impl` (status is now IMPLEMENTED).
|
||||||
|
"""
|
||||||
|
fit_classification = {
|
||||||
|
"visual_check_passed": False,
|
||||||
|
"classifications": [
|
||||||
|
{
|
||||||
|
"source": "body",
|
||||||
|
"zone_position": "bottom",
|
||||||
|
"category": "structural_major_overflow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "table:summary",
|
||||||
|
"zone_position": "bottom",
|
||||||
|
"category": "tabular_overflow",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
summary = route_fit_classification(fit_classification)
|
||||||
|
assert summary["router_active"] is True
|
||||||
|
assert summary["routed_count"] == 2
|
||||||
|
assert "details_popup_escalation" in summary["proposed_actions_summary"]
|
||||||
|
# Both rows escalated onto the popup terminal — status summary must
|
||||||
|
# therefore reflect 2 IMPLEMENTED counts (no MISSING) for u3.
|
||||||
|
assert summary["implementation_status_summary"].get("IMPLEMENTED") == 2
|
||||||
|
assert "details_popup_escalation" not in summary["missing_actions_pending_impl"]
|
||||||
|
# Per-row enrichment carries the new IMPLEMENTED status onto the
|
||||||
|
# classification entries (in-place mutation contract preserved).
|
||||||
|
for cls in fit_classification["classifications"]:
|
||||||
|
assert cls["proposed_action"] == "details_popup_escalation"
|
||||||
|
assert cls["proposed_action_implementation_status"] == "IMPLEMENTED"
|
||||||
551
tests/phase_z2/test_phase_z2_step17_popup_gate.py
Normal file
551
tests/phase_z2/test_phase_z2_step17_popup_gate.py
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
"""IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor tests.
|
||||||
|
|
||||||
|
Stage 2 binding contract (unit u5):
|
||||||
|
- ``run_step17_popup_gate`` is the deterministic cascade-terminal gate
|
||||||
|
that stamps ``popup_escalation_plan`` + idempotent ``has_popup``
|
||||||
|
marker per unit. Runs AFTER the DETERMINISTIC stage exhausts and
|
||||||
|
BEFORE the AI_REPAIR cascade stage (canonical OVERFLOW_CASCADE_ORDER).
|
||||||
|
- No AI call: deterministic-with-data. ``ai_called=False`` on every
|
||||||
|
record. The u4 ``gather_step17_popup_split_decisions`` AI hook is
|
||||||
|
a SEPARATE cascade-stage surface (api_gated) and is NOT invoked
|
||||||
|
from this gate.
|
||||||
|
- q1 (per-unit), q2 (idempotent via ``has_popup``), q3 (deterministic
|
||||||
|
split from container px telemetry — preview / popup body composed
|
||||||
|
downstream in u6 / u7).
|
||||||
|
|
||||||
|
Cross-references:
|
||||||
|
- u3 router stub (``plan_details_popup_escalation``) — accepted
|
||||||
|
categories ``structural_major_overflow`` / ``tabular_overflow``:
|
||||||
|
tests/phase_z2/test_phase_z2_router_popup.py
|
||||||
|
- u1 + u2 cascade-terminal classifier + NEXT_ACTION row:
|
||||||
|
tests/phase_z2/test_phase_z2_failure_router_cascade.py
|
||||||
|
- u4 api_gated split-decision contract:
|
||||||
|
tests/phase_z2_ai_fallback/test_step17.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from src.phase_z2_ai_fallback.step17 import (
|
||||||
|
STEP17_POPUP_GATE_ESCALATED_REASON,
|
||||||
|
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON,
|
||||||
|
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON,
|
||||||
|
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON,
|
||||||
|
OverflowCascadeStage,
|
||||||
|
run_step17_popup_gate,
|
||||||
|
)
|
||||||
|
from src.phase_z2_router import plan_details_popup_escalation
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeUnit:
|
||||||
|
label: str | None = "restructure"
|
||||||
|
provisional: bool = True
|
||||||
|
frame_template_id: str = "tmpl"
|
||||||
|
source_section_ids: list[str] = field(default_factory=lambda: ["s1"])
|
||||||
|
has_popup: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
_ROUTE_HINTS: dict[str | None, str | None] = {
|
||||||
|
"use_as_is": "direct_render",
|
||||||
|
"light_edit": "deterministic_minor_adjustment",
|
||||||
|
"restructure": "ai_adaptation_required",
|
||||||
|
"reject": "design_reference_only",
|
||||||
|
None: None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _route_for_label(label: str | None) -> str | None:
|
||||||
|
return _ROUTE_HINTS.get(label)
|
||||||
|
|
||||||
|
|
||||||
|
def _always_popup_classification(category: str = "structural_major_overflow"):
|
||||||
|
"""Helper: classification_for_unit fake returning a popup category."""
|
||||||
|
cls = {"category": category, "zone_position": "top"}
|
||||||
|
return lambda _unit: cls
|
||||||
|
|
||||||
|
|
||||||
|
def _no_classification(_unit):
|
||||||
|
"""Helper: classification_for_unit fake returning None (no overflow)."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Reason constants ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_reason_constants_are_distinct_and_stable():
|
||||||
|
"""u5 — gate_status / skip_reason enum constants must be machine-readable
|
||||||
|
and disjoint. Consumers parse the trace by these strings."""
|
||||||
|
reasons = {
|
||||||
|
STEP17_POPUP_GATE_ESCALATED_REASON,
|
||||||
|
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON,
|
||||||
|
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON,
|
||||||
|
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON,
|
||||||
|
}
|
||||||
|
assert len(reasons) == 4
|
||||||
|
assert STEP17_POPUP_GATE_ESCALATED_REASON == "step17_popup_gate_escalated"
|
||||||
|
assert (
|
||||||
|
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
|
||||||
|
== "step17_popup_gate_idempotent_short_circuit"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON
|
||||||
|
== "step17_popup_gate_infeasible_category"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON
|
||||||
|
== "step17_popup_gate_no_classification_for_unit"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Basic shape + cascade_stage ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_with_empty_units_returns_empty_list():
|
||||||
|
records = run_step17_popup_gate(
|
||||||
|
[],
|
||||||
|
classification_for_unit=_no_classification,
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=plan_details_popup_escalation,
|
||||||
|
)
|
||||||
|
assert records == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_returns_one_record_per_unit():
|
||||||
|
units = [
|
||||||
|
FakeUnit(label="restructure"),
|
||||||
|
FakeUnit(label="reject"),
|
||||||
|
FakeUnit(label="use_as_is"),
|
||||||
|
]
|
||||||
|
records = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_no_classification,
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=plan_details_popup_escalation,
|
||||||
|
)
|
||||||
|
assert len(records) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_cascade_stage_is_popup_everywhere():
|
||||||
|
"""u5 — gate runs at OverflowCascadeStage.POPUP, never AI_REPAIR."""
|
||||||
|
units = [
|
||||||
|
FakeUnit(label="restructure"),
|
||||||
|
FakeUnit(label="reject"),
|
||||||
|
FakeUnit(label=None),
|
||||||
|
]
|
||||||
|
records = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_no_classification,
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=plan_details_popup_escalation,
|
||||||
|
)
|
||||||
|
for record in records:
|
||||||
|
assert record["cascade_stage"] == OverflowCascadeStage.POPUP.value
|
||||||
|
assert record["cascade_stage"] != OverflowCascadeStage.AI_REPAIR.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_ai_called_is_false_everywhere():
|
||||||
|
"""u5 — deterministic gate. NO Anthropic call. Never invokes AI even
|
||||||
|
when classification is present and plan is feasible. The AI hook is
|
||||||
|
a separate cascade-stage surface (u4 gather_step17_popup_split_decisions,
|
||||||
|
api_gated=True)."""
|
||||||
|
units = [FakeUnit(label="restructure")]
|
||||||
|
records = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_always_popup_classification(),
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=plan_details_popup_escalation,
|
||||||
|
)
|
||||||
|
assert all(record["ai_called"] is False for record in records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_preserves_unit_metadata():
|
||||||
|
"""u5 — schema mirrors u4 (unit_index, source_section_ids,
|
||||||
|
frame_template_id, label, provisional, route_hint)."""
|
||||||
|
units = [
|
||||||
|
FakeUnit(
|
||||||
|
label="restructure",
|
||||||
|
provisional=True,
|
||||||
|
frame_template_id="frame_05_overview",
|
||||||
|
source_section_ids=["s1", "s2"],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
record = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_no_classification,
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=plan_details_popup_escalation,
|
||||||
|
)[0]
|
||||||
|
assert record["unit_index"] == 0
|
||||||
|
assert record["frame_template_id"] == "frame_05_overview"
|
||||||
|
assert record["source_section_ids"] == ["s1", "s2"]
|
||||||
|
assert record["label"] == "restructure"
|
||||||
|
assert record["provisional"] is True
|
||||||
|
assert record["route_hint"] == "ai_adaptation_required"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Feasible escalation path: stamp popup_escalation_plan + has_popup ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_feasible_path_stamps_plan_and_has_popup_marker():
|
||||||
|
"""u5 binding contract — when classification is a popup category
|
||||||
|
(structural_major_overflow / tabular_overflow) and plan is feasible,
|
||||||
|
the gate stamps popup_escalation_plan and flips has_popup=True."""
|
||||||
|
units = [FakeUnit(label="restructure", has_popup=False)]
|
||||||
|
record = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_always_popup_classification(
|
||||||
|
"structural_major_overflow"
|
||||||
|
),
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=plan_details_popup_escalation,
|
||||||
|
)[0]
|
||||||
|
assert record["gate_status"] == "escalated"
|
||||||
|
assert record["has_popup"] is True
|
||||||
|
assert record["popup_escalation_plan"] is not None
|
||||||
|
plan = record["popup_escalation_plan"]
|
||||||
|
assert plan["action"] == "details_popup_escalation"
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["category"] == "structural_major_overflow"
|
||||||
|
assert plan["needs_split_decision"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_feasible_path_for_tabular_overflow():
|
||||||
|
"""u5 — tabular_overflow is the second popup-mapped category. Both
|
||||||
|
categories must successfully escalate through this gate."""
|
||||||
|
units = [FakeUnit(label="light_edit", has_popup=False)]
|
||||||
|
record = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_always_popup_classification("tabular_overflow"),
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=plan_details_popup_escalation,
|
||||||
|
)[0]
|
||||||
|
assert record["gate_status"] == "escalated"
|
||||||
|
assert record["has_popup"] is True
|
||||||
|
assert record["popup_escalation_plan"]["category"] == "tabular_overflow"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Idempotency (q2) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_idempotent_short_circuit_when_has_popup_already_true():
|
||||||
|
"""u5 q2 — re-running Step 17 on a unit that already carries
|
||||||
|
has_popup=True must short-circuit. NO duplicate plan, NO re-routing.
|
||||||
|
The previously stamped marker stays True; gate_status records the
|
||||||
|
short-circuit explicitly."""
|
||||||
|
units = [FakeUnit(label="restructure", has_popup=True)]
|
||||||
|
# Even if classification would emit a feasible plan, idempotency
|
||||||
|
# short-circuit takes precedence.
|
||||||
|
record = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_always_popup_classification(),
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=plan_details_popup_escalation,
|
||||||
|
)[0]
|
||||||
|
assert record["gate_status"] == "idempotent_short_circuit"
|
||||||
|
assert (
|
||||||
|
record["skip_reason"]
|
||||||
|
== STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
|
||||||
|
)
|
||||||
|
assert record["has_popup"] is True
|
||||||
|
# No duplicate plan emitted — the plan field stays None on the
|
||||||
|
# short-circuit record (the previously stamped plan lives on the
|
||||||
|
# unit, not re-stamped here).
|
||||||
|
assert record["popup_escalation_plan"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_lifecycle_first_call_escalates_second_call_short_circuits():
|
||||||
|
"""u5 q2 lifecycle — the actual rerun contract this gate must satisfy.
|
||||||
|
|
||||||
|
Scenario the Codex rewind flagged: a unit starts with
|
||||||
|
``has_popup=False``; the first call to ``run_step17_popup_gate``
|
||||||
|
escalates it (gate_status='escalated', record has_popup=True). On
|
||||||
|
the SAME unit (no manual marker reset), a second call must observe
|
||||||
|
the persisted ``unit.has_popup=True`` and short-circuit with
|
||||||
|
``gate_status='idempotent_short_circuit'`` — without re-invoking
|
||||||
|
the plan callable and without re-stamping the plan on the record.
|
||||||
|
|
||||||
|
This locks the unit-side persistence of ``has_popup`` and
|
||||||
|
``popup_escalation_plan`` (set via ``setattr`` on the feasible
|
||||||
|
escalation path). Without that persistence, a rerun would re-emit
|
||||||
|
a duplicate escalation record and re-invoke the router stub —
|
||||||
|
contradicting q2 / IMP-35 u5.
|
||||||
|
"""
|
||||||
|
unit = FakeUnit(label="restructure", has_popup=False)
|
||||||
|
units = [unit]
|
||||||
|
|
||||||
|
plan_calls: list[dict] = []
|
||||||
|
|
||||||
|
def _spy_plan(classification):
|
||||||
|
plan_calls.append(classification)
|
||||||
|
return plan_details_popup_escalation(classification)
|
||||||
|
|
||||||
|
# First call: feasible escalation. Unit should be stamped on its own
|
||||||
|
# attributes (not just the record) so a rerun can short-circuit.
|
||||||
|
first = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_always_popup_classification(
|
||||||
|
"structural_major_overflow"
|
||||||
|
),
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=_spy_plan,
|
||||||
|
)[0]
|
||||||
|
assert first["gate_status"] == "escalated"
|
||||||
|
assert first["has_popup"] is True
|
||||||
|
assert first["popup_escalation_plan"] is not None
|
||||||
|
assert first["popup_escalation_plan"]["feasible"] is True
|
||||||
|
# Unit-side persistence — this is the contract the rewind required.
|
||||||
|
assert getattr(unit, "has_popup") is True
|
||||||
|
assert getattr(unit, "popup_escalation_plan") is not None
|
||||||
|
assert (
|
||||||
|
getattr(unit, "popup_escalation_plan")["action"]
|
||||||
|
== "details_popup_escalation"
|
||||||
|
)
|
||||||
|
assert len(plan_calls) == 1
|
||||||
|
|
||||||
|
# Second call on the SAME unit (no reset) must short-circuit.
|
||||||
|
second = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_always_popup_classification(
|
||||||
|
"structural_major_overflow"
|
||||||
|
),
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=_spy_plan,
|
||||||
|
)[0]
|
||||||
|
assert second["gate_status"] == "idempotent_short_circuit"
|
||||||
|
assert (
|
||||||
|
second["skip_reason"]
|
||||||
|
== STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
|
||||||
|
)
|
||||||
|
assert second["has_popup"] is True
|
||||||
|
# No duplicate plan emitted on the rerun record (the unit-side plan
|
||||||
|
# is what u6/u7 consume; the gate does not re-stamp on rerun).
|
||||||
|
assert second["popup_escalation_plan"] is None
|
||||||
|
# plan callable must NOT be invoked again on the rerun — the
|
||||||
|
# idempotent short-circuit branch fires before classification or
|
||||||
|
# plan is consulted.
|
||||||
|
assert len(plan_calls) == 1, (
|
||||||
|
"plan_for_classification must NOT be invoked on the second call "
|
||||||
|
"over an already-escalated unit (q2 idempotent short-circuit)."
|
||||||
|
)
|
||||||
|
# Unit-side state stays stamped (not reset by the rerun).
|
||||||
|
assert getattr(unit, "has_popup") is True
|
||||||
|
assert getattr(unit, "popup_escalation_plan") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_lifecycle_infeasible_path_does_not_persist_marker_on_unit():
|
||||||
|
"""u5 — symmetric guard. The infeasible_category branch must NOT
|
||||||
|
set ``unit.has_popup=True`` or stamp ``unit.popup_escalation_plan``.
|
||||||
|
A rerun on such a unit re-evaluates classification (no short-circuit)
|
||||||
|
— the marker is reserved for actually-escalated units."""
|
||||||
|
unit = FakeUnit(label="light_edit", has_popup=False)
|
||||||
|
units = [unit]
|
||||||
|
|
||||||
|
plan_calls: list[dict] = []
|
||||||
|
|
||||||
|
def _spy_plan(classification):
|
||||||
|
plan_calls.append(classification)
|
||||||
|
return plan_details_popup_escalation(classification)
|
||||||
|
|
||||||
|
first = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_always_popup_classification("minor_overflow"),
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=_spy_plan,
|
||||||
|
)[0]
|
||||||
|
assert first["gate_status"] == "infeasible_category"
|
||||||
|
# Unit-side marker NOT stamped on the infeasible path.
|
||||||
|
assert getattr(unit, "has_popup") is False
|
||||||
|
assert getattr(unit, "popup_escalation_plan", None) is None
|
||||||
|
assert len(plan_calls) == 1
|
||||||
|
|
||||||
|
second = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_always_popup_classification("minor_overflow"),
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=_spy_plan,
|
||||||
|
)[0]
|
||||||
|
# Second call must re-evaluate (no short-circuit) — plan_callable
|
||||||
|
# invoked again, gate_status still infeasible_category.
|
||||||
|
assert second["gate_status"] == "infeasible_category"
|
||||||
|
assert len(plan_calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_idempotent_short_circuit_does_not_call_plan_callable():
|
||||||
|
"""u5 q2 — the plan_for_classification callable must NOT be invoked
|
||||||
|
when idempotency short-circuit fires. Guards against duplicate work."""
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
def _spy_plan(classification):
|
||||||
|
calls.append(classification)
|
||||||
|
return plan_details_popup_escalation(classification)
|
||||||
|
|
||||||
|
units = [FakeUnit(label="restructure", has_popup=True)]
|
||||||
|
run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_always_popup_classification(),
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=_spy_plan,
|
||||||
|
)
|
||||||
|
assert calls == [], (
|
||||||
|
"plan_for_classification must NOT be invoked when the unit already "
|
||||||
|
"carries has_popup=True (idempotent short-circuit takes precedence)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── No-classification path ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_no_classification_skips_with_skip_reason():
|
||||||
|
"""u5 — when classification_for_unit returns None (no overflow on
|
||||||
|
this unit), the gate records gate_status='no_classification' and
|
||||||
|
does NOT call plan_for_classification."""
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
def _spy_plan(classification):
|
||||||
|
calls.append(classification)
|
||||||
|
return plan_details_popup_escalation(classification)
|
||||||
|
|
||||||
|
units = [FakeUnit(label="restructure")]
|
||||||
|
record = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_no_classification,
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=_spy_plan,
|
||||||
|
)[0]
|
||||||
|
assert record["gate_status"] == "no_classification"
|
||||||
|
assert (
|
||||||
|
record["skip_reason"] == STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON
|
||||||
|
)
|
||||||
|
assert record["has_popup"] is False
|
||||||
|
assert record["popup_escalation_plan"] is None
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Infeasible category path (router defensive guard) ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_infeasible_category_records_skip_reason_and_keeps_has_popup_false():
|
||||||
|
"""u5 — when classification_for_unit returns a NON-popup category
|
||||||
|
(e.g., minor_overflow), plan_details_popup_escalation emits
|
||||||
|
feasible=False. The gate must NOT silently escalate; it records
|
||||||
|
gate_status='infeasible_category', stamps the plan dict (with
|
||||||
|
feasible=False) so traces are auditable, and leaves has_popup=False."""
|
||||||
|
units = [FakeUnit(label="light_edit", has_popup=False)]
|
||||||
|
record = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_always_popup_classification("minor_overflow"),
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=plan_details_popup_escalation,
|
||||||
|
)[0]
|
||||||
|
assert record["gate_status"] == "infeasible_category"
|
||||||
|
assert (
|
||||||
|
record["skip_reason"]
|
||||||
|
== STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON
|
||||||
|
)
|
||||||
|
assert record["has_popup"] is False
|
||||||
|
# plan dict is still recorded for trace auditability (router u3
|
||||||
|
# emits feasible=False with failure_reason).
|
||||||
|
assert record["popup_escalation_plan"] is not None
|
||||||
|
assert record["popup_escalation_plan"]["feasible"] is False
|
||||||
|
assert "failure_reason" in record["popup_escalation_plan"]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Mixed batch — per-unit gate decisions are independent ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_per_unit_decisions_are_independent():
|
||||||
|
"""u5 q1 — gate runs per-unit. Mixed batch: one feasible-escalation,
|
||||||
|
one idempotent short-circuit, one infeasible-category, one
|
||||||
|
no-classification. Each record reflects its own unit's path."""
|
||||||
|
units = [
|
||||||
|
FakeUnit(label="restructure", has_popup=False), # 0 escalate
|
||||||
|
FakeUnit(label="reject", has_popup=True), # 1 idempotent
|
||||||
|
FakeUnit(label="light_edit", has_popup=False), # 2 infeasible
|
||||||
|
FakeUnit(label="use_as_is", has_popup=False), # 3 no_cls
|
||||||
|
]
|
||||||
|
|
||||||
|
def _classification_for_unit(unit):
|
||||||
|
idx = next(i for i, u in enumerate(units) if u is unit)
|
||||||
|
if idx == 0:
|
||||||
|
return {"category": "structural_major_overflow"}
|
||||||
|
if idx == 1:
|
||||||
|
return {"category": "tabular_overflow"}
|
||||||
|
if idx == 2:
|
||||||
|
return {"category": "minor_overflow"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
records = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_classification_for_unit,
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=plan_details_popup_escalation,
|
||||||
|
)
|
||||||
|
assert [r["gate_status"] for r in records] == [
|
||||||
|
"escalated",
|
||||||
|
"idempotent_short_circuit",
|
||||||
|
"infeasible_category",
|
||||||
|
"no_classification",
|
||||||
|
]
|
||||||
|
assert [r["has_popup"] for r in records] == [True, True, False, False]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── route_for_label callable is honored ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_route_for_label_callable_is_honored_per_unit():
|
||||||
|
"""u5 — route_for_label callable shape mirrors u4 / Step 12 / Step 17
|
||||||
|
AI_REPAIR. The route_hint must be stamped on every record regardless
|
||||||
|
of gate path (escalated / idempotent / infeasible / no_cls)."""
|
||||||
|
units = [
|
||||||
|
FakeUnit(label="use_as_is"),
|
||||||
|
FakeUnit(label="light_edit"),
|
||||||
|
FakeUnit(label="restructure"),
|
||||||
|
FakeUnit(label="reject"),
|
||||||
|
FakeUnit(label=None),
|
||||||
|
]
|
||||||
|
records = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_no_classification,
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=plan_details_popup_escalation,
|
||||||
|
)
|
||||||
|
assert [r["route_hint"] for r in records] == [
|
||||||
|
"direct_render",
|
||||||
|
"deterministic_minor_adjustment",
|
||||||
|
"ai_adaptation_required",
|
||||||
|
"design_reference_only",
|
||||||
|
None,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── plan_for_classification injection lock ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_gate_plan_for_classification_callable_is_used_not_imported_directly():
|
||||||
|
"""u5 — plan_for_classification is a callable parameter, not a module-
|
||||||
|
level import inside the gate. Pipeline injects the real router stub;
|
||||||
|
tests inject a stub. This keeps the gate decoupled from the router
|
||||||
|
surface for testability and isolation."""
|
||||||
|
sentinel_plan = {
|
||||||
|
"action": "details_popup_escalation",
|
||||||
|
"feasible": True,
|
||||||
|
"stub": True,
|
||||||
|
"category": "structural_major_overflow",
|
||||||
|
"needs_split_decision": True,
|
||||||
|
"mapping_source": "test sentinel",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _sentinel_plan_for(_classification):
|
||||||
|
return sentinel_plan
|
||||||
|
|
||||||
|
units = [FakeUnit(label="restructure", has_popup=False)]
|
||||||
|
record = run_step17_popup_gate(
|
||||||
|
units,
|
||||||
|
classification_for_unit=_always_popup_classification(),
|
||||||
|
route_for_label=_route_for_label,
|
||||||
|
plan_for_classification=_sentinel_plan_for,
|
||||||
|
)[0]
|
||||||
|
assert record["popup_escalation_plan"] is sentinel_plan
|
||||||
|
assert record["gate_status"] == "escalated"
|
||||||
|
assert record["has_popup"] is True
|
||||||
305
tests/phase_z2/test_popup_mdx_preservation.py
Normal file
305
tests/phase_z2/test_popup_mdx_preservation.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
"""IMP-35 (#64) u10 — MDX preservation guard tests.
|
||||||
|
|
||||||
|
Stage 2 binding contract (unit u10):
|
||||||
|
After Step 17 POPUP gate (u5) stamps the unit, composition (u6) binds
|
||||||
|
the strategy, pipeline (u7) wires the render context, and slide_base
|
||||||
|
(u8) renders the ``<details>/<summary>`` wrapper, the end-to-end
|
||||||
|
invariant the user lock requires is:
|
||||||
|
|
||||||
|
MDX 원문 무손실 보존 (오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6
|
||||||
|
line 110, CLAUDE.md 자세히보기 원칙):
|
||||||
|
- popup body == FULL ``raw_content`` (byte-for-byte verbatim)
|
||||||
|
- body preview == SUBSET of ``raw_content`` (deterministic
|
||||||
|
leading-substring CUT — never a rewrite, never a re-summary)
|
||||||
|
- the original is ALWAYS reachable via the popup; the preview
|
||||||
|
loses no information because the popup holds the full source
|
||||||
|
- no structural element is dropped: text_block / table / image
|
||||||
|
/ ``<details>`` counts in popup body match the original
|
||||||
|
|
||||||
|
u6 and u7 each lock pieces of this invariant on their own surface.
|
||||||
|
u10 locks the END-TO-END no-content-drop guarantee on the rendered
|
||||||
|
payload — the surface a downstream verifier (Selenium / vision gate)
|
||||||
|
would inspect — so a future refactor on either u6 or u7 cannot
|
||||||
|
silently degrade MDX preservation without this test failing first.
|
||||||
|
|
||||||
|
Key invariants this file locks:
|
||||||
|
1. popup_html (full source) preserves every structural element from
|
||||||
|
raw_content byte-for-byte: bullet lines, paragraph blocks, markdown
|
||||||
|
table rows, image markdown, and nested ``<details>`` blocks.
|
||||||
|
2. preview_text is a deterministic leading-substring CUT of
|
||||||
|
raw_content — ``raw_content.startswith(preview_text)`` holds when
|
||||||
|
truncation happened.
|
||||||
|
3. Combined invariant: popup_html holds the FULL original even when
|
||||||
|
preview_text is shorter, so no content is dropped — the full
|
||||||
|
source is always reachable via the popup.
|
||||||
|
4. has_popup=False path: popup_html / preview_text are both None.
|
||||||
|
There is no popup escalation, so by definition no escalation can
|
||||||
|
drop content; the frame's partial_html (rendered separately by
|
||||||
|
slide_base.html and not part of u7 popup wiring) holds the inline
|
||||||
|
body.
|
||||||
|
5. AI isolation contract — pure deterministic preservation check;
|
||||||
|
no anthropic import, no route_ai_fallback path.
|
||||||
|
|
||||||
|
Cross-references:
|
||||||
|
- u6 composition popup binding (popup_body_source = full raw_content):
|
||||||
|
tests/phase_z2/test_composition_popup_strategy.py
|
||||||
|
- u7 pipeline wiring (popup_html = popup_body_source verbatim;
|
||||||
|
preview_text is a deterministic line-budget cut):
|
||||||
|
tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py
|
||||||
|
- u8 slide_base.html render surface (autoescaped popup body):
|
||||||
|
tests/phase_z2/test_slide_base_popup_render.py
|
||||||
|
- u9 display_strategies.yaml catalog (preserves_original=True for the
|
||||||
|
popup-bearing strategy):
|
||||||
|
tests/phase_z2/test_display_strategies_popup.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.phase_z2_composition import compose_zone_popup_payload
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Synthetic stubs ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _StubUnit:
|
||||||
|
"""Minimal duck-typed CompositionUnit for u10 preservation tests."""
|
||||||
|
|
||||||
|
raw_content: str = "MOCK_ORIGINAL_CONTENT"
|
||||||
|
has_popup: bool = False
|
||||||
|
popup_escalation_plan: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_popup_plan() -> dict:
|
||||||
|
"""Mirror the plan_details_popup_escalation feasible-escalation shape
|
||||||
|
(u3). u10 only echoes the plan into the unit so the binder reaches
|
||||||
|
the popup branch; no field is consumed here."""
|
||||||
|
return {
|
||||||
|
"action": "details_popup_escalation",
|
||||||
|
"stub": True,
|
||||||
|
"feasible": True,
|
||||||
|
"category": "structural_major_overflow",
|
||||||
|
"needs_split_decision": True,
|
||||||
|
"rationale": "MOCK_RATIONALE",
|
||||||
|
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Deterministic structural-element counters ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _count_markdown_bullet_lines(text: str) -> int:
|
||||||
|
"""Count leading-``-`` markdown bullet lines (- / * / + at line start)."""
|
||||||
|
return sum(
|
||||||
|
1 for line in text.splitlines() if re.match(r"^\s*[-*+]\s+", line)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _count_markdown_table_rows(text: str) -> int:
|
||||||
|
"""Count markdown table rows (lines with ``|`` somewhere)."""
|
||||||
|
return sum(1 for line in text.splitlines() if "|" in line)
|
||||||
|
|
||||||
|
|
||||||
|
def _count_markdown_images(text: str) -> int:
|
||||||
|
"""Count markdown image references ````."""
|
||||||
|
return len(re.findall(r"!\[[^\]]*\]\([^)]+\)", text))
|
||||||
|
|
||||||
|
|
||||||
|
def _count_details_blocks(text: str) -> int:
|
||||||
|
"""Count nested ``<details>`` blocks in raw_content (rare — used to
|
||||||
|
lock the invariant even when MDX already carries native popups)."""
|
||||||
|
return len(re.findall(r"<details\b", text, flags=re.IGNORECASE))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Sample MDX content (structural diversity for the count guard) ──
|
||||||
|
|
||||||
|
|
||||||
|
_FULL_MDX_SAMPLE = (
|
||||||
|
"## MOCK_SECTION_TITLE\n"
|
||||||
|
"\n"
|
||||||
|
"Paragraph one explaining the MOCK topic. Lorem ipsum dolor sit amet.\n"
|
||||||
|
"\n"
|
||||||
|
"- bullet one with **bold** marker\n"
|
||||||
|
"- bullet two with *italic* marker\n"
|
||||||
|
"- bullet three trailing\n"
|
||||||
|
"\n"
|
||||||
|
"| col_a | col_b |\n"
|
||||||
|
"| --- | --- |\n"
|
||||||
|
"| MOCK_A | MOCK_B |\n"
|
||||||
|
"| MOCK_C | MOCK_D |\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"<details><summary>MOCK_NESTED_TRIGGER</summary>"
|
||||||
|
"<p>MOCK_NESTED_BODY</p></details>\n"
|
||||||
|
"\n"
|
||||||
|
"Paragraph two — closing remarks for the MOCK topic.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Popup body = full raw_content (byte-for-byte) ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_body_byte_for_byte_equal_to_raw_content():
|
||||||
|
"""u10 — the end-to-end invariant: popup_html on the rendered payload
|
||||||
|
is byte-for-byte equal to the unit's raw_content. u6 + u7 already
|
||||||
|
lock this on their own surface; u10 re-asserts on the payload a
|
||||||
|
downstream verifier (Selenium / vision gate) would inspect."""
|
||||||
|
unit = _StubUnit(
|
||||||
|
raw_content=_FULL_MDX_SAMPLE,
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||||
|
assert payload["popup_html"] == _FULL_MDX_SAMPLE
|
||||||
|
assert len(payload["popup_html"]) == len(_FULL_MDX_SAMPLE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_body_preserves_bullet_line_count():
|
||||||
|
"""u10 — text_block count equality. Every bullet line present in
|
||||||
|
raw_content MUST also be present in popup_html. A future refactor
|
||||||
|
that accidentally trims popup body to a summary would drop bullets
|
||||||
|
and fail this guard."""
|
||||||
|
unit = _StubUnit(
|
||||||
|
raw_content=_FULL_MDX_SAMPLE,
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||||
|
assert _count_markdown_bullet_lines(payload["popup_html"]) == (
|
||||||
|
_count_markdown_bullet_lines(_FULL_MDX_SAMPLE)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_body_preserves_markdown_table_row_count():
|
||||||
|
"""u10 — table count equality. Markdown table rows (header / divider
|
||||||
|
/ data) MUST all survive the popup wiring."""
|
||||||
|
unit = _StubUnit(
|
||||||
|
raw_content=_FULL_MDX_SAMPLE,
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||||
|
assert _count_markdown_table_rows(payload["popup_html"]) == (
|
||||||
|
_count_markdown_table_rows(_FULL_MDX_SAMPLE)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_body_preserves_image_reference_count():
|
||||||
|
"""u10 — image count equality. Markdown ```` references
|
||||||
|
MUST all survive (CLAUDE.md: 이미지는 원본 그대로 사용, 크기만 조절 —
|
||||||
|
popup escalation must not silently drop image refs)."""
|
||||||
|
unit = _StubUnit(
|
||||||
|
raw_content=_FULL_MDX_SAMPLE,
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||||
|
assert _count_markdown_images(payload["popup_html"]) == (
|
||||||
|
_count_markdown_images(_FULL_MDX_SAMPLE)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_body_preserves_nested_details_block_count():
|
||||||
|
"""u10 — nested ``<details>`` blocks. Even when MDX already carries
|
||||||
|
a native popup, the u10 popup escalation MUST NOT collapse or drop
|
||||||
|
nested ``<details>`` markers."""
|
||||||
|
unit = _StubUnit(
|
||||||
|
raw_content=_FULL_MDX_SAMPLE,
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||||
|
assert _count_details_blocks(payload["popup_html"]) == (
|
||||||
|
_count_details_blocks(_FULL_MDX_SAMPLE)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Preview = deterministic leading-substring CUT ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_text_is_a_leading_substring_of_raw_content_when_truncated():
|
||||||
|
"""u10 — preview is a CUT, never a rewrite. When truncation happens,
|
||||||
|
raw_content MUST start with preview_text verbatim (line-boundary
|
||||||
|
cut semantics; popup body retains the FULL original)."""
|
||||||
|
unit = _StubUnit(
|
||||||
|
raw_content=_FULL_MDX_SAMPLE,
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
# 2-line budget — far smaller than the multi-line sample.
|
||||||
|
payload = compose_zone_popup_payload(unit, container_height_px=36)
|
||||||
|
preview = payload["preview_text"]
|
||||||
|
assert preview, "preview_text must be non-empty when truncation fires"
|
||||||
|
assert _FULL_MDX_SAMPLE.startswith(preview), (
|
||||||
|
"preview_text must be a leading-substring of raw_content "
|
||||||
|
"(MDX 원문 무손실 보존 — preview is a CUT, never a rewrite)."
|
||||||
|
)
|
||||||
|
# The popup body still holds the FULL original — no information loss.
|
||||||
|
assert payload["popup_html"] == _FULL_MDX_SAMPLE
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_content_drop_when_preview_is_shorter_than_popup_body():
|
||||||
|
"""u10 — combined no-drop invariant. preview_text may be a strict
|
||||||
|
prefix of popup_html (shorter), but the popup body always holds the
|
||||||
|
full original. The user can always reach every line of the source
|
||||||
|
via the popup, even when the inline preview shows only the head."""
|
||||||
|
unit = _StubUnit(
|
||||||
|
raw_content=_FULL_MDX_SAMPLE,
|
||||||
|
has_popup=True,
|
||||||
|
popup_escalation_plan=_stub_popup_plan(),
|
||||||
|
)
|
||||||
|
payload = compose_zone_popup_payload(unit, container_height_px=36)
|
||||||
|
preview = payload["preview_text"]
|
||||||
|
popup_body = payload["popup_html"]
|
||||||
|
# preview is strictly shorter when truncation fires.
|
||||||
|
assert len(preview) < len(popup_body)
|
||||||
|
# popup_body is the FULL original — every line of raw_content is
|
||||||
|
# present in popup_body regardless of the inline preview budget.
|
||||||
|
for line in _FULL_MDX_SAMPLE.splitlines():
|
||||||
|
assert line in popup_body, (
|
||||||
|
f"MDX preservation guard violated — line {line!r} not present "
|
||||||
|
f"in popup body."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── has_popup=False path: no popup, no escalation, no drop ─────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_popup_path_yields_no_popup_html_no_preview_text():
|
||||||
|
"""u10 — when the Step 17 POPUP gate did not fire, no popup
|
||||||
|
escalation happens. popup_html and preview_text are both None.
|
||||||
|
By construction this branch cannot drop content (no escalation),
|
||||||
|
and the frame's partial_html (rendered separately by slide_base
|
||||||
|
and not part of u7 popup wiring) holds the inline body."""
|
||||||
|
unit = _StubUnit(raw_content=_FULL_MDX_SAMPLE, has_popup=False)
|
||||||
|
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||||
|
assert payload["has_popup"] is False
|
||||||
|
assert payload["popup_html"] is None
|
||||||
|
assert payload["preview_text"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AI isolation contract (structural import lock) ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_mdx_preservation_module_has_no_ai_imports():
|
||||||
|
"""u10 — preservation guard MUST stay AI-free. Structural guard:
|
||||||
|
composition module (where compose_zone_popup_payload lives) is
|
||||||
|
allowed to consult the catalog and unit state, never the Anthropic
|
||||||
|
SDK / route_ai_fallback path. Mirrors u6 / u7 import-isolation
|
||||||
|
pattern (feedback_ai_isolation_contract)."""
|
||||||
|
import src.phase_z2_composition as composition_module
|
||||||
|
|
||||||
|
source = composition_module.__file__
|
||||||
|
assert source is not None
|
||||||
|
with open(source, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
assert "import anthropic" not in text
|
||||||
|
assert "from anthropic" not in text
|
||||||
|
assert "route_ai_fallback" not in text
|
||||||
413
tests/phase_z2/test_slide_base_popup_render.py
Normal file
413
tests/phase_z2/test_slide_base_popup_render.py
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
"""IMP-35 (#64) u8 — slide_base.html details/summary popup render tests.
|
||||||
|
|
||||||
|
Stage 2 wiring contract (unit u8):
|
||||||
|
u7 (``compose_zone_popup_payload`` in ``src/phase_z2_pipeline.py``) wired
|
||||||
|
four uniform per-zone render-context keys into every ``zones_data``
|
||||||
|
entry::
|
||||||
|
|
||||||
|
has_popup : bool
|
||||||
|
popup_html : str | None (FULL ``raw_content`` verbatim when
|
||||||
|
has_popup=True)
|
||||||
|
preview_text : str | None
|
||||||
|
popup_binding : dict | None (u6 binding — includes
|
||||||
|
``display_strategy``,
|
||||||
|
``detail_trigger.{placement,label}``)
|
||||||
|
|
||||||
|
u8 is the slide_base.html consumer side: it renders a JS-free
|
||||||
|
``<details>/<summary>`` wrapper inside the zone div when
|
||||||
|
``zone.has_popup`` is True. The summary acts as the toggle, the body
|
||||||
|
holds the FULL ``popup_html``. The frame's existing ``partial_html``
|
||||||
|
remains the zone body (inline preview / FIT-version of content); the
|
||||||
|
popup body holds the original — never replaces the partial.
|
||||||
|
|
||||||
|
Key invariants this file locks:
|
||||||
|
1. has_popup=False → no ``<details>`` element emitted (byte-identical
|
||||||
|
contract for non-popup zones, no regression to pre-u8).
|
||||||
|
2. has_popup=True → exactly one ``<details class="zone__popup-details
|
||||||
|
zone__popup-details--<placement>">`` per zone with a ``<summary>``
|
||||||
|
trigger and a ``<div class="zone__popup-body">`` holding the full
|
||||||
|
popup_html.
|
||||||
|
3. Popup body content is HTML-escaped (Jinja2 autoescape is ON for
|
||||||
|
slide_base.html — popup_html is plain MDX text, never raw HTML).
|
||||||
|
A ``<script>`` literal in raw_content MUST appear escaped, never as
|
||||||
|
an executable tag.
|
||||||
|
4. Whitespace inside the popup body is preserved via the
|
||||||
|
``.zone__popup-body`` CSS contract (``white-space: pre-wrap``).
|
||||||
|
Locks MDX 원문 무손실 보존 — newline structure of raw_content is
|
||||||
|
visible verbatim (오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6
|
||||||
|
line 110).
|
||||||
|
5. Placement / label / strategy id are READ from
|
||||||
|
``zone.popup_binding.detail_trigger.{placement,label}`` and
|
||||||
|
``zone.popup_binding.display_strategy`` — no hardcoded literal
|
||||||
|
drift from the catalog
|
||||||
|
(``templates/phase_z2/regions/display_strategies.yaml``).
|
||||||
|
6. Defensive defaults: a popup zone whose ``popup_binding`` is ``None``
|
||||||
|
(the unrenderable empty-plan branch of the pipeline composer
|
||||||
|
stamps ``popup_binding=None``) still renders sane defaults
|
||||||
|
(``placement=top-right``, ``label=details``,
|
||||||
|
``display_strategy=inline_preview_with_details``) — no
|
||||||
|
KeyError/AttributeError on the Jinja2 path.
|
||||||
|
7. The zone div carries ``data-has-popup="1"`` exactly when
|
||||||
|
has_popup=True — downstream observability anchor.
|
||||||
|
|
||||||
|
Cross-references:
|
||||||
|
- u5 Step 17 POPUP gate (stamps the marker on the unit):
|
||||||
|
tests/phase_z2/test_phase_z2_step17_popup_gate.py
|
||||||
|
- u6 composition popup binding (produces the binding dict u8 reads):
|
||||||
|
tests/phase_z2/test_composition_popup_strategy.py
|
||||||
|
- u7 pipeline composer wiring (puts the four keys into zones_data):
|
||||||
|
tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py
|
||||||
|
- display strategy catalog (placement / label source of truth):
|
||||||
|
templates/phase_z2/regions/display_strategies.yaml
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.phase_z2_pipeline import render_slide
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Test scaffolding ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _layout_css() -> dict:
|
||||||
|
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
|
||||||
|
|
||||||
|
|
||||||
|
def _no_popup_zone(**overrides) -> dict:
|
||||||
|
"""Baseline non-popup zone (matches the four-key wiring from u7
|
||||||
|
when has_popup=False — popup_binding may be None for the empty plan
|
||||||
|
branch or the u6 ``inline_full`` echo for renderable no-popup units;
|
||||||
|
here we exercise the empty-plan branch where popup_binding=None)."""
|
||||||
|
base = {
|
||||||
|
"position": "primary",
|
||||||
|
"template_id": "__empty__",
|
||||||
|
"slot_payload": {},
|
||||||
|
"has_popup": False,
|
||||||
|
"popup_html": None,
|
||||||
|
"preview_text": None,
|
||||||
|
"popup_binding": None,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _popup_binding(
|
||||||
|
*,
|
||||||
|
placement: str = "top-right",
|
||||||
|
label: str = "details",
|
||||||
|
strategy: str = "inline_preview_with_details",
|
||||||
|
) -> dict:
|
||||||
|
"""Matches the u6 binding shape (subset relevant to u8 render)."""
|
||||||
|
return {
|
||||||
|
"display_strategy": strategy,
|
||||||
|
"detail_trigger": {"placement": placement, "label": label},
|
||||||
|
"has_popup": True,
|
||||||
|
"popup_escalation_plan": {"action": "details_popup_escalation"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _popup_zone(
|
||||||
|
*,
|
||||||
|
popup_html: str = "MOCK_POPUP_BODY_FULL_ORIGINAL",
|
||||||
|
binding: dict | None = None,
|
||||||
|
**overrides,
|
||||||
|
) -> dict:
|
||||||
|
"""Baseline popup zone (has_popup=True) for u8 rendering tests."""
|
||||||
|
base = {
|
||||||
|
"position": "primary",
|
||||||
|
"template_id": "__empty__",
|
||||||
|
"slot_payload": {},
|
||||||
|
"has_popup": True,
|
||||||
|
"popup_html": popup_html,
|
||||||
|
"preview_text": "MOCK_PREVIEW",
|
||||||
|
"popup_binding": binding if binding is not None else _popup_binding(),
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _render(zones: list[dict]) -> str:
|
||||||
|
return render_slide(
|
||||||
|
slide_title="t",
|
||||||
|
slide_footer=None,
|
||||||
|
zones_data=zones,
|
||||||
|
layout_preset="single",
|
||||||
|
layout_css=_layout_css(),
|
||||||
|
gap_px=14,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invariant 1 — no details on no-popup zone ───────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _body_section(html: str) -> str:
|
||||||
|
"""Return the HTML between </style> and </body> so assertions can
|
||||||
|
target the rendered body content without false positives on the
|
||||||
|
in-template CSS block (which legitimately declares the popup CSS
|
||||||
|
classes regardless of whether any zone emits a popup)."""
|
||||||
|
end_of_style = html.index("</style>") + len("</style>")
|
||||||
|
return html[end_of_style:]
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_without_popup_does_not_render_details_element():
|
||||||
|
"""has_popup=False → no ``<details class="zone__popup-details">``
|
||||||
|
element emitted. The CSS class declarations stay in <style> (CSS
|
||||||
|
contract lives once in the template); what MUST NOT appear is the
|
||||||
|
element instance in the body."""
|
||||||
|
body = _body_section(_render([_no_popup_zone()]))
|
||||||
|
assert "<details" not in body
|
||||||
|
assert "zone__popup-details" not in body
|
||||||
|
assert "zone__popup-summary" not in body
|
||||||
|
assert "zone__popup-body" not in body
|
||||||
|
assert "data-has-popup" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_without_popup_keeps_existing_zone_attrs():
|
||||||
|
"""No regression on the zone div for non-popup zones — the
|
||||||
|
data-zone-position + data-template-id contract from pre-u8 stays
|
||||||
|
intact."""
|
||||||
|
html = _render([_no_popup_zone()])
|
||||||
|
assert 'data-zone-position="primary"' in html
|
||||||
|
assert 'data-template-id="__empty__"' in html
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invariant 2 — exactly one details on popup zone ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_with_popup_renders_details_summary_body_triple():
|
||||||
|
"""has_popup=True → exactly one ``<details class="zone__popup-details
|
||||||
|
...">`` per zone with a ``<summary class="zone__popup-summary">``
|
||||||
|
trigger AND a ``<div class="zone__popup-body">`` body."""
|
||||||
|
html = _render([_popup_zone()])
|
||||||
|
details_matches = re.findall(
|
||||||
|
r'<details class="zone__popup-details[^"]*"', html
|
||||||
|
)
|
||||||
|
assert len(details_matches) == 1
|
||||||
|
assert 'class="zone__popup-summary"' in html
|
||||||
|
assert 'class="zone__popup-body"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_with_popup_marks_zone_div_with_data_has_popup_attr():
|
||||||
|
"""The zone div carries ``data-has-popup="1"`` exactly when
|
||||||
|
has_popup=True (downstream observability anchor)."""
|
||||||
|
html = _render([_popup_zone()])
|
||||||
|
assert 'data-has-popup="1"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_without_popup_does_not_carry_data_has_popup_attr():
|
||||||
|
"""has_popup=False zone div MUST NOT carry the data-has-popup
|
||||||
|
attribute (otherwise the observability anchor lies)."""
|
||||||
|
html = _render([_no_popup_zone()])
|
||||||
|
assert "data-has-popup" not in html
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invariant 3 — escaping (XSS safety + literal preservation) ──────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_body_html_special_chars_are_escaped():
|
||||||
|
"""popup_html is plain MDX text. A literal ``<script>`` in
|
||||||
|
raw_content MUST appear escaped (Jinja2 autoescape ON), never as an
|
||||||
|
executable tag. Locks XSS guard + MDX-as-text contract."""
|
||||||
|
payload = "<script>alert(1)</script>"
|
||||||
|
html = _render([_popup_zone(popup_html=payload)])
|
||||||
|
# Raw <script> tag MUST NOT appear inside popup body.
|
||||||
|
assert "<script>alert(1)</script>" not in html
|
||||||
|
# Escaped form MUST appear (& -> & lt -> <).
|
||||||
|
assert "<script>alert(1)</script>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_body_ampersand_and_quotes_are_escaped():
|
||||||
|
"""Literal ``&`` ``<`` ``>`` ``"`` ``'`` in popup_html are
|
||||||
|
autoescaped — round-trip safe through the HTML body."""
|
||||||
|
payload = "A & B < C > D \" E ' F"
|
||||||
|
html = _render([_popup_zone(popup_html=payload)])
|
||||||
|
assert "&" in html
|
||||||
|
assert "<" in html
|
||||||
|
assert ">" in html
|
||||||
|
# Raw form of the un-escaped ampersand sequence must not appear.
|
||||||
|
assert "A & B < C > D" not in html
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invariant 4 — whitespace preservation contract ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_body_preserves_newlines_in_content_verbatim():
|
||||||
|
"""popup_html with newlines is emitted verbatim into the body —
|
||||||
|
no collapse, no trim. Visual newline preservation is the CSS
|
||||||
|
contract (.zone__popup-body { white-space: pre-wrap }) but the
|
||||||
|
underlying text MUST carry the newlines through to the HTML."""
|
||||||
|
payload = "line one\nline two\nline three"
|
||||||
|
html = _render([_popup_zone(popup_html=payload)])
|
||||||
|
# The exact body text appears between the body div tags.
|
||||||
|
body_match = re.search(
|
||||||
|
r'<div class="zone__popup-body">(.*?)</div>',
|
||||||
|
html,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert body_match is not None
|
||||||
|
assert body_match.group(1) == payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_body_css_class_declares_whitespace_pre_wrap():
|
||||||
|
"""The CSS contract that makes the preserved newlines actually
|
||||||
|
visible is ``.zone__popup-body { white-space: pre-wrap }`` in
|
||||||
|
slide_base.html. Locks the styling axis — without this rule the
|
||||||
|
preserved newlines collapse in render."""
|
||||||
|
html = _render([_popup_zone()])
|
||||||
|
# Compress whitespace before regex match (CSS block formatting
|
||||||
|
# may vary across edits).
|
||||||
|
flat = re.sub(r"\s+", " ", html)
|
||||||
|
assert ".zone__popup-body" in flat
|
||||||
|
assert "white-space: pre-wrap" in flat
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_body_holds_full_raw_content_verbatim():
|
||||||
|
"""popup_html (FULL raw_content from u7 / u6) appears in the body
|
||||||
|
char-for-char (modulo HTML escape on special chars). No trim, no
|
||||||
|
summary substitution — MDX 원문 무손실 보존 (오답노트 #5)."""
|
||||||
|
payload = (
|
||||||
|
"## MOCK_SECTION_TITLE\n\n"
|
||||||
|
"- bullet 1\n"
|
||||||
|
"- bullet 2\n"
|
||||||
|
"- bullet 3 with **bold**\n"
|
||||||
|
)
|
||||||
|
html = _render([_popup_zone(popup_html=payload)])
|
||||||
|
# Extract the popup body content.
|
||||||
|
body_match = re.search(
|
||||||
|
r'<div class="zone__popup-body">(.*?)</div>',
|
||||||
|
html,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert body_match is not None
|
||||||
|
# ** stays as ** (autoescape only touches HTML special chars).
|
||||||
|
assert body_match.group(1) == payload
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invariant 5 — placement / label / strategy from binding ─────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_placement_class_modifier_reflects_binding_placement():
|
||||||
|
"""The placement (top-right / top-left / bottom-right / bottom-left)
|
||||||
|
is READ from zone.popup_binding.detail_trigger.placement and
|
||||||
|
surfaces as the BEM modifier on the details element."""
|
||||||
|
for placement in ("top-right", "top-left", "bottom-right", "bottom-left"):
|
||||||
|
zone = _popup_zone(binding=_popup_binding(placement=placement))
|
||||||
|
html = _render([zone])
|
||||||
|
assert f"zone__popup-details--{placement}" in html
|
||||||
|
assert f'data-popup-placement="{placement}"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_summary_label_reflects_binding_label():
|
||||||
|
"""The summary trigger text is READ from
|
||||||
|
zone.popup_binding.detail_trigger.label — no hardcoded literal in
|
||||||
|
the template (catalog drift guard)."""
|
||||||
|
zone = _popup_zone(binding=_popup_binding(label="자세히"))
|
||||||
|
html = _render([zone])
|
||||||
|
assert ">자세히</summary>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_data_display_strategy_attr_reflects_binding_strategy_id():
|
||||||
|
"""The details element carries data-display-strategy=<strategy_id>
|
||||||
|
from the binding so downstream observability (DOM scrape, test
|
||||||
|
introspection) can identify which catalog strategy fired."""
|
||||||
|
zone = _popup_zone(binding=_popup_binding(strategy="details_only"))
|
||||||
|
html = _render([zone])
|
||||||
|
assert 'data-display-strategy="details_only"' in html
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invariant 6 — defensive defaults (binding=None / missing keys) ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_zone_with_binding_none_uses_defensive_defaults():
|
||||||
|
"""The unrenderable empty-plan branch of the pipeline composer
|
||||||
|
stamps popup_binding=None (u7 wiring). u8 MUST render sane defaults
|
||||||
|
rather than KeyError/AttributeError on the Jinja2 path: placement =
|
||||||
|
top-right, label = 'details', strategy =
|
||||||
|
inline_preview_with_details."""
|
||||||
|
zone = _popup_zone(binding=None)
|
||||||
|
html = _render([zone])
|
||||||
|
assert "zone__popup-details--top-right" in html
|
||||||
|
assert ">details</summary>" in html
|
||||||
|
assert 'data-display-strategy="inline_preview_with_details"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_zone_with_partial_binding_falls_back_per_missing_key():
|
||||||
|
"""A binding dict missing detail_trigger (defensive — should not
|
||||||
|
happen in normal u6 output, but the template MUST be robust) falls
|
||||||
|
back to the same defaults as binding=None."""
|
||||||
|
partial_binding = {
|
||||||
|
"display_strategy": "inline_preview_with_details",
|
||||||
|
# detail_trigger intentionally omitted.
|
||||||
|
}
|
||||||
|
zone = _popup_zone(binding=partial_binding)
|
||||||
|
html = _render([zone])
|
||||||
|
assert "zone__popup-details--top-right" in html
|
||||||
|
assert ">details</summary>" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invariant 7 — multi-zone rendering ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_popup_zones_emit_details_in_multi_zone_slide():
|
||||||
|
"""Mixed slide: zone A has_popup=False, zone B has_popup=True.
|
||||||
|
Exactly ONE <details> block in the rendered HTML, on zone B only."""
|
||||||
|
zone_a = _no_popup_zone(position="left")
|
||||||
|
zone_b = _popup_zone(position="right")
|
||||||
|
html = _render([
|
||||||
|
zone_a,
|
||||||
|
zone_b,
|
||||||
|
])
|
||||||
|
matches = re.findall(r'<details class="zone__popup-details', html)
|
||||||
|
assert len(matches) == 1
|
||||||
|
# zone B is the right grid-area — popup details should sit within
|
||||||
|
# the zone whose div carries data-zone-position="right".
|
||||||
|
right_zone_block = re.search(
|
||||||
|
r'<div class="zone" data-zone-position="right"[^>]*>(.*?)</div>\s*</div>',
|
||||||
|
html,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
# If the regex above doesn't anchor (template HTML evolves), fall
|
||||||
|
# back to checking the details element appears AFTER the right
|
||||||
|
# zone marker but BEFORE the next zone marker.
|
||||||
|
if right_zone_block is None:
|
||||||
|
right_idx = html.index('data-zone-position="right"')
|
||||||
|
assert html.find("zone__popup-details", right_idx) > right_idx
|
||||||
|
# And the left zone block should NOT contain the popup.
|
||||||
|
left_end = html.index('data-zone-position="right"')
|
||||||
|
assert "zone__popup-details" not in html[:left_end]
|
||||||
|
else:
|
||||||
|
assert "zone__popup-details" in right_zone_block.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Determinism + smoke check ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_render_is_deterministic_across_calls():
|
||||||
|
"""Two calls with identical input produce byte-identical HTML —
|
||||||
|
no order-dependence on dict iteration, no time-based identifier."""
|
||||||
|
zone = _popup_zone(popup_html="MOCK\nMULTI\nLINE")
|
||||||
|
assert _render([zone]) == _render([zone])
|
||||||
|
|
||||||
|
|
||||||
|
def test_popup_emits_no_javascript_on_render_path():
|
||||||
|
"""CLAUDE.md 자세히보기 contract — HTML-native ``<details>`` ONLY,
|
||||||
|
no JavaScript hook on the popup render path (print auto-expand is a
|
||||||
|
separate OOS axis per IMP-35 scope-lock)."""
|
||||||
|
html = _render([_popup_zone()])
|
||||||
|
# The slide_base.html embedded-mode <script> is allowed (separate
|
||||||
|
# axis). What MUST NOT appear is any popup-specific JS handler.
|
||||||
|
# Search the popup details block for inline JS attributes.
|
||||||
|
details_block_match = re.search(
|
||||||
|
r'<details class="zone__popup-details.*?</details>',
|
||||||
|
html,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert details_block_match is not None
|
||||||
|
block = details_block_match.group(0)
|
||||||
|
for js_attr in ("onclick=", "onload=", "onopen=", "ontoggle="):
|
||||||
|
assert js_attr not in block
|
||||||
|
# And no <script> tag inside the details body.
|
||||||
|
assert "<script" not in block
|
||||||
@@ -21,8 +21,10 @@ from src.phase_z2_ai_fallback import step17 as step17_mod
|
|||||||
from src.phase_z2_ai_fallback.step17 import (
|
from src.phase_z2_ai_fallback.step17 import (
|
||||||
OVERFLOW_CASCADE_ORDER,
|
OVERFLOW_CASCADE_ORDER,
|
||||||
STEP17_AI_REPAIR_BLOCKED_REASON,
|
STEP17_AI_REPAIR_BLOCKED_REASON,
|
||||||
|
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON,
|
||||||
OverflowCascadeStage,
|
OverflowCascadeStage,
|
||||||
gather_step17_ai_repair_proposals,
|
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 == []
|
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 ─
|
# ─── Structural guarantee: u9 must NOT import route_ai_fallback / anthropic ─
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user