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