Compare commits

Author SHA1 Message Date
Kyeongmin 13a8d752d3 On main: imp87 prior attempt mixed 2026-05-23 19:55:28 +09:00
Kyeongmin 24cecf2b72 index on main: c53722a feat(#86): IMP-86 u1~u5 placeholder zones_data + invariant guard 2026-05-23 19:55:28 +09:00
KyeongminandClaude Opus 4.7 c53722ad0b feat(#86): IMP-86 u1~u5 placeholder zones_data + invariant guard
Mapper FitError handler now appends a __empty__ placeholder to zones_data
and a matching debug_zone so the surviving cardinality stays in sync with
the active layout preset's grid rows. A pre-build_layout_css invariant
guard fails fast with preset/positions/count diagnostics if drift recurs.
Per-record telemetry (adapter_needed, mapper_fit_error, provisional) is
exposed on both placeholder records; authoritative slide_status.adapter_
needed_units schema is unchanged.

Closes mdx03 reject override regression: Step 12 AI router now reachable
without heights_px ValueError; default-path behavior unaffected.

u1 — FitError placeholder zones_data + debug_zone (src/phase_z2_pipeline.py)
u2 — pre-build_layout_css invariant guard (src/phase_z2_pipeline.py)
u3 — horizontal-2 normal+placeholder helper unit (test_compute_per_zone_geometry.py)
u4 — mdx03 reject override → Step 12 integration + default regression
u5 — placeholder telemetry surface (adapter_needed/mapper_fit_error/provisional)

Tests:
- u3 helper: 7 passed (0.06s)
- u4+u5 integration: 2 passed (7.87s)
- Phase Z2 + AI fallback regression: 544 passed (66.28s)
- Broader sweep (excl. matching/pipeline heavy): 1066 passed (96.12s)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:25:14 +09:00
KyeongminandClaude Opus 4.7 cacc5b30db feat(#85): IMP catalog builder invariant + VP runtime gate (u1~u7)
- u1: BuilderMissingError(FitError) — narrow exception aligned with pipeline catch
- u2: load_frame_contracts catalog invariant + VP skip + CatalogInvariantError
- u3a: audit CLI I1~I3 (partial existence / declared builder / registry membership)
- u3b: audit CLI I4 (slot_payload refs vs declared/generated payload keys)
- u4: lookup_v4_candidates VP filter (lookup_v4_all_judgments raw telemetry untouched)
- u5: catalog invariant regression coverage + temp non-VP failure fixtures
- u6: mdx04 VP routing fixture tests (sw_dependency_four_problems excluded from live)
- u7: tests/conftest.py env isolation + mdx03/mdx04/mdx05 subprocess smoke

Targeted 74 PASS (12.31s). Full regression 1063 PASS (87.70s). Audit CLI clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:56:38 +09:00
Kyeongmin d9d338416a feat(#62): IMP-46 cache fingerprint forwarding u1~u4 (router kwarg + step12 forward + 8 scenarios) 2026-05-23 08:53:22 +09:00
KyeongminandClaude Opus 4.7 f3ef4d917c feat(#64): IMP-35 details_popup_escalation u1~u10 + Stage 3 R7 anchor re-pin
Land the production + test surface for the Step 17 cascade POPUP terminal
(DETERMINISTIC -> POPUP -> AI_REPAIR -> USER_OVERRIDE) per Stage 2 plan R2.
u11 (baseline-red invariance gate) was already landed in 7c93031 ahead of
this commit; this commit completes u1~u10 plus the Stage 3 R7 follow-up
anchor re-pin for test_imp17_comment_anchor.py.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 07:36:57 +09:00
KyeongminandClaude Opus 4.7 7c93031f9b feat(#64): IMP-35 details_popup_escalation u11 baseline-red invariance gate
Add a test-only invariance gate that locks the pre-existing four-test red
baseline so IMP-35 cannot silently grow the red surface while in-flight.
u11 does NOT fix the four reds — Stage 2 follow_up_candidates tracks the
actual repair as a separate issue. u1~u10 production work remains in the
worktree and is explicitly out of this commit per Stage 3 R7 carve-out.

Frozen registry (IMP35_BASELINE_RED_NODE_IDS, set semantics):
  1. tests/test_imp47b_step12_ai_wiring.py
       ::test_mixed_units_classified_by_route_and_provisional_flag
  2. tests/test_imp47b_step12_ai_wiring.py
       ::test_reject_provisional_unit_reaches_router_short_circuit
  3. tests/test_imp47b_step12_ai_wiring.py
       ::test_step12_ai_repair_artifact_writes_json_serialisable_records
  4. tests/test_phase_z2_ai_fallback_config.py
       ::test_ai_fallback_master_flag_default_off

Gate semantics (subprocess pytest, set comparison):
  - All 4 node ids resolve to collectible pytest items
    (rename / delete is caught up front).
  - Broader baseline-area sweep across the two registry files yields
    EXACTLY 4 FAILED and 0 ERROR, with FAILED set ≡ registry.
  - A new red in the baseline area flips count above 4 OR introduces a
    FAILED id outside the registry; either branch fails the gate.
  - Cross-lock test ensures registry node ids cannot point outside the
    declared area-files inventory.

AI isolation contract (feedback_ai_isolation_contract):
  Gate body uses stdlib only (subprocess + re + ast). An AST self-verify
  test rejects `anthropic` imports and `route_ai_fallback` references in
  this file, structurally preventing AI routing inside the gate.

Stage 4 verification (HEAD c1df656 pre-commit):
  pytest -q tests/phase_z2/test_imp35_baseline_red_invariance.py
    → 7 passed in 15.26s.
  Baseline area sweep
    (tests/test_imp47b_step12_ai_wiring.py +
     tests/test_phase_z2_ai_fallback_config.py)
    → 4 failed / 6 passed / 0 errors; FAILED set ≡ registry (identity).
  pytest --collect-only on the 4 registered node ids → all 4 resolve.
  py_compile clean. Codex R1 = YES (independent verify).

Guardrails honored:
  - Scope-locked: test-only file; zero production code in this commit.
  - 1 commit = 1 decision unit (u11 only).
  - No hardcoding: registry = Stage 2 contract frozen tuple, not
    sample-specific literal; gate body has zero magic constants.
  - AI isolation: stdlib-only gate, AST self-verify locks isolation.
  - baseline-red 4 body repair = separate follow-up issue, not u11 scope.

source_comment_ids: Stage 1 problem-review; Stage 2 plan R2 + Codex R2
YES; Stage 3 Claude #30 + Codex #31 R7 YES; Stage 4 Claude #32 + Codex
#33 R1 YES.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 04:13:54 +09:00
KyeongminandClaude Opus 4.7 c1df656312 feat(#65): IMP-36 fit/rotation generalization (u1~u8)
Generalize Phase Z frame partial responsive fit / rotation to four canonical
F13/F14/F20/F8 family partials. Surface = 13 canonical partials; 19
builder-only contracts remain explicitly out of scope.

u1  test_imp17_comment_anchor: re-pin L570->L578 (restructure+IMP-17),
    L571->L579 (IMP-29 -> IMP-47B supersession). Stage 1 red baseline gate.
u2  frame_contracts.yaml: add rotation_eligible (P1) + body_fit_pattern2 (P2)
    bool axes on 13 partial-backed contracts. P1 True: F13/F14/F20/F8 (4).
    P2 True: F23 + P1_set (5). F29 columns[1].body_parser column_plain ->
    column_with_transform (P3 parity).
u3  test_imp36_fit_rotation_generalization (NEW, 166 lines): static
    parametrized assertions for P1 metadata + CQ presence, P1 opt-out
    absence, P2 --max-body-lines + clamp + cqh, P2 opt-out absence, 19
    builder-only exclusion.
u4  three_parallel_requirements (F13): introduce f13b-root container-name +
    container-type:size + @container (aspect-ratio<1.5) rotation;
    add inline --max-body-lines + body line-height clamp/cqh/calc.
u5  three_persona_benefits (F14): f14b-root P1 + P2 cqh/jinja body fit.
    Persona colors (#285b4a/#445a2f/#743002) and circle SVG aspect 1/1
    preserved.
u6  dx_sw_necessity_three_perspectives (F20): f20b-root P1 + P2 cqh/jinja
    body fit under IMP-49 partial-fidelity lock.
u7  info_management_what_how_when (F8): f8b-root P1 + P2 cqh/jinja body fit.
u8  test_imp36_overflow_chain_self_fire (NEW, 299 lines): Selenium self-fire
    harness for F13/F14/F20/F8 at aspect 1.78 vs 1.0. Asserts line-height
    changes, font-size invariance across all 4 frames (no per-frame exempt),
    grid columns rotate 3 -> 1, OVERFLOW_CASCADE_ORDER remains 4-tuple.

Stage 4 verification (HEAD 6f1c736 pre-commit baseline):
  u1 2/2 PASS, u3 33/33 PASS, u8 9/9 PASS (live Chrome).
  Regression sweep tests/phase_z2 + tests/orchestrator_unit 335/335 PASS.
  font-size mutations introduced: 0.
  Pre-existing red (test_imp47b_step12_ai_wiring x3, ai_fallback_master_flag
  default_off x1) verified unchanged via stash swap -> not introduced.

Guardrails honored:
  - cqh / clamp / container query only (no shared margin/padding/gap shrink).
  - font-size invariant under aspect change (P2 mutates line-height +
    --max-body-lines only).
  - No cross-frame .fNb__ class borrowing (IMP-49 partial-fidelity lock).
  - F14 circle SVG aspect 1/1 untouched; persona colors preserved.
  - AI isolation: no HTML structure generation; AI calls remain zone-content.
  - 1 turn = 1 step; commit excludes .claude/settings.json and all
    out-of-scope untracked worktree per Stage 4 binding contract.

source_comment_ids: Stage 1 #13/#14; Stage 2 #21/#22; Stage 3 #4 + Codex #4
YES; Stage 4 Claude #1 + Codex #3 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 01:18:20 +09:00
Kyeongmin 6f1c7367e0 feat(#79): IMP-51 image_overrides axis (u1~u11 backend stamp+CLI+CSS inject + frontend drag/resize+persistence + tests) 2026-05-22 21:54:38 +09:00
KyeongminandClaude Opus 4.7 bd8bcf748b feat(#81): IMP-54 frontend zone editing UI (u1~u4 edit-mode body-drag + emerald highlight + pure drag-math helper + vitest)
u1: 4 perimeter edge strips (~8px) + top-left grip chip at zone wrapper
    provide an edit-mode pointer-event surface (zIndex 25) so wrapper-level
    handleZoneMouseDown becomes reachable in edit mode. Wrapper stays
    pointerEvents:none and iframe stays pointerEvents:auto to preserve
    text-edit reachability (A8 guardrail). Resize handles (z-30) win in
    overlap regions. Iframe pointer-events temporarily forced none during
    drag to prevent mouseup leak.
u2: Edit-mode isSelected branch reuses selectedZoneId with emerald visual
    (border-emerald-500 / bg-emerald-500/10) distinct from pendingLayout
    blue, decorative-only (pointerEvents:none inherits via wrapper rules).
u3: Pure drag math extracted to slideCanvasDragMath.ts — DRAG_THRESHOLD_PX,
    crossedDragThreshold(dx, dy) strict Math.hypot > 5, and clampZoneMove
    pixel→fraction conversion with x∈[0, 1-w] / y∈[0, 1-h] clamp.
    Resize math (makeResizeHandler) untouched.
u4: Vitest coverage (12 tests, 3 describe blocks) on the pure helper:
    threshold strict boundary at (3,4)/(5,0)/(0,5), above-threshold,
    negative-symmetric, clamp negative→0, max-edge → 1-w / 1-h, per-axis
    independence, non-square 500×250 slide-body, return-shape {x,y} only.

Stage 4 verify: pnpm exec vitest run client/src/components/slideCanvasDragMath.test.ts → 12/12 PASS.
Scope: edit-mode UX only. No HTML text modification, no automatic frame swap, no MDX touched.
Depends on: #9 IMP-09 (--override-zone-geometry backend wire), #80 IMP-52 (user_overrides.json zone_geometries persistence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:35:34 +09:00
KyeongminandClaude Opus 4.7 9388e25e76 feat(#80): IMP-52 user_overrides.json persistence (u1~u10 backend + frontend + tests)
4-axis MDX-stem keyed persistence so layout / zone_geometries / zone_sections / frames
survive across `/api/run` sessions. Auto-restore on MDX reopen; CLI > file precedence
on backend pipeline entry; 300ms-debounced PUT flushed before Generate.

u1 src/user_overrides_io.py — load/save/validate_key (MDX-stem regex), 4-axis schema,
  miss={}, corrupt warning+{}, atomic tmp+rename, foreign-key preserve.
u2 src/phase_z2_pipeline.py — post-argparse fallback fills only missing axes.
u3 Front/vite.config.ts — GET /api/user-overrides/:key (200 {} on miss, 400 traversal).
u4 Front/vite.config.ts — PUT /api/user-overrides/:key, 4-axis allowlist, partial merge.
u5 Front/client/src/services/userOverridesApi.ts — typed get/save + flushUserOverrides
  with 300ms debounce and mutated-axis partial payloads.
u6 Front/client/src/pages/Home.tsx + slidePlanUtils.ts — restore on MDX upload (non-frame
  axes immediately, frames remapped post-loadRun unit_id → region.id).
u7 Home.tsx — persist on 4 mutation handlers (section drop, layout select, zone resize,
  frame select); zone_sizes and Generate excluded.
u8 tests/test_user_overrides_io.py — round-trip, unknown-key passthrough, missing/corrupt,
  invalid keys (26 tests).
u9 tests/test_user_overrides_pipeline_fallback.py — per-axis fill, CLI-wins, no-file noop,
  corrupt warning+skip (16 tests).
u10 Home.tsx + user_overrides_write.test.ts — await flushUserOverrides() before runPipeline
  in handleGenerate try-block head; source-pattern regression assertions (20 → 22 tests).

Backend pytest 42/42 green. Frontend vitest 113/113 green (endpoint 42 / restore 21 /
service 28 / write 22). HEAD baseline ee97f4f; no spillover to phase_z2 templates /
families / frames / pipeline orchestration outside the IMP-52 surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:47:11 +09:00
KyeongminandClaude Opus 4.7 ee97f4fc78 feat(#77): IMP-48 composition planner re-split on all-reject (u1~u9)
Add resplit_all_reject_merges() helper in phase_z2_composition.py that
detects parent_merged / parent_merged_inferred units with label=reject
and rebuilds them as per-section single units using each section's own
rank-1 V4 evidence (no frame swap, MDX raw_content preserved).

Pipeline hook fires once after Step 6 settling chain (u12/u4/empty-shell)
and section_assignment_plan resolution, before Step 6 artifact write.
Guards: beneficial-split rule (>=1 non-reject), coverage equality, layout
cap (>4 abort), max_retry=1, section_assignment_override short-circuit.

Audit: comp_debug["imp48_resplit"] additive payload (applied, split_units,
skipped_units, post_split_unit_count, post_split_layout_preset);
selection_path="resplit_from_merge" telemetry on rebuilt singles;
layout_preset re-derived via select_layout_preset(new_units).

Tests: 39/39 PASS (composition u1~u6: 14 cases; pipeline u7~u9: 25 cases).
Scoped regression 720/6 with 6 failures isolated as pre-existing on
baseline 79f9ea5 (independent of IMP-48). mdx03 golden lock preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 05:00:07 +09:00
KyeongminandClaude Opus 4.7 79f9ea5c92 feat(#78): IMP-49 dx_sw_necessity partial Figma provenance fix (u1~u3)
Replace eyeballed PROMOTED green hex (#296B55, #123328) with verbatim
upstream values from figma_to_html_agent/blocks/1171281198/index.html:
- border + check mark: #1d4d3e (upstream :208 -webkit-text-stroke)
- header gradient: rgb(15, 50, 30) / rgb(60, 52, 34) (upstream :54, :64)

Document .f20b__* as authoring-ordinal namespace (NOT Figma frame_id
1171281198); structural link via data-frame-id attribute. No selector
rename, no catalog edit.

Add focused regression test (tests/test_imp49_partial_figma_provenance.py)
extracting <style>-block hex/rgb/rgba literals and asserting non-whitelisted
literals exist byte-identically in upstream source. Whitelist limited to
neutrals (#fff, #1a1a1a) + shared zone-title token (#000, #883700,
rgba(50,44,30,0.4)).

Scope: dx_sw_necessity_three_perspectives.html only. 19 missing partials,
.fNb__ rename, full 32-contract audit deferred to follow-up axes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:49:43 +09:00
KyeongminandClaude Opus 4.7 2ef02f5f18 feat(#76): IMP-47B u11 frontend human_review surfacing (hunk-split from IMP-41)
- AiRepairStatus interface mirrors backend step20 u8 schema
- formatAiRepairHumanReviewMessage(): pure helper for the three failure axes
  (error / coverage_violated / unsupported_kind) — null on success/no-AI
- Home.tsx: toast.error(aiReviewMsg) after run completion
- FramePanel.tsx: reject-click window.confirm guard ("frame 유지 + AI 재구성")
- imp47b_human_review_toast.test.tsx: 6 vitest cases (null/false/3 axes/other)

Verification (frontend node_modules junction from main worktree):
- vitest imp47b_human_review_toast.test.tsx: 6/6 passed
- vitest full suite: 19/19 passed (imp41_application_mode 13 + u11 6, zero regression)

Hunk-split rationale:
- stash@{0} (imp47b-frontend-u11-pre-rebase, captured before IMP-41 merged)
  contained inline IMP-41 helpers alongside u11 changes
- HEAD already has IMP-41 helper-based implementation (buildBadgeTitle /
  mergeApplicationCandidates from services/applicationMode.ts, f358604)
- This commit adds ONLY the u11 surface on top of HEAD's IMP-41 baseline
- No IMP-41 hunk regression: buildBadgeTitle / mergeApplicationCandidates /
  applicationMode forwarding preserved verbatim

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 00:34:32 +09:00
KyeongminandClaude Opus 4.7 1186ad8ae2 feat(#76): IMP-47B reject-as-AI-adaptation activation (u1~u13 backend + tests)
- u1~u9: AI fallback infrastructure (router/prompts/schema/validator) + Step 12 hook
- u10: e2e reject chain (writes final.html with AI-repaired slot, full coverage)
- u11: frontend wiring deferred to follow-up commit (split from IMP-41 hunks)
- u12: coverage_invariant guard
- u13: cache save gate (visual_check PASS + user_approved/auto_cache) — Codex #22 verified

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 00:19:10 +09:00
KyeongminandClaude Opus 4.7 f358604fb3 feat(#70): IMP-41 application_mode forwarding to FramePanel V4 badge tooltip (u1~u5)
Forward backend Step 9 `unit.application_candidates[]` (application_mode /
auto_applicable / delegated_to) onto FrameCandidate and surface the
application_mode as a Korean consequence phrase in the FramePanel V4-label
inline badge tooltip. Deterministic frontend-only refactor; no LLM call,
no V4-label color change, no outer composedTitle change.

u1: types/designAgent.ts — add optional applicationMode / autoApplicable /
    delegatedTo on FrameCandidate (legacy fixtures keep undefined).
u2: services/applicationMode.ts (new) — pure helper exporting
    ApplicationMode union, APPLICATION_MODE_TOOLTIP_KR (keyed by backend
    mode VALUE, NOT V4 label), buildBadgeTitle, mergeApplicationCandidates.
u3: tests/imp41_application_mode.test.ts (new) — 13 Vitest cases pinning
    composite output per mode, undefined/unknown→legacy fallback, merge by
    template_id, skip missing/empty/non-string keys, first-wins on dupes,
    empty/null/non-array input.
u4: services/designAgentApi.ts — bridge consumes mergeApplicationCandidates
    and forwards three fields onto FrameCandidate while preserving
    LABEL_PRIORITY sort and TOP_N_FRAMES slicing.
u5: components/FramePanel.tsx — V4-label badge `title` now calls
    `buildBadgeTitle(candidate.label, candidate.applicationMode)`;
    badge color className map preserved verbatim; outer composedTitle
    untouched.

Scope-qualified verification (5 files, IMP-41 axis only):
- Vitest: client/tests/imp41_application_mode.test.ts — 13/13 PASS.
- Diff↔Plan parity: 5 files match Stage 2 plan, no scope creep.
- AI-isolation contract honored: tooltip values originate from backend
  enum; no frontend re-derivation from V4 label.
- No spacing/font shrink; clipping resolution stays at layout/zone/frame
  layer (feedback_phase_z_spacing_direction).

Pre-existing unrelated diagnostics (BottomActions.tsx,
imp47b_human_review_toast.test.tsx) remain open on their own axes and are
not gated by this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:17:32 +09:00
71 changed files with 16763 additions and 216 deletions
+24 -48
View File
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
import { motion } from 'framer-motion';
import type { Zone, InternalRegion, UserSelection, FrameCandidate, SlidePlan } from '../types/designAgent';
import { getSectionsForZone } from '../utils/slidePlanUtils';
import { buildBadgeTitle } from '../services/applicationMode';
interface FramePanelProps {
slidePlan: SlidePlan | null;
@@ -19,20 +20,6 @@ interface FramePanelProps {
onNoDesignToggle: () => void;
}
// ─── IMP-41 u3 — application_mode consequence tooltip map (issue #70) ────────
// Keyed by application_mode VALUE (backend authoritative), NOT V4 label.
// Source = src/phase_z2_pipeline.py APPLICATION_MODE_BY_V4_LABEL (:107-112)
// emitted via Step 9 unit.application_candidates[] and forwarded by
// designAgentApi.ts (IMP-41 u2). When applicationMode is absent (legacy
// fixtures pre-IMP-32, or candidate filtered out at Step 9) the tooltip
// falls back to the raw V4 label string per Stage 2 contract.
const APPLICATION_MODE_TOOLTIP_KR: Record<string, string> = {
direct_insert: "코드 직접 적용",
same_frame_with_adjustment: "AI 보강 필요",
layout_or_region_change: "AI restructure 필요",
exclude: "render path 제외",
};
export default function FramePanel({
slidePlan,
selectedZone,
@@ -60,11 +47,6 @@ export default function FramePanel({
return userSelection.overrides.zone_frames[targetRegion.id] || targetRegion.frame_match_strategy.frame_id;
}, [selectedZone, selectedRegion, userSelection.overrides.zone_frames]);
// IMP-47B u11 — reject-click confirm guard. Per #76 policy: 사용자가 reject
// 카드 명시 클릭 → backend `--override-frame` 전달 + reject frame 유지 + AI 재구성.
// The window.confirm makes the AI-rebuild intent explicit (deselecting an
// already-applied reject frame does not prompt). Pure UX gate — no state
// mutation here; the parent `onFrameSelect` still owns the override apply.
const handleFrameSelect = React.useCallback(
(candidate: FrameCandidate) => {
const isReject = candidate.label === "reject";
@@ -269,35 +251,29 @@ export default function FramePanel({
</span>
)}
{/* V4 label badge */}
{candidate.label && (() => {
// IMP-41 u3 — applicationMode-keyed Korean consequence
// tooltip with legacy fallback. applicationMode is
// forwarded by designAgentApi.ts (u2) from Step 9
// unit.application_candidates[]; undefined when the
// backend did not emit a mapping for this candidate.
const consequence = candidate.applicationMode
? APPLICATION_MODE_TOOLTIP_KR[candidate.applicationMode]
: undefined;
const badgeTitle = consequence
? `${consequence} (${candidate.applicationMode})`
: `V4 label: ${candidate.label}`;
return (
<span
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
candidate.label === "use_as_is"
? "bg-emerald-100 text-emerald-700"
: candidate.label === "light_edit"
? "bg-blue-100 text-blue-700"
: candidate.label === "restructure"
? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700"
}`}
title={badgeTitle}
>
{candidate.label}
</span>
);
})()}
{/* IMP-41 u5 — tooltip delegated to pure helper
`buildBadgeTitle` (services/applicationMode.ts).
applicationMode is forwarded by designAgentApi.ts
(u4) from Step 9 unit.application_candidates[];
helper falls back to the raw V4 label when the
mode is undefined or unknown. Badge color mapping
is intentionally untouched per Stage 2 scope. */}
{candidate.label && (
<span
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
candidate.label === "use_as_is"
? "bg-emerald-100 text-emerald-700"
: candidate.label === "light_edit"
? "bg-blue-100 text-blue-700"
: candidate.label === "restructure"
? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700"
}`}
title={buildBadgeTitle(candidate.label, candidate.applicationMode)}
>
{candidate.label}
</span>
)}
{/* IMP-29 u3 — route hint chip (skip when direct_render = default). */}
{showRouteChip && (
<span
+386 -19
View File
@@ -21,6 +21,14 @@ import type {
UserSelection,
NormalizedContent,
} from "../types/designAgent";
import {
IMAGE_RESIZE_MIN_SIZE_PERCENT,
clampImagePercentGeometry,
clampZoneMove,
crossedDragThreshold,
type ImageDragDirection,
} from "./slideCanvasDragMath";
import type { ImageOverridesOverride } from "../services/userOverridesApi";
interface SlideCanvasProps {
slidePlan: SlidePlan | null;
@@ -51,6 +59,24 @@ interface SlideCanvasProps {
onZoneResize?: (
geometries: Record<string, { x: number; y: number; w: number; h: number }>
) => void;
/** IMP-51 (#79) u8 — persisted slide-absolute image geometries
* (image_id → {x,y,w,h} as percent of 1280×720, range 0100). Mirrors
* the u3 typed-client `ImageOverride` contract and the u7 stamper that
* emits CSS `left/top/width/height: {value}%`. Forward-compat optional;
* u11 wires this from `userSelection.overrides.image_overrides`. When
* present, SlideCanvas displays the persisted geometry instead of the
* iframe-measured baseline. */
imageOverrides?: ImageOverridesOverride;
/** IMP-51 (#79) u8 — emitted when the user drags or resizes a stamped
* user-content image. Geometry is slide-absolute percent (0100 of
* 1280×720), matching the persisted axis schema (u3 typed client) and
* the u7 CSS injection that writes the values directly into
* `left/top/width/height: {value}%`. u10 wires this to a persistence
* handler that updates `image_overrides` on user_overrides.json. */
onImageResize?: (
imageId: string,
geometry: { x: number; y: number; w: number; h: number }
) => void;
}
const SLIDE_W = 1280;
@@ -70,6 +96,8 @@ export default function SlideCanvas({
onSlideClick,
onSectionDrop,
onZoneResize,
imageOverrides,
onImageResize,
}: SlideCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
@@ -91,6 +119,24 @@ export default function SlideCanvas({
// Step B : section drag-drop drop target. 사용자가 LeftMdxPanel 의 section 카드
// 를 drag 해서 zone 에 drop 시 그 zone 에 section 할당. dragOver 시 강조 표시.
const [dragOverZoneId, setDragOverZoneId] = useState<string | null>(null);
// IMP-51 (#79) u8 — measured user-content image bboxes inside iframe
// (slide-absolute percent of 1280×720, range 0100). key = data-image-id
// stamped by u4 (`src/image_id_stamper.py`). Populated in the iframe
// onLoad measure block alongside measuredZones / measuredSlideBody.
// Units intentionally match the persisted `image_overrides` axis (u3
// typed client) and the u7 CSS injection so the overlay math has a
// single coord space across measured/persisted/emitted values. Used as
// the baseline geometry when no persisted override exists for that id;
// `imageOverrides` prop (u11-fed) wins when present.
const [measuredImages, setMeasuredImages] = useState<
Record<string, { x: number; y: number; w: number; h: number }>
>({});
// IMP-51 (#79) u8 — currently selected user-content image id (= the one
// whose drag/resize handles are shown). Set by the click-listener
// installed inside the iframe contentDocument when edit mode is active.
// Reset on finalHtmlUrl change and on edit-mode exit so stale ids never
// leak across runs.
const [selectedImageId, setSelectedImageId] = useState<string | null>(null);
// HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용.
// 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업.
// pendingLayout 과 배타적 (충돌 방지).
@@ -118,6 +164,10 @@ export default function SlideCanvas({
const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"];
let inputHandler: ((e: Event) => void) | null = null;
// IMP-51 (#79) u8 — user-content image click listeners installed
// inside the iframe contentDocument. Tracked here so the cleanup
// callback can remove them when edit mode exits (or iframe reloads).
const imageClickBindings: Array<{ el: HTMLImageElement; handler: (e: Event) => void; prevCursor: string; prevOutline: string }> = [];
if (isEditMode) {
doc.designMode = "on";
doc.querySelectorAll(".slide *").forEach((el) => {
@@ -130,17 +180,49 @@ export default function SlideCanvas({
onContentEdit?.();
};
doc.addEventListener("input", inputHandler);
// IMP-51 (#79) u8 — wire click → selectedImageId on every stamped
// user-content image. Selector mirrors USER_CONTENT_IMAGE_SELECTOR
// in src/image_id_stamper.py (+ requires data-image-id which the
// stamper always emits). Decorative / frame imgs lacking the role
// attribute are intentionally NOT clickable here.
const imgEls = doc.querySelectorAll<HTMLImageElement>(
'.slide img[data-image-role="user-content"][data-image-id]'
);
imgEls.forEach((imgEl) => {
const imgId = imgEl.dataset.imageId;
if (!imgId) return;
const handler = (ev: Event) => {
ev.stopPropagation();
ev.preventDefault();
setSelectedImageId(imgId);
};
const prevCursor = imgEl.style.cursor;
const prevOutline = imgEl.style.outline;
imgEl.style.cursor = "pointer";
imgEl.style.outline = "1px dashed rgba(16, 185, 129, 0.55)";
imgEl.addEventListener("click", handler);
imageClickBindings.push({ el: imgEl, handler, prevCursor, prevOutline });
});
} else {
doc.designMode = "off";
doc.querySelectorAll("[contenteditable]").forEach((el) => {
(el as HTMLElement).removeAttribute("contenteditable");
});
// edit-mode exit also clears stale image selection so the handle
// overlay never lingers on a non-editable iframe.
setSelectedImageId(null);
}
return () => {
if (inputHandler && doc) {
doc.removeEventListener("input", inputHandler);
}
imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => {
el.removeEventListener("click", handler);
el.style.cursor = prevCursor;
el.style.outline = prevOutline;
});
};
}, [isEditMode, finalHtmlUrl, onContentEdit]);
@@ -154,6 +236,10 @@ export default function SlideCanvas({
useEffect(() => {
setMeasuredZones({});
setMeasuredSlideBody(null);
// IMP-51 (#79) u8 — image measurements + selection are per-render;
// drop both so the new iframe's onLoad starts clean.
setMeasuredImages({});
setSelectedImageId(null);
}, [finalHtmlUrl]);
// 16:9 비율 유지하며 컨테이너에 통째로 fit (스크롤 X).
@@ -351,6 +437,33 @@ export default function SlideCanvas({
h: r.height / SLIDE_H,
});
}
// ── IMP-51 (#79) u8 — user-content image bbox 측정 ──
// u4 stamper 가 부착한 data-image-id 가 있는 img 만 잡음
// (decorative / frame img 제외). 측정 결과는 1280×720 기준
// 슬라이드-절대 percent (0100) — image_overrides axis (u3
// 타입 + u7 CSS `left/top/width/height: {value}%` 주입) 와
// 동일한 좌표계라서 측정 / 영구 저장 / emit 가 1:1 매칭됨.
const imageEls = doc.querySelectorAll<HTMLImageElement>(
'.slide img[data-image-role="user-content"][data-image-id]'
);
const measuredImg: Record<
string,
{ x: number; y: number; w: number; h: number }
> = {};
imageEls.forEach((imgEl) => {
const id = imgEl.dataset.imageId;
if (!id) return;
const r = imgEl.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return;
measuredImg[id] = {
x: (r.left / SLIDE_W) * 100,
y: (r.top / SLIDE_H) * 100,
w: (r.width / SLIDE_W) * 100,
h: (r.height / SLIDE_H) * 100,
};
});
setMeasuredImages(measuredImg);
} catch (err) {
console.warn("[SlideCanvas] iframe inject/measure 실패:", err);
}
@@ -465,10 +578,9 @@ export default function SlideCanvas({
const makeResizeHandler = (
direction: ResizeDir
) => (ev: React.MouseEvent<HTMLDivElement>) => {
// resize 는 pendingLayout 모드에서만 — 첫 초안 (normal) 과 편집 모드에서는
// frame HTML 이 reflow 못 해서 의미 없음. layout 변경 후 빈 layout 에서만
// zone 자유 배치.
if (!isPendingLayout || !onZoneResize) return;
// resize 는 pendingLayout OR 편집 모드 활성. 2026-05-22 demo hot-fix —
// frame partial 에 @container aspect-ratio 회전이 들어가서 fixed px 제약 사라짐.
if ((!isPendingLayout && !isEditMode) || !onZoneResize) return;
if (!measuredSlideBody) return;
ev.preventDefault();
ev.stopPropagation();
@@ -485,6 +597,12 @@ export default function SlideCanvas({
const affectsTop = direction === "top" || direction === "nw" || direction === "ne";
const affectsBottom = direction === "bottom" || direction === "sw" || direction === "se";
// 2026-05-22 demo hot-fix — iframe 이 마우스 가로채서 mouseup leak 일어남
// (편집 모드에서 iframe pointerEvents=auto). drag 동안 iframe 강제 none.
const iframeEl = iframeRef.current;
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
if (iframeEl) iframeEl.style.pointerEvents = "none";
const onMove = (mv: MouseEvent) => {
const dx = (mv.clientX - startMouseX) / slideBodyWidthPx;
const dy = (mv.clientY - startMouseY) / slideBodyHeightPx;
@@ -511,6 +629,7 @@ export default function SlideCanvas({
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
@@ -532,7 +651,7 @@ export default function SlideCanvas({
ev: React.MouseEvent<HTMLDivElement>
) => {
ev.stopPropagation();
const canDrag = !!(isPendingLayout && measuredSlideBody && onZoneResize);
const canDrag = !!((isPendingLayout || isEditMode) && measuredSlideBody && onZoneResize);
const startMouseX = ev.clientX;
const startMouseY = ev.clientY;
const startGeom = { ...localGeom };
@@ -543,25 +662,26 @@ export default function SlideCanvas({
? H_SCALED * measuredSlideBody!.h
: 1;
let dragged = false;
const dragThresholdPx = 5;
// 2026-05-22 demo hot-fix — same iframe pointer-events fix as makeResizeHandler.
const iframeEl = iframeRef.current;
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
if (iframeEl) iframeEl.style.pointerEvents = "none";
const onMove = (mv: MouseEvent) => {
if (!canDrag) return;
const dxPx = mv.clientX - startMouseX;
const dyPx = mv.clientY - startMouseY;
if (!dragged && Math.hypot(dxPx, dyPx) > dragThresholdPx) {
if (!dragged && crossedDragThreshold(dxPx, dyPx)) {
dragged = true;
}
if (dragged) {
const dx = dxPx / slideBodyWidthPx;
const dy = dyPx / slideBodyHeightPx;
const newX = Math.max(
0,
Math.min(1 - startGeom.w, startGeom.x + dx)
);
const newY = Math.max(
0,
Math.min(1 - startGeom.h, startGeom.y + dy)
const { x: newX, y: newY } = clampZoneMove(
startGeom,
dxPx,
dyPx,
slideBodyWidthPx,
slideBodyHeightPx
);
onZoneResize!({
[zone.zone_id]: {
@@ -576,6 +696,7 @@ export default function SlideCanvas({
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
if (!dragged) {
// 단순 click 으로 처리 — onZoneClick.
onZoneClick?.(zone.id);
@@ -671,6 +792,8 @@ export default function SlideCanvas({
} ${
isDragOver
? "border-4 border-emerald-500 bg-emerald-100/30 shadow-[0_0_0_4px_rgba(16,185,129,0.3)]"
: isSelected && isEditMode
? "border-2 border-emerald-500 bg-emerald-500/10 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
: isSelected && !isEditMode
? "border-2 border-blue-500 bg-blue-500/10 shadow-[0_0_0_2px_rgba(59,130,246,0.2)]"
: !isEditMode
@@ -747,11 +870,12 @@ export default function SlideCanvas({
</div>
)}
{/* Step C : zone resize handles — 8 방향. pendingLayout 모드 활성
(frame html 의 fixed px 디자인 한계로 첫 초안 / 편집 모드 resize 의미 X).
{/* Step C : zone resize handles — 8 방향. pendingLayout OR 편집 모드 활성.
2026-05-22 demo hot-fix — frame partial 에 @container aspect-ratio 회전
들어간 후 fixed px 제약 사라져 편집 모드 resize 도 의미 있음.
edge handle (top/bottom/left/right) : 한 boundary 이동
corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
{isPendingLayout && onZoneResize && (
{(isPendingLayout || isEditMode) && onZoneResize && (
<>
{/* top edge */}
<div
@@ -819,9 +943,252 @@ export default function SlideCanvas({
/>
</>
)}
{/* IMP-54 u1: edit-mode body-drag gesture surfaces.
wrapper sets pointerEvents:none in edit mode (see above) to
preserve iframe text-edit clicks (A8 guardrail), so the
wrapper-level handleZoneMouseDown is unreachable in edit mode.
These 4 perimeter strips + top-left grip provide a separate
pointer-event surface routing into handleZoneMouseDown.
zIndex 25 sits BELOW the 8 resize handles (z-30) so resize
gesture wins in overlap regions, and ABOVE the iframe so the
strips intercept the perimeter while the un-covered iframe
interior keeps text-edit reachability intact.
pendingLayout mode already has wrapper pointerEvents:auto,
so these surfaces are only needed in edit mode. */}
{isEditMode && !isPendingLayout && onZoneResize && (
<>
<div
onMouseDown={handleZoneMouseDown}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
style={{ pointerEvents: "auto", zIndex: 25 }}
title="zone 이동 — 드래그"
/>
<div
onMouseDown={handleZoneMouseDown}
onClick={(ev) => ev.stopPropagation()}
className="absolute bottom-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
style={{ pointerEvents: "auto", zIndex: 25 }}
title="zone 이동 — 드래그"
/>
<div
onMouseDown={handleZoneMouseDown}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-0 left-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
style={{ pointerEvents: "auto", zIndex: 25 }}
title="zone 이동 — 드래그"
/>
<div
onMouseDown={handleZoneMouseDown}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-0 right-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
style={{ pointerEvents: "auto", zIndex: 25 }}
title="zone 이동 — 드래그"
/>
{/* visible grip affordance — placed below the section label
(top-1 left-1 container) so the two don't overlap. */}
<div
onMouseDown={handleZoneMouseDown}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-7 left-1 w-3 h-3 bg-emerald-500/70 border border-emerald-700 rounded-full cursor-grab active:cursor-grabbing shadow hover:scale-125 transition"
style={{ pointerEvents: "auto", zIndex: 25 }}
title="zone 이동 — 드래그하여 위치 변경"
/>
</>
)}
</div>
);
})}
{/* ── IMP-51 (#79) u8 — user-content image edit overlay ──
Activates only in edit mode when an image_id appears in either
`imageOverrides` (u11-fed persisted axis) or `measuredImages`
(iframe-measured baseline). pendingLayout suppresses the image
overlay so zone editing and image editing never compete for the
same pointer events.
For every stamped user-content image we render a transparent
wrapper at the image's slide-absolute coords. Wrapper picks up
the body-drag gesture (move the image without resizing). When
the image is the `selectedImageId` we additionally render 8
resize handles. Aspect ratio is LOCKED on corner drags by
default; holding Shift during the drag unlocks it (matches the
issue contract "corner_resize_ratio_default_locked_shift_unlock").
Coordinate space: slide-absolute percent (0100) throughout —
measured / persisted / emitted values share the same units as
the u7 CSS injector (`left/top/width/height: {value}%`) and the
u3 typed-client `ImageOverride` contract. CSS values are
written verbatim ({geom.x}%, no scale factor) and pixel deltas
from MouseEvent are converted to percent via
`(dx_px / W_SCALED) * 100` so the round-trip drag → save →
re-render produces identical geometry. IMP-51 (#79) u9 moved
the resize / move math to `clampImagePercentGeometry` in
`slideCanvasDragMath.ts` so the boundary contract Codex #16
verified is exercised directly by vitest (mirror of how IMP-54
u3 split the zone math out of SlideCanvas). */}
{!isPendingLayout && isEditMode && finalHtmlUrl && onImageResize &&
Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map(
([imageId]) => {
const persisted = imageOverrides?.[imageId];
const measured = measuredImages[imageId];
// override 우선; 없으면 measured baseline. 둘 다 없으면 skip.
const geom = persisted ?? measured;
if (!geom) return null;
const isSelected = selectedImageId === imageId;
const beginDrag = (
ev: React.MouseEvent<HTMLDivElement>,
direction: ImageDragDirection
) => {
ev.preventDefault();
ev.stopPropagation();
setSelectedImageId(imageId);
const startMouseX = ev.clientX;
const startMouseY = ev.clientY;
const startGeom = { ...geom };
// 2026-05-22 demo hot-fix parity — iframe 이 마우스 가로
// 채서 mouseup leak 일어남 (편집 모드에서 pe=auto).
const iframeEl = iframeRef.current;
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
if (iframeEl) iframeEl.style.pointerEvents = "none";
const isCorner =
direction === "nw" ||
direction === "ne" ||
direction === "sw" ||
direction === "se";
const onMove = (mv: MouseEvent) => {
// Convert pixel delta on the on-screen scaled slide
// back into percent-of-slide so all downstream math
// shares the persisted axis's coord space. W_SCALED /
// H_SCALED already include the wrapper scale factor,
// so dividing then multiplying by 100 gives a stable
// value regardless of viewport zoom.
const dx = ((mv.clientX - startMouseX) / W_SCALED) * 100;
const dy = ((mv.clientY - startMouseY) / H_SCALED) * 100;
// IMP-51 (#79) u9 — boundary contract lives in the
// pure helper so vitest can verify it directly.
// Aspect lock is default on for corner handles and
// released when Shift is held.
const aspectLocked = isCorner && !mv.shiftKey;
const next = clampImagePercentGeometry(
startGeom,
dx,
dy,
direction,
aspectLocked,
IMAGE_RESIZE_MIN_SIZE_PERCENT,
);
onImageResize(imageId, next);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
return (
<div
key={`img-overlay-${imageId}`}
role="button"
tabIndex={0}
data-image-overlay-id={imageId}
onMouseDown={(ev) => beginDrag(ev, "move")}
className={`absolute z-30 ${
isSelected
? "border-2 border-emerald-500 bg-emerald-500/5 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
: "border border-dashed border-emerald-400/60 hover:border-emerald-500"
} cursor-grab active:cursor-grabbing`}
style={{
left: `${geom.x}%`,
top: `${geom.y}%`,
width: `${geom.w}%`,
height: `${geom.h}%`,
pointerEvents: "auto",
}}
title={
isSelected
? "이미지 이동 — 드래그 / 모서리 핸들 = 크기 (Shift = 비율 해제)"
: "클릭하여 선택"
}
>
<span className="absolute top-1 left-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-emerald-600/90 text-white shadow pointer-events-none">
IMG
</span>
{isSelected && (
<>
{/* edges */}
<div
onMouseDown={(ev) => beginDrag(ev, "top")}
onClick={(ev) => ev.stopPropagation()}
className="absolute -top-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
style={{ pointerEvents: "auto" }}
title="상단"
/>
<div
onMouseDown={(ev) => beginDrag(ev, "bottom")}
onClick={(ev) => ev.stopPropagation()}
className="absolute -bottom-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
style={{ pointerEvents: "auto" }}
title="하단"
/>
<div
onMouseDown={(ev) => beginDrag(ev, "left")}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-1/4 -left-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
style={{ pointerEvents: "auto" }}
title="좌측"
/>
<div
onMouseDown={(ev) => beginDrag(ev, "right")}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-1/4 -right-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
style={{ pointerEvents: "auto" }}
title="우측"
/>
{/* corners — aspect locked by default, Shift unlocks */}
<div
onMouseDown={(ev) => beginDrag(ev, "nw")}
onClick={(ev) => ev.stopPropagation()}
className="absolute -top-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="좌상단 (Shift = 비율 해제)"
/>
<div
onMouseDown={(ev) => beginDrag(ev, "ne")}
onClick={(ev) => ev.stopPropagation()}
className="absolute -top-1 -right-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="우상단 (Shift = 비율 해제)"
/>
<div
onMouseDown={(ev) => beginDrag(ev, "sw")}
onClick={(ev) => ev.stopPropagation()}
className="absolute -bottom-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="좌하단 (Shift = 비율 해제)"
/>
<div
onMouseDown={(ev) => beginDrag(ev, "se")}
onClick={(ev) => ev.stopPropagation()}
className="absolute -bottom-1 -right-1 w-4 h-4 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="우하단 (Shift = 비율 해제)"
/>
</>
)}
</div>
);
}
)}
</div>
)}
</div>
@@ -0,0 +1,225 @@
// IMP-54 u4 — vitest coverage for the pure drag-math helpers extracted in u3
// (`Front/client/src/components/slideCanvasDragMath.ts`).
//
// Stage 2 contract (`Stage 2 Exit Report → implementation_units → u4`):
// • Threshold pass/fail at 5 px (strict `Math.hypot > 5`).
// • Clamp negative delta to 0 on both axes.
// • Clamp max-edge delta to `1 - startGeom.w` (x) and `1 - startGeom.h` (y).
//
// The helpers are pure (no React, no DOM) so we drive them directly with
// numeric inputs — no fake timers, no fetch stubs, no component mount.
import { describe, expect, it } from "vitest";
import {
DRAG_THRESHOLD_PX,
IMAGE_RESIZE_MIN_SIZE_PERCENT,
clampImagePercentGeometry,
clampZoneMove,
crossedDragThreshold,
type ImagePercentGeom,
type ZoneFracGeom,
} from "./slideCanvasDragMath";
describe("DRAG_THRESHOLD_PX", () => {
it("is 5", () => {
expect(DRAG_THRESHOLD_PX).toBe(5);
});
});
describe("crossedDragThreshold", () => {
it("returns false for zero movement (still a click)", () => {
expect(crossedDragThreshold(0, 0)).toBe(false);
});
it("returns false just below threshold — 3,4 → hypot 5 with strict >", () => {
expect(crossedDragThreshold(3, 4)).toBe(false);
});
it("returns false at exactly the threshold along each axis", () => {
// strict inequality: Math.hypot(5, 0) === 5, not > 5
expect(crossedDragThreshold(5, 0)).toBe(false);
expect(crossedDragThreshold(0, 5)).toBe(false);
});
it("returns true once distance exceeds threshold", () => {
expect(crossedDragThreshold(4, 4)).toBe(true); // hypot ≈ 5.6568
expect(crossedDragThreshold(6, 0)).toBe(true);
expect(crossedDragThreshold(0, 6)).toBe(true);
});
it("treats negative deltas symmetrically (Euclidean distance)", () => {
expect(crossedDragThreshold(-3, -4)).toBe(false);
expect(crossedDragThreshold(-4, -4)).toBe(true);
expect(crossedDragThreshold(-6, 0)).toBe(true);
});
});
describe("clampZoneMove", () => {
// 1000 × 1000 slide body so 1 px == 0.001 frac — keeps the arithmetic
// exact and the boundary deltas (1000 px) round-trip back to `1 - w/h`.
const W = 1000;
const H = 1000;
const baseGeom: ZoneFracGeom = { x: 0.1, y: 0.2, w: 0.3, h: 0.4 };
it("applies in-bounds delta as startGeom + (dPx / slideBodySize)", () => {
expect(clampZoneMove(baseGeom, 100, 50, W, H)).toEqual({
x: 0.2,
y: 0.25,
});
});
it("clamps negative delta to 0 on both axes", () => {
expect(clampZoneMove(baseGeom, -1000, -1000, W, H)).toEqual({
x: 0,
y: 0,
});
});
it("clamps max-edge delta to (1 - w) on x and (1 - h) on y", () => {
expect(clampZoneMove(baseGeom, 1000, 1000, W, H)).toEqual({
x: 1 - baseGeom.w, // 0.7
y: 1 - baseGeom.h, // 0.6
});
});
it("clamps the two axes independently (negative x, in-bounds y)", () => {
expect(clampZoneMove(baseGeom, -1000, 50, W, H)).toEqual({
x: 0,
y: 0.25,
});
});
it("honours non-square slide bodies via per-axis division", () => {
// dxPx 100 / 500 = 0.2 fr; dyPx 100 / 250 = 0.4 fr (hits the y boundary).
// x is checked with toBeCloseTo because 0.1 + 0.2 is the canonical IEEE-754
// floating-point trap (0.30000000000000004) — the clamp logic is correct,
// it just inherits JS number precision. y stays exact since it clamps to
// the boundary `1 - h`.
const result = clampZoneMove(baseGeom, 100, 100, 500, 250);
expect(result.x).toBeCloseTo(0.3, 10);
expect(result.y).toBe(1 - baseGeom.h); // 0.6
});
it("returns only { x, y } — width / height are preserved by the caller", () => {
const out = clampZoneMove(baseGeom, 0, 0, W, H);
expect(out).toEqual({ x: 0.1, y: 0.2 });
expect("w" in out).toBe(false);
expect("h" in out).toBe(false);
});
});
// IMP-51 (#79) u9 — image overlay resize / move math.
// Boundary contract (must match the inline u8 math Codex #16 verified):
// • slide-bound invariant — x+w ≤ 100 ∧ y+h ≤ 100 for ALL valid inputs,
// including small-near-edge geoms where the existing minSize floor
// would otherwise have pushed past the slide bound.
// • aspect-locked corner — baseAspect = startGeom.w / startGeom.h is
// preserved exactly; the wFloor uses `min(minSize, maxW, maxH*baseAspect)`
// so a floor application never violates either axis.
// The two concrete Codex #15 reproductions are encoded explicitly below
// so a future regression on the boundary math fails this suite directly.
describe("IMAGE_RESIZE_MIN_SIZE_PERCENT", () => {
it("is 2 (percent of slide bbox)", () => {
expect(IMAGE_RESIZE_MIN_SIZE_PERCENT).toBe(2);
});
});
describe("clampImagePercentGeometry", () => {
const baseGeom: ImagePercentGeom = { x: 10, y: 10, w: 20, h: 10 };
describe("direction = 'move'", () => {
it("translates and clamps both axes; preserves w/h", () => {
expect(
clampImagePercentGeometry(baseGeom, 5, 7, "move", false),
).toEqual({ x: 15, y: 17, w: 20, h: 10 });
});
it("clamps negative deltas to (0, 0)", () => {
expect(
clampImagePercentGeometry(baseGeom, -1000, -1000, "move", false),
).toEqual({ x: 0, y: 0, w: 20, h: 10 });
});
it("clamps max-edge deltas to (100 - w, 100 - h)", () => {
expect(
clampImagePercentGeometry(baseGeom, 1000, 1000, "move", false),
).toEqual({ x: 80, y: 90, w: 20, h: 10 });
});
});
describe("edge resize — independent per-axis clamp", () => {
it("right edge clamps width to 100 - startGeom.x", () => {
const out = clampImagePercentGeometry(baseGeom, 1000, 0, "right", false);
expect(out).toEqual({ x: 10, y: 10, w: 90, h: 10 });
expect(out.x + out.w).toBeLessThanOrEqual(100);
});
it("left drag dx=-100 emits {x:0,y:10,w:30,h:10} (Codex regression)", () => {
// From Codex #15 / #16 verification — ordinary left drag past the
// slide edge should pin x at 0 and grow w by the original x amount.
expect(
clampImagePercentGeometry(baseGeom, -100, 0, "left", false),
).toEqual({ x: 0, y: 10, w: 30, h: 10 });
});
it("near-edge right resize keeps x + w ≤ 100 (Codex #15 reproduction)", () => {
// Pre-fix: minSize=2 floor applied AFTER span clamp would emit
// {x:99, w:2} so x+w=101. Post-fix: floor caps at maxW=1.
const start: ImagePercentGeom = { x: 99, y: 10, w: 0.5, h: 10 };
const out = clampImagePercentGeometry(start, 1, 0, "right", false);
expect(out).toEqual({ x: 99, y: 10, w: 1, h: 10 });
expect(out.x + out.w).toBe(100);
});
it("top/bottom edges are symmetric to left/right", () => {
const bottom = clampImagePercentGeometry(baseGeom, 0, 1000, "bottom", false);
expect(bottom).toEqual({ x: 10, y: 10, w: 20, h: 90 });
const top = clampImagePercentGeometry(baseGeom, 0, -100, "top", false);
expect(top).toEqual({ x: 10, y: 0, w: 20, h: 20 });
});
});
describe("corner resize — aspect locked (default Shift-off)", () => {
it("NW drag dx=-100,dy=-100 emits {x:0,y:5,w:30,h:15} (Codex regression)", () => {
// From Codex #16 verification — aspect-locked NW past the slide
// edge: rightEdge=30, bottomEdge=20, baseAspect=2. Independent
// clamps give x=0,w=30,y=0,h=20. Aspect block then picks the
// limiting axis: newH = 30/2 = 15 (≤20). Re-anchor: y = 20 - 15 = 5.
expect(
clampImagePercentGeometry(baseGeom, -100, -100, "nw", true),
).toEqual({ x: 0, y: 5, w: 30, h: 15 });
});
it("tiny near-corner NE resize stays within bounds (Codex #15 reproduction)", () => {
// Pre-fix: dual-axis minSize floor would emit w=2, h=2 with
// re-anchor pushing x+w past 100. Post-fix: wFloor caps at
// min(2, maxW=1, maxH*baseAspect=1) = 1, so newW=1, newH=1.
const start: ImagePercentGeom = { x: 99, y: 99, w: 0.5, h: 0.5 };
const out = clampImagePercentGeometry(start, 1, -1, "ne", true);
expect(out).toEqual({ x: 99, y: 98.5, w: 1, h: 1 });
expect(out.x + out.w).toBeLessThanOrEqual(100);
expect(out.y + out.h).toBeLessThanOrEqual(100);
});
it("preserves baseAspect exactly when the floor is hit", () => {
// 2:1 aspect ratio (w=20, h=10); large negative drag past edges
// hits wFloor. newW/newH ratio must equal baseAspect.
const out = clampImagePercentGeometry(
baseGeom, -1000, -1000, "nw", true,
);
expect(out.w / out.h).toBeCloseTo(baseGeom.w / baseGeom.h, 10);
});
});
describe("corner resize — Shift unlock (independent edges)", () => {
it("SE without aspect lock degenerates to right + bottom edges", () => {
const corner = clampImagePercentGeometry(baseGeom, 1000, 1000, "se", false);
const sides = clampImagePercentGeometry(
clampImagePercentGeometry(baseGeom, 1000, 0, "right", false),
0, 1000, "bottom", false,
);
expect(corner).toEqual(sides);
});
});
});
@@ -0,0 +1,201 @@
// IMP-54 u3 — pure drag math extracted from SlideCanvas.tsx
// `handleZoneMouseDown` (`Front/client/src/components/SlideCanvas.tsx:537-598`).
//
// Resize math (`makeResizeHandler` at SlideCanvas.tsx:465-523) is intentionally
// NOT touched — it has its own independent geometry model (per-side
// `affectsLeft/Right/Top/Bottom`, `minSize`, `1 - startGeom.x/y` cap) that
// must not regress.
//
// Two responsibilities live here:
//
// 1. Drag-vs-click classification — a pointer must travel more than
// `DRAG_THRESHOLD_PX` (Euclidean distance from the mousedown origin)
// before mousedown→mousemove is treated as a drag. Below the
// threshold the gesture stays a click, which the caller surfaces as
// `onZoneClick(zone.id)` in `onUp`.
//
// 2. Pixel-delta → slide-body fraction conversion plus clamp to keep the
// moved zone fully inside the slide body. Width/height are preserved
// verbatim by this helper — only `x` and `y` move.
//
// Both helpers are pure (no React, no DOM, no side effects) so vitest can
// drive them directly. The numeric contract is the inline behavior that
// existed before the extraction; this file is a relocation, not a behavior
// change.
export const DRAG_THRESHOLD_PX = 5;
// IMP-51 (#79) u9 — image overlay resize / move math extracted from
// SlideCanvas.tsx `beginDrag` onMove (lines 10921219 of the u8 patch).
// Slide-absolute percent coordinate space (0100 on both axes), matching
// the persisted `image_overrides` axis (`src/user_overrides_io.py` u1
// KNOWN_AXES) and the typed client `ImageOverride` shape (`userOverridesApi.ts`
// u3). The math is the contract Codex #16 verified post-u8 — this file
// is a relocation, not a behavior change. SlideCanvas calls it from a
// single hook so future tweaks need to update one place + the vitest
// suite alongside.
export const IMAGE_RESIZE_MIN_SIZE_PERCENT = 2;
/** Image overlay geometry in slide-absolute percent (each component ∈ [0, 100]).
* Mirrors `ImageOverride` from `services/userOverridesApi.ts` (u3) so this
* shape moves end-to-end through stamper → overlay → persisted axis. */
export interface ImagePercentGeom {
x: number;
y: number;
w: number;
h: number;
}
export type ImageDragDirection =
| "move"
| "left"
| "right"
| "top"
| "bottom"
| "nw"
| "ne"
| "sw"
| "se";
/** Apply a percent-space drag delta to `startGeom` per `direction` and clamp.
*
* Contract (must match the inline u8 math Codex #16 verified):
* • `direction === "move"` → translate only; w/h preserved verbatim;
* x/y clamped to `[0, 100 - w]` and `[0, 100 - h]`.
* • Edge handle (`left|right|top|bottom`) → one axis only; opposite
* edge pinned so x+w ≤ 100 and y+h ≤ 100 hold.
* • Corner handle (`nw|ne|sw|se`) with `aspectLocked=false` → two
* independent edges (same per-edge clamp as above).
* • Corner handle with `aspectLocked=true` → preserves
* `baseAspect = startGeom.w / startGeom.h`; the pinned-opposite-corner
* stays fixed; the floored axis is `w` and `h` is re-derived so the
* aspect ratio is exact even at the minSize floor.
*
* `minSize` is best-effort: when the available span (e.g. `100 - startGeom.x`
* for `affectsRight`) is below `minSize`, the floor caps at the span itself
* so the slide-bound invariant (x+w ≤ 100 ∧ y+h ≤ 100) is never violated.
* Pure / deterministic / no DOM access — vitest drives it directly. */
export function clampImagePercentGeometry(
startGeom: ImagePercentGeom,
dxPercent: number,
dyPercent: number,
direction: ImageDragDirection,
aspectLocked: boolean,
minSize: number = IMAGE_RESIZE_MIN_SIZE_PERCENT,
): ImagePercentGeom {
if (direction === "move") {
const x = Math.max(0, Math.min(100 - startGeom.w, startGeom.x + dxPercent));
const y = Math.max(0, Math.min(100 - startGeom.h, startGeom.y + dyPercent));
return { x, y, w: startGeom.w, h: startGeom.h };
}
const affectsLeft =
direction === "left" || direction === "nw" || direction === "sw";
const affectsRight =
direction === "right" || direction === "ne" || direction === "se";
const affectsTop =
direction === "top" || direction === "nw" || direction === "ne";
const affectsBottom =
direction === "bottom" || direction === "sw" || direction === "se";
const isCorner =
direction === "nw" ||
direction === "ne" ||
direction === "sw" ||
direction === "se";
const rightEdge = startGeom.x + startGeom.w;
const bottomEdge = startGeom.y + startGeom.h;
let x = startGeom.x;
let y = startGeom.y;
let w = startGeom.w;
let h = startGeom.h;
if (affectsRight) {
const maxW = 100 - startGeom.x;
const floor = Math.min(minSize, maxW);
w = Math.max(floor, Math.min(maxW, startGeom.w + dxPercent));
}
if (affectsBottom) {
const maxH = 100 - startGeom.y;
const floor = Math.min(minSize, maxH);
h = Math.max(floor, Math.min(maxH, startGeom.h + dyPercent));
}
if (affectsLeft) {
const floor = Math.min(minSize, rightEdge);
x = Math.max(0, Math.min(rightEdge - floor, startGeom.x + dxPercent));
w = rightEdge - x;
}
if (affectsTop) {
const floor = Math.min(minSize, bottomEdge);
y = Math.max(0, Math.min(bottomEdge - floor, startGeom.y + dyPercent));
h = bottomEdge - y;
}
if (isCorner && aspectLocked) {
const baseAspect =
startGeom.w > 0 && startGeom.h > 0 ? startGeom.w / startGeom.h : 1;
if (baseAspect > 0) {
const maxW = affectsLeft ? rightEdge : 100 - startGeom.x;
const maxH = affectsTop ? bottomEdge : 100 - startGeom.y;
let newW = w;
let newH = newW / baseAspect;
if (newH > maxH) {
newH = maxH;
newW = newH * baseAspect;
}
if (newW > maxW) {
newW = maxW;
newH = newW / baseAspect;
}
const wFloor = Math.min(minSize, maxW, maxH * baseAspect);
if (newW < wFloor) {
newW = wFloor;
newH = newW / baseAspect;
}
w = newW;
h = newH;
x = affectsLeft ? rightEdge - w : startGeom.x;
y = affectsTop ? bottomEdge - h : startGeom.y;
}
}
return { x, y, w, h };
}
/** Returns true once the pointer has travelled far enough from the mousedown
* origin to be treated as a drag rather than a click. */
export function crossedDragThreshold(dxPx: number, dyPx: number): boolean {
return Math.hypot(dxPx, dyPx) > DRAG_THRESHOLD_PX;
}
/** Zone geometry in slide-body fraction space (each component ∈ [0, 1]).
* Mirrors the shape the SlideCanvas pipeline already uses for
* `localGeom` / `overrideGeom` / `onZoneResize` payloads. */
export interface ZoneFracGeom {
x: number;
y: number;
w: number;
h: number;
}
/** Convert a pixel-space drag delta into a slide-body fraction delta, apply
* it to `startGeom.{x, y}`, and clamp so the zone never escapes the slide
* body (`x ∈ [0, 1 - w]`, `y ∈ [0, 1 - h]`). `w` and `h` are not modified.
*
* The caller (`SlideCanvas.tsx` `handleZoneMouseDown` onMove) guarantees
* `slideBodyWidthPx > 0` and `slideBodyHeightPx > 0` via the
* `measuredSlideBody` precondition, so this helper does not re-guard
* divide-by-zero. */
export function clampZoneMove(
startGeom: ZoneFracGeom,
dxPx: number,
dyPx: number,
slideBodyWidthPx: number,
slideBodyHeightPx: number,
): { x: number; y: number } {
const dx = dxPx / slideBodyWidthPx;
const dy = dyPx / slideBodyHeightPx;
const x = Math.max(0, Math.min(1 - startGeom.w, startGeom.x + dx));
const y = Math.max(0, Math.min(1 - startGeom.h, startGeom.y + dy));
return { x, y };
}
+197 -45
View File
@@ -2,7 +2,7 @@
* Home - 메인 페이지 (Zone-Centric 슬라이드 빌더)
*/
import { useState, useCallback, useMemo, useEffect } from "react";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { toast } from "sonner";
import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent";
import {
@@ -15,6 +15,10 @@ import {
getSelectedRegion,
moveSectionToZone,
saveZoneSizes,
saveImageOverride,
deriveUserOverridesKey,
applyPersistedNonFrameOverrides,
remapPersistedFramesToZoneFrames,
} from "../utils/slidePlanUtils";
import {
parseMdxFile,
@@ -25,6 +29,12 @@ import {
type RunMeta,
type PipelineOverrides,
} from "../services/designAgentApi";
import {
flushUserOverrides,
getUserOverrides,
saveUserOverrides,
type UserOverrides,
} from "../services/userOverridesApi";
import LeftMdxPanel from "../components/LeftMdxPanel";
import SlideCanvas from "../components/SlideCanvas";
@@ -63,6 +73,14 @@ export default function Home() {
// section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시).
const [pendingLayout, setPendingLayout] = useState<LayoutPresetId | null>(null);
// IMP-52 u6 — restore-on-reopen: persisted user_overrides.json fetched at
// handleFileUpload time. layout / zone_geometries / zone_sections are
// seeded into userSelection immediately (so handleGenerate forwards them
// as CLI args). frames are stashed here because their on-disk key
// (unit_id = section_ids joined by "+") only maps to region.id after
// loadRun rebuilds the slidePlan — see handleGenerate post-loadRun.
const persistedOverridesRef = useRef<Partial<UserOverrides>>({});
// pendingLayout 활성 시 effective slidePlan = pendingZones 가 swap 된 plan.
// 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect /
// getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용.
@@ -180,7 +198,19 @@ export default function Home() {
try {
const content = await parseMdxFile(file);
setState((p) => ({ ...p, normalizedContent: content, isLoading: false }));
// IMP-52 u6 — restore-on-reopen. Key = MDX stem (matches backend
// u2 fallback's Path(args.mdx_path).stem). getUserOverrides returns
// {} on miss / corrupt / network failure (u5 contract) so the upload
// path never fails on a fresh MDX.
const overridesKey = deriveUserOverridesKey(file.name);
const persisted = await getUserOverrides(overridesKey);
persistedOverridesRef.current = persisted;
setState((p) => ({
...p,
normalizedContent: content,
userSelection: applyPersistedNonFrameOverrides(p.userSelection, persisted),
isLoading: false,
}));
toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`);
} catch (err) {
console.error(err);
@@ -257,9 +287,11 @@ export default function Home() {
const overrides: PipelineOverrides = {};
const sourcePlan = effectiveSlidePlan;
if (sourcePlan && state.slidePlan) {
const defaultLayout = state.slidePlan.layout_preset;
// 2026-05-22 demo hot-fix — 이전 비교 가드 (default !== override) 제거.
// restore loop 이 default = override 로 sync 시 override 안 보내고 backend
// default fallback 발생. user 가 명시한 layout 이 있으면 무조건 보냄.
const overrideLayout = state.userSelection.overrides.layout_preset;
if (overrideLayout && overrideLayout !== defaultLayout) {
if (overrideLayout) {
overrides.layout = overrideLayout;
}
const frames: Record<string, string> = {};
@@ -302,12 +334,9 @@ export default function Home() {
overrides.zoneGeometries = zoneGeometries;
}
// IMP-08 B-3 : zoneSections forward only when the user diverged from
// the auto plan. Codex Stage 3 R3 B3 fix : `createInitialUserSelection`
// seeds `zone_sections` with the default placement, so a literal copy
// would pollute backend assignment-source provenance even on a fresh
// re-render. Diff against `sourcePlan.zones[].section_ids` per zone and
// only emit zones whose section list differs.
// 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀.
// 시연 안정성 우선. section swap 은 별 path (수동 drag detection) 로 풀어야 함.
// 임시 over-aggressive fix 가 default flow 깨뜨려 PARTIAL_COVERAGE 발생했음.
const userZoneSections = state.userSelection.overrides.zone_sections;
if (userZoneSections) {
const defaultByZone = new Map<string, string[]>();
@@ -349,6 +378,12 @@ export default function Home() {
toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`);
try {
// IMP-52 u10 — Force-commit any pending debounced PUTs before backend
// reads user_overrides.json on pipeline entry. Without this, a user
// who changes an override (300ms debounce window) and immediately
// clicks Generate would race the PUT against /api/run; the u2
// fallback could then load a stale persisted document.
await flushUserOverrides();
const result = await runPipeline(state.uploadedFile, overrides);
if (!result.success || !result.final_html_exists) {
@@ -362,20 +397,44 @@ export default function Home() {
}
const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id);
setState((p) => ({
...p,
normalizedContent,
// IMP-52 u6 — post-loadRun frame remap. persistedOverridesRef holds
// the user_overrides.json read at handleFileUpload time. Frames there
// are keyed by unit_id (section_ids joined by "+"); the in-memory
// zone_frames is keyed by region.id. Remap against the new slidePlan
// zones so SlideCanvas's override-vs-default preview indicator shows
// the user's persisted choice without forcing them to re-click.
const restoredZoneFrames = remapPersistedFramesToZoneFrames(
slidePlan,
userSelection: createInitialUserSelection(slidePlan),
isLoading: false,
}));
persistedOverridesRef.current.frames as Record<string, string> | undefined,
);
setState((p) => {
// IMP-52 u6 — restore-on-reopen: re-layer the persisted non-frame
// axes (layout / zone_geometries / zone_sections) onto the post-load
// `base`. `createInitialUserSelection` rebuilds from slidePlan and
// drops anything the backend fallback could not round-trip through
// a CLI arg — `zone_geometries` in particular has no slidePlan
// representation, so without this merge the user would see their
// resized zones revert on every Generate.
const base = applyPersistedNonFrameOverrides(
createInitialUserSelection(slidePlan),
persistedOverridesRef.current,
);
return {
...p,
normalizedContent,
slidePlan,
userSelection: {
...base,
overrides: {
...base.overrides,
zone_frames: { ...base.overrides.zone_frames, ...restoredZoneFrames },
},
},
isLoading: false,
};
});
setRunMeta(runMeta);
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
// IMP-47B u11 — surface Step 12 AI repair failure axes (error /
// coverage_violated / unsupported_kind) as a human_review notification.
// Auto-pipeline first ([[feedback_auto_pipeline_first]]): no review_queue
// insertion — just an explicit error toast directing the user to pick
// another frame or edit manually. Helper returns null on success path.
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
if (aiReviewMsg) toast.error(aiReviewMsg);
} catch (err) {
@@ -391,10 +450,21 @@ export default function Home() {
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
setState((p) => {
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
return {
...p,
userSelection: selectZone(newSelection, zoneId) // 이동된 존 자동 선택
};
const finalSelection = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
// IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk
// schema axis (`zone_sections`) shares the in-memory shape (zone_id →
// section_ids), so we forward the full mutated value; the u4 PUT path
// replaces this axis atomically while preserving the foreign axes.
// p.uploadedFile gate skips persistence before any MDX is loaded —
// the demo-mode initial render path would otherwise PUT to the empty
// key. saveUserOverrides is debounced (300ms) and per-key coalesced.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, {
zone_sections: finalSelection.overrides.zone_sections,
});
}
return { ...p, userSelection: finalSelection };
});
setRightTab("frame");
setHasPendingChanges(true);
@@ -420,10 +490,18 @@ export default function Home() {
// ── Layout 선택 ──
const handleLayoutSelect = useCallback((layoutId: string) => {
setState((p) => ({
...p,
userSelection: applyLayout(p.userSelection, layoutId as LayoutPresetId)
}));
setState((p) => {
const newSelection = applyLayout(p.userSelection, layoutId as LayoutPresetId);
// IMP-52 u7 — persist the selected layout preset id. The on-disk
// `layout` axis is a single string; `applyLayout` validates the
// preset id before mutating the selection, so the value here is
// already the LayoutPresetId we want to round-trip.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { layout: layoutId });
}
return { ...p, userSelection: newSelection };
});
setHasPendingChanges(true);
}, []);
@@ -436,22 +514,64 @@ export default function Home() {
}, []);
const handleZoneResize = useCallback((geometries: Record<string, { x: number; y: number; w: number; h: number }>) => {
setState((p) => ({
...p,
userSelection: {
...p.userSelection,
overrides: {
...p.userSelection.overrides,
zone_geometries: {
...p.userSelection.overrides.zone_geometries,
...geometries
}
}
setState((p) => {
const mergedGeometries = {
...p.userSelection.overrides.zone_geometries,
...geometries,
};
// IMP-52 u7 — persist the merged zone_geometries snapshot. Resize
// gestures fire repeatedly during a drag; the 300ms u5 debounce
// collapses them into a single PUT at gesture-end, so we don't
// need to gate on resize-finished here.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { zone_geometries: mergedGeometries });
}
}));
return {
...p,
userSelection: {
...p.userSelection,
overrides: {
...p.userSelection.overrides,
zone_geometries: mergedGeometries,
},
},
};
});
setHasPendingChanges(true);
}, []);
// IMP-51 (#79) u10 — wire SlideCanvas's user-content image drag/resize
// emit into the 5th persisted axis. Mirrors handleZoneResize exactly:
// • merge the single (imageId → {x,y,w,h}) tick onto the prior
// in-memory `image_overrides` map via the u11 `saveImageOverride`
// helper so the immutable update path is shared with the test suite,
// • forward the full merged snapshot through `saveUserOverrides`
// (the u3 typed client) under the `image_overrides` key — the 300ms
// debounce defined alongside `zone_geometries` collapses the
// per-mousemove emits into one PUT at gesture-end,
// • flip `hasPendingChanges` so the "선택대로 재생성하기" CTA appears.
// Coordinates are slide-absolute percent (0100) from u8/u9 — passed
// through unchanged so the on-disk schema matches the SlideCanvas
// overlay, the stamper selector (u4), and the render-time CSS
// injector (u7) without any per-zone transform.
const handleImageResize = useCallback(
(imageId: string, geometry: { x: number; y: number; w: number; h: number }) => {
setState((p) => {
const nextSelection = saveImageOverride(p.userSelection, imageId, geometry);
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, {
image_overrides: nextSelection.overrides.image_overrides,
});
}
return { ...p, userSelection: nextSelection };
});
setHasPendingChanges(true);
},
[],
);
// 편집 모드 텍스트 변경 시 hasPendingChanges 활성. useCallback 으로 reference 안정화 —
// SlideCanvas 의 useEffect 가 매번 rerun 안 하도록 (resize drag 매 mousemove 마다
// re-render 시 useEffect retrigger → iframe contentEditable 재설정 = 매우 느림).
@@ -491,10 +611,40 @@ export default function Home() {
return;
}
setState((p) => ({
...p,
userSelection: applyFrame(p.userSelection, region.id, frameId)
}));
setState((p) => {
const newSelection = applyFrame(p.userSelection, region.id, frameId);
// IMP-52 u7 — persist frames keyed by `unit_id`. The on-disk schema
// uses `unit_id = zone.section_ids.join("+")` (the same convention
// handleGenerate uses when forwarding `overrides.frames` to the
// backend CLI). `zone_frames` is keyed by region.id, so we walk
// the effectiveSlidePlan zones to translate. Only true user
// overrides are persisted — `createInitialUserSelection` pre-fills
// `zone_frames[region.id]` with `region.frame_match_strategy.frame_id`
// (backend default) for every region, so we mirror handleGenerate's
// `overrideFrameId !== defaultFrameId` gate to avoid leaking defaults
// into user_overrides.json. Zones with no sections are skipped.
if (p.uploadedFile && effectiveSlidePlan) {
const framesByUnitId: Record<string, string> = {};
for (const z of effectiveSlidePlan.zones) {
const r = z.internal_regions[0];
if (!r) continue;
if (!Array.isArray(z.section_ids) || z.section_ids.length === 0) continue;
const unitId = z.section_ids.join("+");
const overrideId = newSelection.overrides.zone_frames?.[r.id];
const defaultFrameId = r.frame_match_strategy.frame_id;
if (
typeof overrideId === "string" &&
overrideId.length > 0 &&
overrideId !== defaultFrameId
) {
framesByUnitId[unitId] = overrideId;
}
}
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { frames: framesByUnitId });
}
return { ...p, userSelection: newSelection };
});
setHasPendingChanges(true);
}, [effectiveSlidePlan, state.userSelection]);
@@ -627,6 +777,8 @@ export default function Home() {
onSectionDrop={handleSectionDrop}
onLayoutResize={handleLayoutResize}
onZoneResize={handleZoneResize}
imageOverrides={state.userSelection.overrides.image_overrides}
onImageResize={handleImageResize}
/>
</main>
@@ -0,0 +1,64 @@
// ─── IMP-41 u2 — application_mode helper (issue #70) ────────────────────────
// Pure deterministic helpers for forwarding backend Step 9
// `unit.application_candidates[]` to the FramePanel V4-label badge tooltip.
//
// Keyed by backend `application_mode` VALUE (NOT V4 label) — preserves the
// AI-isolation contract: tooltip text is a read-only display of backend
// authority, never re-derived on the frontend from V4 label.
//
// Source of truth = src/phase_z2_pipeline.py APPLICATION_MODE_BY_V4_LABEL
// (:107-112) emitted via _application_candidates_for_unit() (:3071-3092)
// onto unit.application_candidates[] in step09_application_plan.json.
/** Backend application_mode enumeration (verbatim from APPLICATION_MODE_BY_V4_LABEL). */
export type ApplicationMode =
| 'direct_insert'
| 'same_frame_with_adjustment'
| 'layout_or_region_change'
| 'exclude';
/** Korean consequence phrases per issue #70 spec item #2. Keyed by mode VALUE. */
export const APPLICATION_MODE_TOOLTIP_KR: Record<ApplicationMode, string> = {
direct_insert: '코드 직접 적용',
same_frame_with_adjustment: 'AI 보강 필요',
layout_or_region_change: 'AI restructure 필요',
exclude: 'render path 제외',
};
/**
* Compose the V4-label badge tooltip title. When `applicationMode` resolves
* to a known mode the title shows the Korean consequence + raw mode token;
* otherwise (undefined or unknown — legacy fixtures pre-IMP-32) it falls
* back to the raw V4 label string per Stage 2 contract.
*/
export function buildBadgeTitle(
label: string,
applicationMode: string | undefined,
): string {
const consequence = applicationMode
? APPLICATION_MODE_TOOLTIP_KR[applicationMode as ApplicationMode]
: undefined;
return consequence
? `${consequence} (${applicationMode})`
: `V4 label: ${label}`;
}
/**
* Build a Map<template_id, applicationCandidate> from a Step 9
* `unit.application_candidates[]` array. Entries with a non-string or empty
* `template_id` are skipped. First occurrence wins on duplicate keys.
* Pure — does NOT sort, slice, or filter by label/confidence.
*/
export function mergeApplicationCandidates(
applicationCandidates: unknown,
): Map<string, any> {
const out = new Map<string, any>();
if (!Array.isArray(applicationCandidates)) return out;
for (const ac of applicationCandidates) {
const key = (ac as any)?.template_id;
if (typeof key === 'string' && key.length > 0 && !out.has(key)) {
out.set(key, ac);
}
}
return out;
}
+8 -29
View File
@@ -20,6 +20,8 @@ import {
MOCK_FRAME_CANDIDATES_SECTION1,
} from "../data/mockDesignAgentData";
import { mergeApplicationCandidates } from "./applicationMode";
/** 네트워크 지연 시뮬레이션 */
const simulateDelay = (ms: number = 800) =>
new Promise((resolve) => setTimeout(resolve, ms));
@@ -223,10 +225,6 @@ export interface FilteredSectionReason {
position?: string | null;
}
// IMP-47B u11 — verbatim mirror of step20_slide_status.ai_repair_status (u8 schema).
// Surfaces Step 12 AI repair outcomes so the frontend can render a
// human_review notification when AI proposal validation, coverage, or call
// itself failed. Enum / field names kept verbatim — no frontend redefinition.
export interface AiRepairStatus {
status: "ok" | "applied" | "unsupported_kind" | "coverage_violated" | "error" | string;
counts: {
@@ -266,21 +264,9 @@ export interface RunMeta {
layout_candidates: string[]; // step07 layout_candidates list
region_layout_candidates_by_zone: Record<string, string[]>; // step08 placeholder
display_strategy_candidates_by_zone: Record<string, string[]>; // step08 placeholder
/** IMP-47B u11 — Step 12 AI repair outcome (u8 surfacing). null when
* step20 omits the field (legacy runs / pipeline aborted before Step 12). */
ai_repair_status: AiRepairStatus | null;
}
/**
* IMP-47B u11 — Build the human_review notification text when Step 12 AI repair
* reports a failure axis. Returns null when no notification is needed (success,
* no AI invocation, or human_review_required=false). Pure function — no DOM, no
* toast side-effect — so it can be unit-tested without React Testing Library.
*
* Failure axes mapped to user-facing text (verbatim policy from
* IMP-47B #76 guardrail: "AI 호출 실패 / proposal validation 실패 / coverage 미달
* → frontend 에 명확한 notification").
*/
export function formatAiRepairHumanReviewMessage(
ai: AiRepairStatus | null | undefined,
): string | null {
@@ -589,22 +575,15 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
if (lp !== 0) return lp;
return (b.confidence ?? 0) - (a.confidence ?? 0);
});
// ─── IMP-41 u2 — application_candidates enrichment (issue #70) ───────────
// ─── IMP-41 u4 — application_candidates enrichment (issue #70) ───────────
// Backend Step 9 emits `unit.application_candidates[]` (src/phase_z2_pipeline.py
// _application_candidates_for_unit, :3071-3092) one entry per v4 candidate with
// application_mode / auto_applicable / delegated_to derived from
// APPLICATION_MODE_BY_V4_LABEL (:107-112). Enrichment ONLY — does NOT alter
// candidate source priority, sorting, or TOP_N_FRAMES slicing.
const applicationCandidates: any[] = Array.isArray(unit.application_candidates)
? unit.application_candidates
: [];
const applicationModeMap = new Map<string, any>();
applicationCandidates.forEach((ac: any) => {
const key = ac?.template_id;
if (typeof key === "string" && key.length > 0) {
applicationModeMap.set(key, ac);
}
});
// APPLICATION_MODE_BY_V4_LABEL (:107-112). Indexing delegated to the pure
// helper `mergeApplicationCandidates` (services/applicationMode.ts) keyed
// by template_id. Enrichment ONLY — does NOT alter candidate source
// priority, sorting, or TOP_N_FRAMES slicing.
const applicationModeMap = mergeApplicationCandidates(unit.application_candidates);
const frameCandidates: FrameCandidate[] = v4Source
.slice(0, TOP_N_FRAMES)
.map((c: any) => {
@@ -0,0 +1,243 @@
// IMP-52 u5 — typed frontend client for `/api/user-overrides/:key` (GET + PUT).
//
// The on-disk schema (KNOWN_AXES) and endpoint contract are owned by:
// • src/user_overrides_io.py (Python — backend pipeline fallback, u1/u2)
// • Front/vite.config.ts (handleGet/PutUserOverrides, u3/u4)
// This module is the typed view used by Home.tsx restore-on-reopen (u6) and
// the four mutation handlers (u7). It does NOT own the schema — any change
// to KNOWN_AXES must land in u1/u4 first, then reflect here.
//
// IMP-51 (#79) u3 — added `image_overrides` (5th axis). `image_id` → percent-
// of-slide {x,y,w,h}. Mirrors src/user_overrides_io.py KNOWN_AXES (u1) and
// Front/vite.config.ts KNOWN_USER_OVERRIDES_AXES (u2). Backend stamper +
// render-time CSS injection ride on u4~u7; the SlideCanvas drag/resize
// handles that drive this axis ride on u8~u11.
//
// Contract (Stage 2 unit u5 summary):
// • Typed `getUserOverrides(key)` → returns `Partial<UserOverrides>` from
// the GET endpoint. Missing / corrupt / non-object payloads degrade to
// `{}` so the frontend reopen flow never crashes on a fresh MDX.
// • Typed `saveUserOverrides(key, partial)` → schedules a 300ms-debounced
// PUT carrying ONLY the axes the user has mutated since the last flush.
// Per-axis coalescing: a later call overwrites the same axis in the
// pending payload; axes the user did not mutate are NOT sent (the
// server-side merge in u4 preserves them on disk).
// • Per-key debounce buckets — rapid edits to MDX "03" do not delay the
// flush for MDX "04".
// • Explicit clear sentinel: `partial[axis] = null` forwards to the PUT
// body verbatim so u4 `mergeUserOverrides` can `delete` the axis on disk.
// • `flushUserOverrides()` / `flushUserOverrides(key)` force an immediate
// PUT (used by tests + Home.tsx Generate flow to ensure outstanding
// writes commit before pipeline run).
const ENDPOINT_BASE = "/api/user-overrides";
const DEBOUNCE_MS = 300;
// ── Schema (mirror of backend KNOWN_AXES; see header comment) ───────────────
/** unit_id → template_id. unit_id = source_section_ids joined by "+". */
export type FramesOverride = Record<string, string>;
/** zone_id → 0-1 normalized geometry inside slide-body. */
export type ZoneGeometryOverride = {
x: number;
y: number;
w: number;
h: number;
};
export type ZoneGeometriesOverride = Record<string, ZoneGeometryOverride>;
/** zone_id → ordered list of section_ids assigned to that zone. */
export type ZoneSectionsOverride = Record<string, string[]>;
/**
* IMP-51 #79 u3 — image_id → percent-of-slide geometry. Matches the user-
* content image selector `.slide img[data-image-role="user-content"]`
* (stamper in u4) and the render-time CSS injection map (u7). Coordinates
* are slide-absolute percent (0100) so SlideCanvas drag handles (u8~u11)
* map 1:1 with the persisted axis without per-zone transforms.
*/
export type ImageOverride = {
x: number;
y: number;
w: number;
h: number;
};
export type ImageOverridesOverride = Record<string, ImageOverride>;
/** Full on-disk schema. All axes optional — file may carry any subset. */
export interface UserOverrides {
layout: string;
frames: FramesOverride;
zone_geometries: ZoneGeometriesOverride;
zone_sections: ZoneSectionsOverride;
image_overrides: ImageOverridesOverride;
}
/** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */
export type UserOverridesPartial = {
[K in keyof UserOverrides]?: UserOverrides[K] | null;
};
// ── Per-key debounce buckets ────────────────────────────────────────────────
type PendingBucket = {
partial: UserOverridesPartial;
timer: ReturnType<typeof setTimeout> | null;
waiters: Array<{
resolve: (merged: Partial<UserOverrides>) => void;
reject: (err: unknown) => void;
}>;
};
const buckets = new Map<string, PendingBucket>();
function getBucket(key: string): PendingBucket {
let b = buckets.get(key);
if (!b) {
b = { partial: {}, timer: null, waiters: [] };
buckets.set(key, b);
}
return b;
}
// ── GET ─────────────────────────────────────────────────────────────────────
/**
* Fetch the persisted user_overrides for `key` (MDX stem). Returns `{}` on
* any failure mode (network error, 4xx/5xx, non-object body) so the caller
* can use it unconditionally during MDX reopen without branching on
* error paths.
*/
export async function getUserOverrides(
key: string,
): Promise<Partial<UserOverrides>> {
let res: Response;
try {
res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
method: "GET",
headers: { Accept: "application/json" },
});
} catch {
return {};
}
if (!res.ok) return {};
let parsed: unknown;
try {
parsed = await res.json();
} catch {
return {};
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return {};
}
return parsed as Partial<UserOverrides>;
}
// ── PUT (debounced) ─────────────────────────────────────────────────────────
async function flushBucket(
key: string,
bucket: PendingBucket,
): Promise<void> {
const payload = bucket.partial;
const waiters = bucket.waiters;
bucket.partial = {};
bucket.timer = null;
bucket.waiters = [];
let merged: Partial<UserOverrides> = {};
try {
const res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) {
try {
const parsed = (await res.json()) as unknown;
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
merged = parsed as Partial<UserOverrides>;
}
} catch {
// server returned 200 with non-JSON body → treat as empty merged
}
} else {
const err = new Error(`PUT ${ENDPOINT_BASE}/${key}${res.status}`);
waiters.forEach((w) => w.reject(err));
return;
}
} catch (err) {
waiters.forEach((w) => w.reject(err));
return;
}
waiters.forEach((w) => w.resolve(merged));
}
/**
* Schedule a debounced PUT to persist the mutated axes. Resolves with the
* server-side merged document when the debounced PUT eventually fires.
* Multiple rapid calls for the same `key` coalesce into a single PUT;
* a later call's value for a given axis overrides an earlier pending value.
* Calls for different `key`s are isolated.
*/
export function saveUserOverrides(
key: string,
partial: UserOverridesPartial,
): Promise<Partial<UserOverrides>> {
const bucket = getBucket(key);
// Per-axis coalescing — later mutations replace earlier pending values.
for (const axis of Object.keys(partial) as Array<keyof UserOverridesPartial>) {
bucket.partial[axis] = partial[axis] as never;
}
const p = new Promise<Partial<UserOverrides>>((resolve, reject) => {
bucket.waiters.push({ resolve, reject });
});
if (bucket.timer !== null) clearTimeout(bucket.timer);
bucket.timer = setTimeout(() => {
void flushBucket(key, bucket);
}, DEBOUNCE_MS);
return p;
}
/**
* Force-flush pending debounced writes. With no arg, flushes ALL pending
* keys (used before pipeline runs so the backend reads the latest file).
* With a key, flushes only that key's bucket.
*
* Resolves after every flushed bucket's PUT completes. Per-bucket errors
* are swallowed at the flush level — the original caller's
* saveUserOverrides() promise still rejects to its owner via the waiter.
*/
export async function flushUserOverrides(key?: string): Promise<void> {
const targets: Array<[string, PendingBucket]> = [];
if (key !== undefined) {
const b = buckets.get(key);
if (b && b.timer !== null) targets.push([key, b]);
} else {
buckets.forEach((b, k) => {
if (b.timer !== null) targets.push([k, b]);
});
}
const flushPromises = targets.map(([k, b]) => {
if (b.timer !== null) {
clearTimeout(b.timer);
b.timer = null;
}
return flushBucket(k, b);
});
await Promise.all(flushPromises);
}
/** Test-only — clears all pending buckets without firing PUTs. */
export function __resetUserOverridesBuckets_FOR_TEST(): void {
buckets.forEach((b) => {
if (b.timer !== null) clearTimeout(b.timer);
});
buckets.clear();
}
+7
View File
@@ -206,6 +206,13 @@ export interface UserSelection {
zone_sections: Record<string, string[]>; // zoneId -> sectionIds[]
zone_sizes: Record<string, number[]>; // layoutGroupId -> [size1, size2, ...]
zone_geometries: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id -> geometry
// IMP-51 (#79) u11 — image_id → slide-absolute percent geometry (0100
// on each axis). image_id is stamped by `src/image_id_stamper.py` (u4)
// on user-content `<img>` tags; the same key is consumed by the u7 CSS
// injector and the SlideCanvas u8 overlay. Shape mirrors the on-disk
// `image_overrides` axis (KNOWN_AXES, src/user_overrides_io.py u1) and
// the typed-client `ImageOverridesOverride` (services/userOverridesApi.ts u3).
image_overrides: Record<string, { x: number; y: number; w: number; h: number }>;
};
}
+148 -2
View File
@@ -1,4 +1,120 @@
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
import type { UserOverrides } from "../services/userOverridesApi";
// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ────
// These helpers compose persisted `user_overrides.json` payloads (typed by
// the u5 service) onto the in-memory `UserSelection`. They live here rather
// than inline in Home.tsx so vitest can drive them in a node environment
// without booting React or pulling in the radix-ui / lucide UI deps that
// Home.tsx requires. Home.tsx wires these into:
// • handleFileUpload (pre-Generate layout / zone_geometries / zone_sections
// seed so handleGenerate's CLI-args build picks them up)
// • handleGenerate post-loadRun (frame remap unit_id → region.id over the
// freshly built slidePlan)
// The on-disk schema and clear-sentinel semantics are owned by:
// • src/user_overrides_io.py (KNOWN_AXES, u1)
// • Front/vite.config.ts mergeUserOverrides (u4)
// • Front/client/src/services/userOverridesApi.ts (UserOverrides type, u5)
// Any KNOWN_AXES drift must land in those files first.
/**
* Derive the `/api/user-overrides/:key` MDX-stem key from a filename.
* Strips a trailing `.mdx` (case-insensitive). The key matches the Python
* `Path(args.mdx_path).stem` derivation used by the backend fallback (u2),
* so the same persisted file is read from both ends without translation.
*/
export function deriveUserOverridesKey(filename: string): string {
return filename.replace(/\.mdx$/i, "");
}
const LAYOUT_PRESET_IDS = new Set<string>([
"single",
"horizontal-2",
"vertical-2",
"top-1-bottom-2",
"top-2-bottom-1",
"left-1-right-2",
"left-2-right-1",
"grid-2x2",
]);
/**
* Layer the three non-frame axes from a persisted `user_overrides.json`
* payload onto an existing `UserSelection`. Foreign / unrecognized payload
* shapes are silently ignored — the u5 GET path already returns `{}` on
* corrupt files, but we revalidate here so hand-edited files or future
* forward-compat axes cannot poison the in-memory state.
*
* Frames are NOT layered here because the on-disk key (`unit_id` =
* section_ids joined by `+`) only resolves after the slidePlan zones are
* known. Use `remapPersistedFramesToZoneFrames` in the post-loadRun step.
*/
export function applyPersistedNonFrameOverrides(
selection: UserSelection,
persisted: Partial<UserOverrides> | null | undefined,
): UserSelection {
if (!persisted || typeof persisted !== "object") return selection;
const next = { ...selection.overrides };
if (typeof persisted.layout === "string" && LAYOUT_PRESET_IDS.has(persisted.layout)) {
next.layout_preset = persisted.layout as LayoutPresetId;
}
if (
persisted.zone_geometries &&
typeof persisted.zone_geometries === "object" &&
!Array.isArray(persisted.zone_geometries)
) {
next.zone_geometries = { ...persisted.zone_geometries };
}
if (
persisted.zone_sections &&
typeof persisted.zone_sections === "object" &&
!Array.isArray(persisted.zone_sections)
) {
next.zone_sections = { ...persisted.zone_sections };
}
// IMP-51 (#79) u11 — layer the 5th persisted axis (`image_overrides`) by
// the same array / non-object guard the zone_geometries branch uses. The
// u3 typed client (services/userOverridesApi.ts) shape and the on-disk
// KNOWN_AXES entry (src/user_overrides_io.py u1) are both flat dicts
// (image_id → {x,y,w,h} percent-of-slide), so a shallow copy is enough.
if (
persisted.image_overrides &&
typeof persisted.image_overrides === "object" &&
!Array.isArray(persisted.image_overrides)
) {
next.image_overrides = { ...persisted.image_overrides };
}
return { ...selection, overrides: next };
}
/**
* Remap persisted frames (`unit_id` → template_id) to the in-memory
* `zone_frames` (region.id → template_id) using the freshly built
* slidePlan zones. `unit_id` follows handleGenerate's convention:
* `zone.section_ids.join("+")`. Persisted entries whose unit_id no longer
* matches any zone (e.g. user changed zone_sections between sessions) are
* silently dropped.
*/
export function remapPersistedFramesToZoneFrames(
slidePlan: SlidePlan | null | undefined,
framesByUnitId: Record<string, string> | null | undefined,
): Record<string, string> {
if (!slidePlan || !framesByUnitId || typeof framesByUnitId !== "object") {
return {};
}
const out: Record<string, string> = {};
for (const zone of slidePlan.zones) {
const region = zone.internal_regions[0];
if (!region) continue;
if (!Array.isArray(zone.section_ids) || zone.section_ids.length === 0) continue;
const unitId = zone.section_ids.join("+");
const templateId = framesByUnitId[unitId];
if (typeof templateId === "string" && templateId.length > 0) {
out[region.id] = templateId;
}
}
return out;
}
/**
* Phase Z 초기 선택 상태 생성
@@ -39,13 +155,17 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
zone_sections: initialSections,
zone_sizes: {},
zone_geometries: {},
// IMP-51 (#79) u11 — image_overrides axis starts empty; entries land
// here via `saveImageOverride` (SlideCanvas drag/resize handler) and
// are seeded on reopen via `applyPersistedNonFrameOverrides`.
image_overrides: {},
},
};
}
export function saveZoneGeometry(
selection: UserSelection,
zoneId: string,
selection: UserSelection,
zoneId: string,
geometry: { x: number; y: number; w: number; h: number }
): UserSelection {
return {
@@ -60,6 +180,32 @@ export function saveZoneGeometry(
};
}
/**
* IMP-51 (#79) u11 — record a single `image_id` → slide-absolute percent
* geometry on the in-memory selection. Mirrors `saveZoneGeometry` but on
* the 5th persisted axis (`image_overrides`); the SlideCanvas drag/resize
* handler (u8) emits one entry per pointer move, and u10's Home wiring
* funnels each emit through this helper before scheduling the debounced
* PUT. Pure / immutable — returns a fresh `UserSelection`; the input is
* never mutated. Existing entries for the same `imageId` are replaced.
*/
export function saveImageOverride(
selection: UserSelection,
imageId: string,
geometry: { x: number; y: number; w: number; h: number },
): UserSelection {
return {
...selection,
overrides: {
...selection.overrides,
image_overrides: {
...selection.overrides.image_overrides,
[imageId]: geometry,
},
},
};
}
export function saveZoneSizes(selection: UserSelection, groupId: string, sizes: number[]): UserSelection {
return {
...selection,
@@ -0,0 +1,123 @@
// IMP-41 u3 — Vitest coverage for application_mode helper (issue #70).
//
// Scope (Stage 2 unit u3 contract):
// 1) buildBadgeTitle: composite output for each known mode + legacy fallback
// (undefined applicationMode) + unknown fallback (string not in
// APPLICATION_MODE_TOOLTIP_KR).
// 2) mergeApplicationCandidates: array → Map<template_id, candidate>
// semantics, including skip-missing-key and empty-input.
//
// Pure helper unit test — no React, no DOM, no fetch. Aligns with the
// AI-isolation contract: assertions key by backend application_mode VALUE,
// never by V4 label.
import { describe, it, expect } from "vitest";
import {
buildBadgeTitle,
mergeApplicationCandidates,
APPLICATION_MODE_TOOLTIP_KR,
} from "../src/services/applicationMode";
describe("buildBadgeTitle (IMP-41 u3)", () => {
it("returns composite '<consequence> (<mode>)' for direct_insert", () => {
expect(buildBadgeTitle("use_as_is", "direct_insert")).toBe(
`${APPLICATION_MODE_TOOLTIP_KR.direct_insert} (direct_insert)`,
);
});
it("returns composite output for same_frame_with_adjustment", () => {
expect(
buildBadgeTitle("light_edit", "same_frame_with_adjustment"),
).toBe(
`${APPLICATION_MODE_TOOLTIP_KR.same_frame_with_adjustment} (same_frame_with_adjustment)`,
);
});
it("returns composite output for layout_or_region_change", () => {
expect(
buildBadgeTitle("restructure", "layout_or_region_change"),
).toBe(
`${APPLICATION_MODE_TOOLTIP_KR.layout_or_region_change} (layout_or_region_change)`,
);
});
it("returns composite output for exclude", () => {
expect(buildBadgeTitle("reject", "exclude")).toBe(
`${APPLICATION_MODE_TOOLTIP_KR.exclude} (exclude)`,
);
});
it("falls back to 'V4 label: <label>' when applicationMode is undefined (legacy fixtures pre-IMP-32)", () => {
expect(buildBadgeTitle("use_as_is", undefined)).toBe("V4 label: use_as_is");
});
it("falls back to 'V4 label: <label>' when applicationMode is an unknown string", () => {
expect(buildBadgeTitle("light_edit", "some_future_mode")).toBe(
"V4 label: light_edit",
);
});
});
describe("mergeApplicationCandidates (IMP-41 u3)", () => {
it("returns empty Map when input is undefined", () => {
const result = mergeApplicationCandidates(undefined);
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
it("returns empty Map when input is null", () => {
const result = mergeApplicationCandidates(null);
expect(result.size).toBe(0);
});
it("returns empty Map when input is not an array", () => {
expect(mergeApplicationCandidates({ template_id: "f01" }).size).toBe(0);
expect(mergeApplicationCandidates("f01").size).toBe(0);
expect(mergeApplicationCandidates(42).size).toBe(0);
});
it("returns empty Map when input is an empty array", () => {
expect(mergeApplicationCandidates([]).size).toBe(0);
});
it("keys entries by template_id and preserves the candidate payload", () => {
const ac1 = {
template_id: "f01",
label: "use_as_is",
application_mode: "direct_insert",
auto_applicable: true,
delegated_to: null,
};
const ac2 = {
template_id: "f17",
label: "light_edit",
application_mode: "same_frame_with_adjustment",
auto_applicable: false,
delegated_to: "step10_contract_check",
};
const result = mergeApplicationCandidates([ac1, ac2]);
expect(result.size).toBe(2);
expect(result.get("f01")).toBe(ac1);
expect(result.get("f17")).toBe(ac2);
});
it("skips entries with missing or non-string template_id", () => {
const result = mergeApplicationCandidates([
{ label: "use_as_is" }, // missing template_id
{ template_id: "", label: "light_edit" }, // empty string
{ template_id: 17, label: "restructure" }, // non-string
{ template_id: "f29", label: "reject" }, // valid
]);
expect(result.size).toBe(1);
expect(result.has("f29")).toBe(true);
expect(result.has("")).toBe(false);
});
it("keeps the first occurrence on duplicate template_id keys (deterministic)", () => {
const first = { template_id: "f01", label: "use_as_is" };
const second = { template_id: "f01", label: "reject" };
const result = mergeApplicationCandidates([first, second]);
expect(result.size).toBe(1);
expect(result.get("f01")).toBe(first);
});
});
@@ -0,0 +1,135 @@
// IMP-47B u11 — Frontend ai_repair_status notification surfacing.
//
// Scope (Stage 2 unit u11 contract):
// 1) loadRun → RunMeta.ai_repair_status exposes the u8 step20 payload.
// 2) formatAiRepairHumanReviewMessage(...) returns user-facing notification
// text on the three failure axes (error / coverage_violated /
// unsupported_kind) and returns null on success / no-AI paths.
//
// Pure-function unit test (no React Testing Library required — vitest is
// already in devDependencies; @testing-library/* is NOT installed). The
// Home.tsx wiring is a 2-line site that calls this helper after
// setRunMeta(...); covering the helper covers the user-visible message text
// directly without DOM rendering.
//
// File extension is `.tsx` per Stage 2 unit contract path; no JSX is required
// for these assertions but the extension allows future RTL-based tests to
// land here without renaming.
import { describe, it, expect } from "vitest";
import {
formatAiRepairHumanReviewMessage,
type AiRepairStatus,
} from "../src/services/designAgentApi";
const baseCounts = {
total: 1,
applied: 0,
no_proposal: 0,
no_zone_match: 0,
unsupported_kind: 0,
error: 0,
};
describe("formatAiRepairHumanReviewMessage (IMP-47B u11)", () => {
it("returns null when ai_repair_status is null (legacy / pre-Step12 abort)", () => {
expect(formatAiRepairHumanReviewMessage(null)).toBeNull();
expect(formatAiRepairHumanReviewMessage(undefined)).toBeNull();
});
it("returns null when human_review_required=false (success / no-AI path)", () => {
const ok: AiRepairStatus = {
status: "ok",
counts: { ...baseCounts, total: 0 },
unsupported_kind_records: [],
error_records: [],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: false,
};
expect(formatAiRepairHumanReviewMessage(ok)).toBeNull();
const applied: AiRepairStatus = {
...ok,
status: "applied",
counts: { ...baseCounts, total: 1, applied: 1 },
};
expect(formatAiRepairHumanReviewMessage(applied)).toBeNull();
});
it("surfaces AI call failures with count + frame/manual guidance", () => {
const errored: AiRepairStatus = {
status: "error",
counts: { ...baseCounts, total: 2, error: 2 },
unsupported_kind_records: [],
error_records: [
{ unit_index: 0, source_section_ids: ["03-1"], error: "timeout" },
{ unit_index: 1, source_section_ids: ["03-2"], error: "validation" },
],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(errored);
expect(msg).not.toBeNull();
expect(msg).toContain("AI 재구성 호출 실패");
expect(msg).toContain("2");
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
});
it("surfaces coverage violations with the dropped section ids", () => {
const dropped: AiRepairStatus = {
status: "coverage_violated",
counts: { ...baseCounts, total: 1, applied: 1 },
unsupported_kind_records: [],
error_records: [],
coverage_status: "violated",
dropped_section_ids: ["03-2"],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(dropped);
expect(msg).not.toBeNull();
expect(msg).toContain("콘텐츠 누락");
expect(msg).toContain("03-2");
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
});
it("surfaces unsupported proposal kinds with the unsupported count", () => {
const unsupported: AiRepairStatus = {
status: "unsupported_kind",
counts: { ...baseCounts, total: 1, unsupported_kind: 1 },
unsupported_kind_records: [
{
unit_index: 0,
source_section_ids: ["03-1"],
apply_status: "unsupported_kind_for_reject_route:builder_options_patch",
},
],
error_records: [],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(unsupported);
expect(msg).not.toBeNull();
expect(msg).toContain("AI 제안 형식 미지원");
expect(msg).toContain("1");
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
});
it("falls back to a generic human_review message on unknown status enums", () => {
const future: AiRepairStatus = {
status: "future_axis_not_yet_mapped",
counts: { ...baseCounts, total: 0 },
unsupported_kind_records: [],
error_records: [],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(future);
expect(msg).not.toBeNull();
expect(msg).toContain("human_review");
expect(msg).toContain("future_axis_not_yet_mapped");
});
});
@@ -0,0 +1,809 @@
// IMP-52 u3/u4 — vitest coverage for the vite `/api/user-overrides/:key`
// GET and PUT endpoints and their supporting helpers.
//
// Scope:
// u3 (read path):
// 1) isValidUserOverridesKey: accept MDX-stem keys (03, 03__DX_BIM,
// a-b.c), reject empty / leading-dot / `..` / `/` / `\` /
// disallowed chars. Mirrors src/user_overrides_io.validate_key so
// backend (u2) and frontend endpoint (u3) agree on every key.
// 2) userOverridesPath: returns <root>/data/user_overrides/<key>.json.
// 3) handleGetUserOverrides: method != GET → false (next chained for
// PUT); invalid key → 400; missing file → 200 {}; corrupt JSON /
// non-object root → 200 {} (graceful degrade per u1 load contract);
// valid object JSON → 200 with parsed payload echoed back.
//
// u4 (write path):
// 4) mergeUserOverrides: only KNOWN_USER_OVERRIDES_AXES mutated;
// foreign top-level keys preserved; null clears axis; non-axis
// partial keys dropped (allowlist).
// 5) atomicWriteUserOverrides: tmp + rename; parent dir auto-created.
// 6) handlePutUserOverrides: method != PUT → false (next chained);
// invalid key → 400; invalid JSON → 400; non-object body → 400;
// success → 200 with merged result; partial-merge preserves axes
// not in payload; foreign-key preserve on disk; allowlist drops
// unknown payload keys; explicit null clears; corrupt existing →
// recover to clean state.
//
// Tests exercise the pure handlers with mock req/res — no real vite server.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
KNOWN_USER_OVERRIDES_AXES,
USER_OVERRIDES_KEY_RE,
atomicWriteUserOverrides,
handleGetUserOverrides,
handlePutUserOverrides,
isValidUserOverridesKey,
mergeUserOverrides,
userOverridesPath,
} from "../../vite.config";
// ---------------------------------------------------------------------------
// mock res helper — captures writeHead(status, headers) + end(body) so the
// handler can be invoked synchronously without spawning a TCP socket.
// ---------------------------------------------------------------------------
function makeMockRes() {
const state = {
statusCode: 0,
headers: {} as Record<string, string>,
body: "",
ended: false,
};
return {
state,
res: {
writeHead(status: number, headers?: Record<string, string>) {
state.statusCode = status;
if (headers) state.headers = headers;
},
end(body?: string) {
state.body = body ?? "";
state.ended = true;
},
},
};
}
describe("USER_OVERRIDES_KEY_RE (IMP-52 u3)", () => {
it("matches Python validate_key regex literally", () => {
// The pattern locked in src/user_overrides_io.py:_KEY_RE — any drift here
// means backend pipeline fallback (u2) and the vite endpoint disagree on
// which keys are routable, which is the single failure mode that would
// silently lose persisted overrides.
expect(USER_OVERRIDES_KEY_RE.source).toBe(
"^[A-Za-z0-9_][A-Za-z0-9_.\\-]*$",
);
});
});
describe("isValidUserOverridesKey (IMP-52 u3)", () => {
it("accepts MDX-stem-style keys actually used in samples/mdx/", () => {
// 03 / 04 / 05 are the wired sample MDXs (vite.config.ts:SAMPLE_MDX_MAP).
expect(isValidUserOverridesKey("03")).toBe(true);
expect(isValidUserOverridesKey("04")).toBe(true);
expect(isValidUserOverridesKey("05")).toBe(true);
// Stage 1 EVIDENCE references 03__DX_BIM... — must round-trip.
expect(isValidUserOverridesKey("03__DX_BIM")).toBe(true);
expect(isValidUserOverridesKey("a-b.c")).toBe(true);
expect(isValidUserOverridesKey("a")).toBe(true);
expect(isValidUserOverridesKey("_leading_underscore")).toBe(true);
expect(isValidUserOverridesKey("9starts_with_digit")).toBe(true);
});
it("rejects empty and whitespace-only keys", () => {
expect(isValidUserOverridesKey("")).toBe(false);
});
it("rejects path-traversal substrings", () => {
// `..` rejected explicitly even if the rest of the regex would allow it
// — `a..b` would otherwise pass the char class.
expect(isValidUserOverridesKey("..")).toBe(false);
expect(isValidUserOverridesKey("a..b")).toBe(false);
expect(isValidUserOverridesKey("../escape")).toBe(false);
});
it("rejects path separators", () => {
expect(isValidUserOverridesKey("a/b")).toBe(false);
expect(isValidUserOverridesKey("a\\b")).toBe(false);
expect(isValidUserOverridesKey("/")).toBe(false);
expect(isValidUserOverridesKey("\\")).toBe(false);
});
it("rejects keys starting with a non-word character", () => {
expect(isValidUserOverridesKey(".hidden")).toBe(false);
expect(isValidUserOverridesKey("-leading-dash")).toBe(false);
});
it("rejects characters outside [A-Za-z0-9_.-]", () => {
expect(isValidUserOverridesKey("a b")).toBe(false);
expect(isValidUserOverridesKey("a:b")).toBe(false);
expect(isValidUserOverridesKey("a*b")).toBe(false);
expect(isValidUserOverridesKey("a%2Fb")).toBe(false);
});
});
describe("userOverridesPath (IMP-52 u3)", () => {
it("resolves <root>/data/user_overrides/<key>.json regardless of OS sep", () => {
const root = path.join("X:", "design_agent");
const got = userOverridesPath(root, "03");
expect(got).toBe(path.join(root, "data", "user_overrides", "03.json"));
});
});
describe("handleGetUserOverrides (IMP-52 u3)", () => {
let tmpRoot: string;
let overridesDir: string;
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u3-"));
overridesDir = path.join(tmpRoot, "data", "user_overrides");
fs.mkdirSync(overridesDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
it("returns false (next chained) when method != GET", () => {
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "PUT", url: "/03" },
res,
tmpRoot,
);
expect(handled).toBe(false);
// Crucial for u4: PUT must reach its own middleware unobstructed.
expect(state.ended).toBe(false);
expect(state.statusCode).toBe(0);
});
it("returns 400 on invalid key (path traversal)", () => {
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/../escape" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body)).toEqual({ error: "invalid key" });
});
it("returns 400 on invalid key (missing key segment)", () => {
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(400);
});
it("returns 200 {} on missing file (graceful degrade)", () => {
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/03" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(200);
expect(state.body).toBe("{}");
});
it("returns 200 {} on corrupt JSON (graceful degrade)", () => {
fs.writeFileSync(path.join(overridesDir, "03.json"), "{not json", "utf-8");
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/03" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(200);
expect(state.body).toBe("{}");
});
it("returns 200 {} when JSON root is not an object", () => {
// Mirrors u1 load() which treats non-object roots as corrupt — covers
// both arrays and primitives so the frontend never receives a shape
// the typed service (u5) can't deserialize.
fs.writeFileSync(
path.join(overridesDir, "arr.json"),
JSON.stringify([1, 2, 3]),
"utf-8",
);
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/arr" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(200);
expect(state.body).toBe("{}");
fs.writeFileSync(path.join(overridesDir, "num.json"), "42", "utf-8");
const { res: res2, state: state2 } = makeMockRes();
handleGetUserOverrides({ method: "GET", url: "/num" }, res2, tmpRoot);
expect(state2.statusCode).toBe(200);
expect(state2.body).toBe("{}");
});
it("returns 200 with parsed JSON object on hit", () => {
const payload = {
layout: "two_zone_split",
frames: { "03-1+03-2": "frame_07" },
zone_geometries: {
top: { x: 0.05, y: 0.1, w: 0.9, h: 0.3 },
},
zone_sections: { top: ["03-1", "03-2"] },
};
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify(payload),
"utf-8",
);
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/03" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(200);
expect(state.headers["Content-Type"]).toBe(
"application/json; charset=utf-8",
);
expect(JSON.parse(state.body)).toEqual(payload);
});
it("preserves foreign top-level keys in the response", () => {
// Forward-compat with future axes (e.g., zone_sizes, image_overrides).
// u1 save() preserves them on the disk side; u3 GET must surface them
// so the frontend service (u5) can decide whether to act on them.
const payload = {
layout: "single_zone",
zone_sizes: { top: 0.42 }, // not part of KNOWN_AXES yet
custom_extension: { foo: "bar" },
};
fs.writeFileSync(
path.join(overridesDir, "future.json"),
JSON.stringify(payload),
"utf-8",
);
const { res, state } = makeMockRes();
handleGetUserOverrides({ method: "GET", url: "/future" }, res, tmpRoot);
expect(state.statusCode).toBe(200);
expect(JSON.parse(state.body)).toEqual(payload);
});
it("strips the leading slash and ignores query string when keying", () => {
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify({ layout: "x" }),
"utf-8",
);
const { res, state } = makeMockRes();
handleGetUserOverrides(
{ method: "GET", url: "/03?ts=1747884800" },
res,
tmpRoot,
);
expect(state.statusCode).toBe(200);
expect(JSON.parse(state.body)).toEqual({ layout: "x" });
});
});
// ---------------------------------------------------------------------------
// IMP-52 u4 — PUT endpoint coverage
// ---------------------------------------------------------------------------
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => {
it("matches the Python KNOWN_AXES tuple in src/user_overrides_io.py", () => {
// The on-disk schema is shared with backend pipeline fallback (u2).
// Any drift here means a PUT could write an axis that the Python
// load() ignores, or vice-versa, silently losing user overrides.
expect(KNOWN_USER_OVERRIDES_AXES).toEqual([
"layout",
"zone_geometries",
"zone_sections",
"frames",
"image_overrides",
]);
});
});
describe("mergeUserOverrides (IMP-52 u4)", () => {
it("only mutates KNOWN_AXES present in partial", () => {
const existing = {
layout: "old",
frames: { "03-1": "frame_01" },
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
zone_sections: { top: ["03-1"] },
};
const merged = mergeUserOverrides(existing, { layout: "new" });
expect(merged.layout).toBe("new");
// axes not in partial are preserved
expect(merged.frames).toEqual({ "03-1": "frame_01" });
expect(merged.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.5 },
});
expect(merged.zone_sections).toEqual({ top: ["03-1"] });
});
it("preserves foreign top-level keys in existing", () => {
// Forward-compat: future axes (zone_sizes, schema_version, etc.) on
// disk must survive PUT writes that only touch the 5 in-scope axes.
// `image_overrides` is no longer a foreign key after IMP-51 #79 u2 —
// it joined KNOWN_USER_OVERRIDES_AXES — so we probe with axes that
// are still NOT in the allowlist.
const existing = {
layout: "old",
zone_sizes: { top: 0.42 },
schema_version: 2,
};
const merged = mergeUserOverrides(existing, { layout: "new" });
expect(merged.zone_sizes).toEqual({ top: 0.42 });
expect(merged.schema_version).toBe(2);
});
it("clears axis when partial value is null (explicit clear)", () => {
const existing = { layout: "x", frames: { "03-1": "f01" } };
const merged = mergeUserOverrides(existing, { layout: null });
expect("layout" in merged).toBe(false);
expect(merged.frames).toEqual({ "03-1": "f01" });
});
it("drops non-axis keys in partial (allowlist)", () => {
// PUT payload may carry junk fields (typo, malicious key); allowlist
// ensures only the 5 axes can be written to disk.
const merged = mergeUserOverrides(
{},
{ layout: "x", random_key: "evil", __proto__: "x" } as Record<
string,
unknown
>,
);
expect(merged.layout).toBe("x");
expect("random_key" in merged).toBe(false);
});
it("merges all 5 axes when present in partial", () => {
const merged = mergeUserOverrides(
{},
{
layout: "two_zone_split",
frames: { "03-1+03-2": "frame_07" },
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
zone_sections: { top: ["03-1", "03-2"] },
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
},
);
expect(Object.keys(merged).sort()).toEqual([
"frames",
"image_overrides",
"layout",
"zone_geometries",
"zone_sections",
]);
});
it("preserves image_overrides when absent from partial (5th axis IMP-51 #79 u2)", () => {
// Sibling axis of layout/frames/zone_geometries/zone_sections: a PUT
// that touches only layout must NOT erase the image_overrides map
// already on disk. Mirrors the partial-merge invariant for the 4
// pre-existing axes.
const existing = {
layout: "old",
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
};
const merged = mergeUserOverrides(existing, { layout: "new" });
expect(merged.image_overrides).toEqual({
"img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 },
});
expect(merged.layout).toBe("new");
});
it("clears image_overrides when partial value is null (explicit clear)", () => {
// Same null-sentinel contract as the 4 sibling axes — `null` removes
// the axis from disk so the next render reverts to baseline (no
// user image position/size override).
const existing = {
layout: "x",
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
};
const merged = mergeUserOverrides(existing, { image_overrides: null });
expect("image_overrides" in merged).toBe(false);
expect(merged.layout).toBe("x");
});
it("does not mutate the existing input", () => {
const existing = { layout: "old", frames: { a: "b" } };
const snapshot = JSON.parse(JSON.stringify(existing));
mergeUserOverrides(existing, { layout: "new", layout_evil: "x" } as Record<
string,
unknown
>);
expect(existing).toEqual(snapshot);
});
});
describe("atomicWriteUserOverrides (IMP-52 u4)", () => {
let tmpRoot: string;
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u4-aw-"));
});
afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
it("creates parent dir if missing and writes JSON content", () => {
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
expect(fs.existsSync(path.dirname(filePath))).toBe(false);
atomicWriteUserOverrides(filePath, { layout: "x" });
expect(fs.existsSync(filePath)).toBe(true);
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
layout: "x",
});
});
it("leaves no .tmp residue after a successful write", () => {
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
atomicWriteUserOverrides(filePath, { layout: "x" });
const dirContents = fs.readdirSync(path.dirname(filePath));
expect(dirContents).toEqual(["03.json"]);
});
it("overwrites an existing file atomically", () => {
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
atomicWriteUserOverrides(filePath, { layout: "v1" });
atomicWriteUserOverrides(filePath, { layout: "v2" });
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
layout: "v2",
});
});
});
// req mock — EventEmitter with method/url + a `send(body)` helper that
// emits the data chunk and then `end`, mirroring the node IncomingMessage
// flow used by vite's dev middlewares.
function makeMockReq(opts: {
method?: string;
url?: string;
}): EventEmitter & { method?: string; url?: string; send: (body: string) => void } {
const ee = new EventEmitter() as EventEmitter & {
method?: string;
url?: string;
send: (body: string) => void;
};
ee.method = opts.method;
ee.url = opts.url;
ee.send = (body: string) => {
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
ee.emit("end");
};
return ee;
}
describe("handlePutUserOverrides (IMP-52 u4)", () => {
let tmpRoot: string;
let overridesDir: string;
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u4-"));
overridesDir = path.join(tmpRoot, "data", "user_overrides");
});
afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
it("returns false (next chained) when method != PUT", () => {
const req = makeMockReq({ method: "GET", url: "/03" });
const { res, state } = makeMockRes();
const handled = handlePutUserOverrides(req, res, tmpRoot);
expect(handled).toBe(false);
expect(state.ended).toBe(false);
});
it("returns 400 on invalid key", () => {
const req = makeMockReq({ method: "PUT", url: "/../escape" });
const { res, state } = makeMockRes();
const handled = handlePutUserOverrides(req, res, tmpRoot);
expect(handled).toBe(true);
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body)).toEqual({ error: "invalid key" });
});
it("returns 400 on invalid JSON body", () => {
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send("{not json");
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body)).toEqual({ error: "invalid JSON" });
// file MUST NOT have been created on parse failure
expect(fs.existsSync(path.join(overridesDir, "03.json"))).toBe(false);
});
it("returns 400 when JSON body is an array", () => {
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(JSON.stringify([1, 2, 3]));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body)).toEqual({
error: "body must be a JSON object",
});
});
it("returns 400 when JSON body is a primitive", () => {
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send("42");
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body)).toEqual({
error: "body must be a JSON object",
});
});
it("creates the override file on first PUT and returns merged body", () => {
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
const payload = { layout: "two_zone_split" };
req.send(JSON.stringify(payload));
expect(state.statusCode).toBe(200);
expect(state.headers["Content-Type"]).toBe(
"application/json; charset=utf-8",
);
expect(JSON.parse(state.body)).toEqual({ layout: "two_zone_split" });
const filePath = path.join(overridesDir, "03.json");
expect(fs.existsSync(filePath)).toBe(true);
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
layout: "two_zone_split",
});
});
it("partial-merges: axes absent from payload are preserved on disk", () => {
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify({
layout: "old",
frames: { "03-1": "frame_01" },
zone_sections: { top: ["03-1"] },
}),
"utf-8",
);
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(JSON.stringify({ layout: "new" }));
expect(state.statusCode).toBe(200);
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
);
expect(onDisk).toEqual({
layout: "new",
frames: { "03-1": "frame_01" },
zone_sections: { top: ["03-1"] },
});
});
it("preserves foreign top-level keys on disk (forward-compat)", () => {
// `image_overrides` is no longer a foreign key after IMP-51 #79 u2;
// probe with axes that are still NOT in KNOWN_USER_OVERRIDES_AXES.
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(
path.join(overridesDir, "future.json"),
JSON.stringify({
layout: "old",
zone_sizes: { top: 0.42 },
schema_version: 2,
}),
"utf-8",
);
const req = makeMockReq({ method: "PUT", url: "/future" });
const { res } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(JSON.stringify({ layout: "new" }));
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "future.json"), "utf-8"),
);
expect(onDisk.zone_sizes).toEqual({ top: 0.42 });
expect(onDisk.schema_version).toBe(2);
expect(onDisk.layout).toBe("new");
});
it("persists image_overrides partial-merge and preserves sibling axes (IMP-51 #79 u2)", () => {
// 5th axis end-to-end PUT round-trip: writing only image_overrides
// must NOT touch the 4 sibling axes already on disk. Mirrors the
// existing partial-merge test for layout above.
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify({
layout: "two_zone_split",
frames: { "03-1": "frame_01" },
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
zone_sections: { top: ["03-1"] },
}),
"utf-8",
);
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(
JSON.stringify({
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
}),
);
expect(state.statusCode).toBe(200);
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
);
expect(onDisk).toEqual({
layout: "two_zone_split",
frames: { "03-1": "frame_01" },
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
zone_sections: { top: ["03-1"] },
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
});
});
it("drops non-axis payload keys (allowlist enforced at write)", () => {
fs.mkdirSync(overridesDir, { recursive: true });
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(
JSON.stringify({
layout: "two_zone_split",
random_evil_key: "should not persist",
}),
);
expect(state.statusCode).toBe(200);
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
);
expect(onDisk).toEqual({ layout: "two_zone_split" });
expect("random_evil_key" in onDisk).toBe(false);
});
it("clears an axis when payload sets it to null", () => {
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify({ layout: "old", frames: { "03-1": "f01" } }),
"utf-8",
);
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(JSON.stringify({ layout: null }));
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
);
expect("layout" in onDisk).toBe(false);
expect(onDisk.frames).toEqual({ "03-1": "f01" });
});
it("recovers from corrupt existing file (graceful degrade)", () => {
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(
path.join(overridesDir, "03.json"),
"{this is not JSON",
"utf-8",
);
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(JSON.stringify({ layout: "recovered" }));
expect(state.statusCode).toBe(200);
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
);
expect(onDisk).toEqual({ layout: "recovered" });
});
it("treats array-rooted existing file as empty (graceful degrade)", () => {
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify(["not", "an", "object"]),
"utf-8",
);
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(JSON.stringify({ layout: "recovered" }));
expect(state.statusCode).toBe(200);
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
);
expect(onDisk).toEqual({ layout: "recovered" });
});
it("strips the leading slash and ignores query string when keying", () => {
const req = makeMockReq({
method: "PUT",
url: "/03?ts=1747884800",
});
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send(JSON.stringify({ layout: "x" }));
expect(state.statusCode).toBe(200);
expect(fs.existsSync(path.join(overridesDir, "03.json"))).toBe(true);
});
it("accepts an empty body as a no-op partial (no axes mutated)", () => {
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify({ layout: "kept" }),
"utf-8",
);
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
req.send("");
expect(state.statusCode).toBe(200);
const onDisk = JSON.parse(
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
);
expect(onDisk).toEqual({ layout: "kept" });
});
it("accepts a chunked PUT body (concatenates data events)", () => {
const req = makeMockReq({ method: "PUT", url: "/03" });
const { res, state } = makeMockRes();
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
const body = JSON.stringify({
layout: "two_zone_split",
frames: { "03-1": "frame_01" },
});
// Emit in two halves to simulate a fragmented HTTP body.
const half = Math.floor(body.length / 2);
req.emit("data", Buffer.from(body.slice(0, half), "utf-8"));
req.emit("data", Buffer.from(body.slice(half), "utf-8"));
req.emit("end");
expect(state.statusCode).toBe(200);
expect(JSON.parse(state.body)).toEqual({
layout: "two_zone_split",
frames: { "03-1": "frame_01" },
});
});
});
@@ -0,0 +1,462 @@
// IMP-52 u6 — vitest coverage for restore-on-reopen helpers used by
// `Home.tsx` to layer persisted `user_overrides.json` payloads onto the
// in-memory `UserSelection` and `slidePlan`.
//
// Scope (Stage 2 unit u6 contract):
// 1) deriveUserOverridesKey(filename) — MDX-stem key derivation that
// matches backend u2 fallback's `Path(args.mdx_path).stem`. Strips
// `.mdx` case-insensitively; preserves everything else.
// 2) applyPersistedNonFrameOverrides(selection, persisted) — layers
// layout / zone_geometries / zone_sections onto an existing selection.
// Frames are NOT layered here (unit_id key requires slidePlan).
// Foreign / unrecognized payloads degrade silently (no throw, no
// partial mutation).
// 3) remapPersistedFramesToZoneFrames(slidePlan, framesByUnitId) —
// remaps frames (unit_id → template_id) to zone_frames (region.id →
// template_id). Stale unit_ids (no matching zone) drop silently;
// zones without internal_regions[0] or without section_ids are
// skipped without throwing.
//
// All helpers are pure; tests run in vitest's default node environment
// without RTL / jsdom. Home.tsx wiring sites (handleFileUpload pre-Generate
// seed + handleGenerate post-loadRun frame remap) are 1-line call sites that
// these helpers cover end-to-end.
import { describe, it, expect } from "vitest";
import type {
LayoutPresetId,
SlidePlan,
UserSelection,
Zone,
} from "../src/types/designAgent";
import {
applyPersistedNonFrameOverrides,
createInitialUserSelection,
deriveUserOverridesKey,
remapPersistedFramesToZoneFrames,
saveImageOverride,
} from "../src/utils/slidePlanUtils";
// ─── Fixtures ───────────────────────────────────────────────────────────────
function makeSelection(overrides?: Partial<UserSelection["overrides"]>): UserSelection {
return {
selectedSectionId: null,
selectedZoneId: null,
selectedRegionId: null,
overrides: {
layout_preset: undefined,
zone_frames: {},
zone_sections: {},
zone_sizes: {},
zone_geometries: {},
// IMP-51 (#79) u11 — keep the fixture in sync with the 5th persisted
// axis declared on `UserSelection.overrides`. Empty by default so the
// existing IMP-52 cases remain unchanged in shape.
image_overrides: {},
...overrides,
},
};
}
function makeZone(
partial: { id: string; zone_id: string; section_ids: string[]; region_id?: string },
): Zone {
return {
id: partial.id,
zone_id: partial.zone_id,
section_ids: partial.section_ids,
position: { x: 0, y: 0, width: 1, height: 1 },
internal_regions: [
{
id: partial.region_id ?? `${partial.id}-r0`,
region_id: "region-single",
role: "primary",
content_type: "text_block",
ratio_estimate: 1,
content_unit_ids: [],
frame_match_strategy: {
kind: "frame_match",
frame_id: null,
display_strategy: "inline_full",
},
frame_candidates: [],
},
],
};
}
function makeSlidePlan(zones: Zone[], layout: LayoutPresetId = "single"): SlidePlan {
return {
id: "plan-1",
title: "test plan",
layout_preset: layout,
zones,
};
}
// ─── deriveUserOverridesKey ─────────────────────────────────────────────────
describe("deriveUserOverridesKey (IMP-52 u6)", () => {
it("strips trailing .mdx", () => {
expect(deriveUserOverridesKey("03__DX_BIM_value_chain.mdx")).toBe(
"03__DX_BIM_value_chain",
);
});
it("strips .MDX case-insensitively", () => {
expect(deriveUserOverridesKey("04_demo.MDX")).toBe("04_demo");
expect(deriveUserOverridesKey("05_intro.Mdx")).toBe("05_intro");
});
it("returns the filename unchanged when no .mdx suffix", () => {
expect(deriveUserOverridesKey("03__DX_BIM_value_chain")).toBe(
"03__DX_BIM_value_chain",
);
expect(deriveUserOverridesKey("notes.txt")).toBe("notes.txt");
});
it("only strips the final .mdx, preserves dots inside the stem", () => {
expect(deriveUserOverridesKey("05.2_layer.mdx")).toBe("05.2_layer");
});
it("returns empty string for empty input", () => {
expect(deriveUserOverridesKey("")).toBe("");
});
it("matches backend Path(args.mdx_path).stem for the canonical demo MDXs", () => {
// These are the three canonical samples loaded by /api/sample-mdx; the
// key on both ends must agree so a write from frontend (PUT) is found
// by backend (u2 fallback on next pipeline run).
expect(deriveUserOverridesKey("03_demo.mdx")).toBe("03_demo");
expect(deriveUserOverridesKey("04_demo.mdx")).toBe("04_demo");
expect(deriveUserOverridesKey("05_demo.mdx")).toBe("05_demo");
});
});
// ─── applyPersistedNonFrameOverrides ────────────────────────────────────────
describe("applyPersistedNonFrameOverrides (IMP-52 u6)", () => {
it("layers layout / zone_geometries / zone_sections", () => {
const sel = makeSelection();
const persisted = {
layout: "horizontal-2",
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.4 } },
zone_sections: { top: ["03-1"], bottom: ["03-2"] },
} as const;
const next = applyPersistedNonFrameOverrides(sel, persisted);
expect(next.overrides.layout_preset).toBe("horizontal-2");
expect(next.overrides.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.4 },
});
expect(next.overrides.zone_sections).toEqual({
top: ["03-1"],
bottom: ["03-2"],
});
});
it("does NOT layer frames (frames need post-loadRun remap)", () => {
const sel = makeSelection({ zone_frames: { "r-existing": "tpl-existing" } });
const persisted = {
frames: { "03-1+03-2": "tpl-persisted" },
};
const next = applyPersistedNonFrameOverrides(sel, persisted);
// zone_frames is untouched here; the post-loadRun remap step owns it.
expect(next.overrides.zone_frames).toEqual({ "r-existing": "tpl-existing" });
});
it("rejects layout values outside the 8 known preset ids", () => {
const sel = makeSelection({ layout_preset: "single" });
const next = applyPersistedNonFrameOverrides(sel, {
layout: "rogue-layout" as unknown as string,
});
// Stays at the original — preset whitelist guards against hand-edited
// files or future schema drift.
expect(next.overrides.layout_preset).toBe("single");
});
it("ignores zone_geometries when the payload axis is an array", () => {
const sel = makeSelection({ zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } } });
const next = applyPersistedNonFrameOverrides(sel, {
zone_geometries: [] as unknown as Record<string, { x: number; y: number; w: number; h: number }>,
});
expect(next.overrides.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.5 },
});
});
it("returns the selection unchanged when persisted is null / undefined / non-object", () => {
const sel = makeSelection({ layout_preset: "single" });
expect(applyPersistedNonFrameOverrides(sel, null)).toEqual(sel);
expect(applyPersistedNonFrameOverrides(sel, undefined)).toEqual(sel);
});
it("returns the selection unchanged when persisted is empty {}", () => {
const sel = makeSelection({ layout_preset: "single" });
const next = applyPersistedNonFrameOverrides(sel, {});
expect(next.overrides.layout_preset).toBe("single");
expect(next.overrides.zone_geometries).toEqual({});
expect(next.overrides.zone_sections).toEqual({});
});
it("returns a NEW selection object (no mutation of input)", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, { layout: "vertical-2" });
expect(next).not.toBe(sel);
expect(next.overrides).not.toBe(sel.overrides);
// Input still pristine.
expect(sel.overrides.layout_preset).toBeUndefined();
});
});
// ─── remapPersistedFramesToZoneFrames ───────────────────────────────────────
describe("remapPersistedFramesToZoneFrames (IMP-52 u6)", () => {
it("maps unit_id (section_ids joined by +) to region.id", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
makeZone({ id: "z-bot", zone_id: "bottom", section_ids: ["03-2", "03-3"], region_id: "r-bot" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"03-1": "tpl-a",
"03-2+03-3": "tpl-b",
});
expect(remapped).toEqual({
"r-top": "tpl-a",
"r-bot": "tpl-b",
});
});
it("silently drops persisted entries whose unit_id matches no zone", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"03-1": "tpl-a",
"stale-section-id": "tpl-stale", // user changed zone_sections between sessions
});
expect(remapped).toEqual({ "r-top": "tpl-a" });
});
it("returns {} when slidePlan is null / undefined", () => {
expect(remapPersistedFramesToZoneFrames(null, { "03-1": "tpl-a" })).toEqual({});
expect(remapPersistedFramesToZoneFrames(undefined, { "03-1": "tpl-a" })).toEqual({});
});
it("returns {} when framesByUnitId is null / undefined / {}", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
expect(remapPersistedFramesToZoneFrames(plan, null)).toEqual({});
expect(remapPersistedFramesToZoneFrames(plan, undefined)).toEqual({});
expect(remapPersistedFramesToZoneFrames(plan, {})).toEqual({});
});
it("skips zones with empty section_ids (no unit_id to derive)", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-empty", zone_id: "empty", section_ids: [], region_id: "r-empty" }),
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"": "tpl-should-not-match-empty-join",
"03-1": "tpl-a",
});
expect(remapped).toEqual({ "r-top": "tpl-a" });
});
it("skips zones without internal_regions[0]", () => {
const plan: SlidePlan = {
id: "plan-x",
title: "no regions",
layout_preset: "single",
zones: [
{
id: "z-bare",
zone_id: "bare",
section_ids: ["03-1"],
position: { x: 0, y: 0, width: 1, height: 1 },
internal_regions: [],
},
],
};
expect(remapPersistedFramesToZoneFrames(plan, { "03-1": "tpl-a" })).toEqual({});
});
it("ignores persisted entries with empty / non-string template_id", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"03-1": "" as unknown as string,
});
expect(remapped).toEqual({});
});
it("preserves the user-selected template even when slidePlan layout would imply a different default", () => {
// Backend u2 fallback should already have applied the user's frame
// override via CLI args, but if the plan's default frame_match_strategy
// disagrees, the post-loadRun remap still surfaces the user's choice
// for the SlideCanvas override-vs-default preview indicator.
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"03-1": "user-chosen-tpl",
});
expect(remapped["r-top"]).toBe("user-chosen-tpl");
});
});
// ─── IMP-51 (#79) u11 — image_overrides axis ────────────────────────────────
// New 5th persisted axis. The on-disk schema (KNOWN_AXES,
// src/user_overrides_io.py u1), the typed client
// (services/userOverridesApi.ts u3 ImageOverridesOverride), the Vite
// allowlist (vite.config.ts u2), and the backend CLI flag (--override-image
// in src/phase_z2_pipeline.py u5) all expect `image_id` → percent-of-slide
// geometry. u11 owns the in-memory mirror on `UserSelection.overrides`
// (declared in types/designAgent.ts) plus the three pure helpers that
// Home.tsx (u10) wires:
// • applyPersistedNonFrameOverrides — restore-on-reopen layer.
// • createInitialUserSelection — fresh-slide initializer.
// • saveImageOverride — single-image record helper invoked by the
// SlideCanvas u8 drag/resize handler.
describe("image_overrides axis — applyPersistedNonFrameOverrides (IMP-51 u11)", () => {
it("layers a flat image_overrides dict onto the selection", () => {
const sel = makeSelection();
const persisted = {
image_overrides: {
"img-abc1234567": { x: 10, y: 15, w: 30.5, h: 25 },
"img-deadbeef00": { x: 50, y: 50, w: 40, h: 40 },
},
};
const next = applyPersistedNonFrameOverrides(sel, persisted);
expect(next.overrides.image_overrides).toEqual({
"img-abc1234567": { x: 10, y: 15, w: 30.5, h: 25 },
"img-deadbeef00": { x: 50, y: 50, w: 40, h: 40 },
});
// Untouched axes stay at their fixture defaults so the round-trip is
// safe to interleave with the other four axes.
expect(next.overrides.zone_geometries).toEqual({});
expect(next.overrides.zone_sections).toEqual({});
expect(next.overrides.layout_preset).toBeUndefined();
});
it("ignores image_overrides when the payload axis is an array", () => {
const sel = makeSelection({
image_overrides: { "img-existing00": { x: 1, y: 2, w: 30, h: 40 } },
});
const next = applyPersistedNonFrameOverrides(sel, {
image_overrides: [] as unknown as Record<
string,
{ x: number; y: number; w: number; h: number }
>,
});
// Same guard the zone_geometries branch uses — array payloads from a
// hand-edited file are rejected and the prior in-memory value stays.
expect(next.overrides.image_overrides).toEqual({
"img-existing00": { x: 1, y: 2, w: 30, h: 40 },
});
});
it("ignores image_overrides when the payload axis is null", () => {
const sel = makeSelection({
image_overrides: { "img-existing00": { x: 0, y: 0, w: 100, h: 100 } },
});
const next = applyPersistedNonFrameOverrides(sel, {
image_overrides: null,
});
expect(next.overrides.image_overrides).toEqual({
"img-existing00": { x: 0, y: 0, w: 100, h: 100 },
});
});
it("layers image_overrides alongside the four IMP-52 axes in one call", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
layout: "horizontal-2",
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.4 } },
zone_sections: { top: ["03-1"] },
image_overrides: { "img-abc1234567": { x: 25, y: 25, w: 50, h: 50 } },
});
expect(next.overrides.layout_preset).toBe("horizontal-2");
expect(next.overrides.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.4 },
});
expect(next.overrides.zone_sections).toEqual({ top: ["03-1"] });
expect(next.overrides.image_overrides).toEqual({
"img-abc1234567": { x: 25, y: 25, w: 50, h: 50 },
});
});
it("seeds an empty image_overrides on a fresh selection (createInitialUserSelection)", () => {
const sel = createInitialUserSelection();
expect(sel.overrides.image_overrides).toEqual({});
// Mirrors the shape Home.tsx receives before any user interaction —
// SlideCanvas u8 expects the axis to exist (not undefined) so its
// `Object.entries(measured + persisted)` merge never crashes.
});
});
describe("image_overrides axis — saveImageOverride (IMP-51 u11)", () => {
const ID_A = "img-abc1234567";
const ID_B = "img-deadbeef00";
it("adds a new image_id entry on an empty axis", () => {
const sel = makeSelection();
const next = saveImageOverride(sel, ID_A, { x: 10, y: 15, w: 30.5, h: 25 });
expect(next.overrides.image_overrides).toEqual({
[ID_A]: { x: 10, y: 15, w: 30.5, h: 25 },
});
});
it("replaces an existing entry under the same image_id (most recent drag wins)", () => {
const sel = makeSelection({
image_overrides: { [ID_A]: { x: 0, y: 0, w: 20, h: 20 } },
});
const next = saveImageOverride(sel, ID_A, { x: 50, y: 50, w: 30, h: 30 });
expect(next.overrides.image_overrides).toEqual({
[ID_A]: { x: 50, y: 50, w: 30, h: 30 },
});
});
it("preserves sibling image_id entries when adding a new one", () => {
const sel = makeSelection({
image_overrides: { [ID_A]: { x: 10, y: 10, w: 20, h: 20 } },
});
const next = saveImageOverride(sel, ID_B, { x: 60, y: 60, w: 30, h: 30 });
expect(next.overrides.image_overrides).toEqual({
[ID_A]: { x: 10, y: 10, w: 20, h: 20 },
[ID_B]: { x: 60, y: 60, w: 30, h: 30 },
});
});
it("does NOT touch the other four override axes", () => {
const sel = makeSelection({
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
zone_sections: { top: ["03-1"] },
zone_frames: { "r-top": "tpl-a" },
layout_preset: "horizontal-2",
});
const next = saveImageOverride(sel, ID_A, { x: 10, y: 10, w: 20, h: 20 });
expect(next.overrides.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.5 },
});
expect(next.overrides.zone_sections).toEqual({ top: ["03-1"] });
expect(next.overrides.zone_frames).toEqual({ "r-top": "tpl-a" });
expect(next.overrides.layout_preset).toBe("horizontal-2");
});
it("returns a NEW selection object (no input mutation)", () => {
const sel = makeSelection({
image_overrides: { [ID_A]: { x: 0, y: 0, w: 10, h: 10 } },
});
const before = { ...sel.overrides.image_overrides };
const next = saveImageOverride(sel, ID_B, { x: 30, y: 30, w: 20, h: 20 });
expect(next).not.toBe(sel);
expect(next.overrides).not.toBe(sel.overrides);
expect(next.overrides.image_overrides).not.toBe(sel.overrides.image_overrides);
// Input still pristine.
expect(sel.overrides.image_overrides).toEqual(before);
});
});
@@ -0,0 +1,561 @@
// IMP-52 u5 — vitest coverage for the typed frontend client at
// `Front/client/src/services/userOverridesApi.ts`.
//
// Scope (Stage 2 unit u5 contract):
// 1) getUserOverrides:
// • 200 with object body → typed payload echoed.
// • 200 with array / primitive / non-JSON body → {} (graceful).
// • 4xx / 5xx → {}.
// • fetch reject (network) → {} (no throw to caller).
// 2) saveUserOverrides:
// • Single call: PUT fires after exactly 300 ms with the mutated-axis
// partial as body (NOT a full snapshot of UserOverrides).
// • Rapid coalescing: N calls in <300 ms window collapse to ONE PUT
// carrying the union of mutated axes.
// • Per-axis later-wins: later call's value replaces earlier pending
// value for the same axis; axes the user did not touch stay absent.
// • null sentinel: forwarded verbatim so u4 mergeUserOverrides can
// `delete` the axis on disk.
// • Per-key isolation: rapid edits to "03" do not delay flush of "04".
// • Promise resolves with the server-side merged document.
// • Promise rejects on 4xx/5xx and on fetch reject.
// 3) flushUserOverrides:
// • No arg → flushes all pending buckets immediately (no 300 ms wait).
// • Specific key → flushes only that bucket; other buckets stay
// pending.
// • No-op when no buckets are pending.
//
// All tests mock `fetch` and use `vi.useFakeTimers()` to make the 300 ms
// debounce deterministic — no real wall-clock waits.
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type Mock,
} from "vitest";
import {
__resetUserOverridesBuckets_FOR_TEST,
flushUserOverrides,
getUserOverrides,
saveUserOverrides,
type UserOverridesPartial,
} from "../src/services/userOverridesApi";
// ---------------------------------------------------------------------------
// fetch mock — minimal Response stub with the two methods the service uses
// (.ok / .status / .json()). We track the call log so debounce + coalescing
// can be asserted by counting PUTs and inspecting their bodies.
// ---------------------------------------------------------------------------
type MockResponse = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
};
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
return {
ok,
status,
json: async () => body,
};
}
let fetchMock: Mock;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
vi.useFakeTimers();
__resetUserOverridesBuckets_FOR_TEST();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
__resetUserOverridesBuckets_FOR_TEST();
});
// Microtask-flushing helper. vi.advanceTimersByTime fires timers, but the
// promise chain inside flushBucket (await fetch → await res.json() → resolve
// waiters) needs the microtask queue to drain before assertions run.
async function drainMicrotasks(): Promise<void> {
// Multiple ticks because each `await` in flushBucket adds another tick.
for (let i = 0; i < 4; i++) {
await Promise.resolve();
}
}
function lastPutBody(): unknown {
const lastCall = fetchMock.mock.calls.at(-1);
if (!lastCall) throw new Error("fetch was not called");
const init = lastCall[1] as RequestInit | undefined;
if (!init?.body) throw new Error("fetch was called without a body");
return JSON.parse(String(init.body));
}
function putCallsCount(): number {
return fetchMock.mock.calls.filter(
(call) => (call[1] as RequestInit | undefined)?.method === "PUT",
).length;
}
// ============================================================================
// getUserOverrides
// ============================================================================
describe("getUserOverrides (IMP-52 u5)", () => {
it("issues GET against /api/user-overrides/<key>", async () => {
fetchMock.mockResolvedValueOnce(mockResponse({ layout: "x" }));
await getUserOverrides("03");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/user-overrides/03");
expect((init as RequestInit).method).toBe("GET");
});
it("returns the parsed object on 200 with object body", async () => {
const payload = {
layout: "two_zone_split",
frames: { "03-1+03-2": "frame_07" },
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
zone_sections: { top: ["03-1", "03-2"] },
};
fetchMock.mockResolvedValueOnce(mockResponse(payload));
const got = await getUserOverrides("03");
expect(got).toEqual(payload);
});
it("returns {} when JSON root is an array (mirrors u3 graceful degrade)", async () => {
fetchMock.mockResolvedValueOnce(mockResponse([1, 2, 3]));
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} when JSON root is a primitive", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(42));
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} when JSON root is null", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(null));
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} on 4xx (invalid key path from u3)", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({ error: "invalid key" }, false, 400),
);
expect(await getUserOverrides("..")).toEqual({});
});
it("returns {} on 5xx", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({ error: "boom" }, false, 500),
);
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} when response.json() throws (non-JSON body)", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError("Unexpected token");
},
});
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} when fetch rejects (network error) — does NOT throw", async () => {
fetchMock.mockRejectedValueOnce(new Error("network down"));
await expect(getUserOverrides("03")).resolves.toEqual({});
});
});
// ============================================================================
// saveUserOverrides — debounce + coalescing
// ============================================================================
describe("saveUserOverrides (IMP-52 u5) — debounce", () => {
it("does NOT fire fetch before 300 ms have elapsed", async () => {
fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" }));
void saveUserOverrides("03", { layout: "two_zone_split" });
vi.advanceTimersByTime(299);
await drainMicrotasks();
expect(putCallsCount()).toBe(0);
});
it("fires exactly one PUT at the 300 ms boundary", async () => {
fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" }));
void saveUserOverrides("03", { layout: "two_zone_split" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
const lastCall = fetchMock.mock.calls.at(-1)!;
expect(lastCall[0]).toBe("/api/user-overrides/03");
expect((lastCall[1] as RequestInit).method).toBe("PUT");
expect((lastCall[1] as RequestInit).headers).toMatchObject({
"Content-Type": "application/json",
});
expect(lastPutBody()).toEqual({ layout: "two_zone_split" });
});
it("PUT body contains ONLY the mutated axis (not a full snapshot)", async () => {
// The frontend handler only knows the axis it just mutated; the server
// is responsible for partial-merge against axes already on disk.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", {
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["zone_geometries"]);
expect("layout" in body).toBe(false);
expect("frames" in body).toBe(false);
expect("zone_sections" in body).toBe(false);
});
it("coalesces N rapid calls into a SINGLE PUT after the debounce", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "old" });
vi.advanceTimersByTime(100);
void saveUserOverrides("03", { frames: { "03-1": "frame_01" } });
vi.advanceTimersByTime(100);
void saveUserOverrides("03", { zone_sections: { top: ["03-1"] } });
vi.advanceTimersByTime(100);
// After 300 ms total (but the timer was reset each call to start the
// 300 ms window over), so we need one more 300 ms to fire.
expect(putCallsCount()).toBe(0);
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
// All three axes accumulated.
const body = lastPutBody() as Record<string, unknown>;
expect(body).toEqual({
layout: "old",
frames: { "03-1": "frame_01" },
zone_sections: { top: ["03-1"] },
});
});
it("per-axis later-wins: same axis mutated twice keeps the LAST value", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "first" });
void saveUserOverrides("03", { layout: "second" });
void saveUserOverrides("03", { layout: "final" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({ layout: "final" });
});
it("forwards null sentinel verbatim (explicit clear)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: null });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(lastPutBody()).toEqual({ layout: null });
});
it("null can override a prior non-null pending value for the same axis", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "two_zone_split" });
void saveUserOverrides("03", { layout: null });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(lastPutBody()).toEqual({ layout: null });
});
it("resolves the caller promise with the server-merged document", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({
layout: "two_zone_split",
// server's view includes axes preserved on disk that the partial
// PUT did NOT carry — confirms we surface the full merged state.
frames: { "03-1": "frame_01" },
}),
);
const p = saveUserOverrides("03", { layout: "two_zone_split" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
await expect(p).resolves.toEqual({
layout: "two_zone_split",
frames: { "03-1": "frame_01" },
});
});
it("rejects all coalesced waiters on 5xx response", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({ error: "write failed" }, false, 500),
);
const p1 = saveUserOverrides("03", { layout: "x" });
const p2 = saveUserOverrides("03", { frames: { "03-1": "f01" } });
vi.advanceTimersByTime(300);
await drainMicrotasks();
await expect(p1).rejects.toThrow(/500/);
await expect(p2).rejects.toThrow(/500/);
});
it("rejects waiters on fetch network error", async () => {
fetchMock.mockRejectedValueOnce(new Error("ECONNRESET"));
const p = saveUserOverrides("03", { layout: "x" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
await expect(p).rejects.toThrow("ECONNRESET");
});
it("after a successful flush, a new save starts a fresh debounce window", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "first" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({ layout: "first" });
void saveUserOverrides("03", { layout: "second" });
vi.advanceTimersByTime(299);
await drainMicrotasks();
expect(putCallsCount()).toBe(1); // not fired yet
vi.advanceTimersByTime(1);
await drainMicrotasks();
expect(putCallsCount()).toBe(2);
expect(lastPutBody()).toEqual({ layout: "second" });
});
});
// ============================================================================
// saveUserOverrides — per-key isolation
// ============================================================================
describe("saveUserOverrides (IMP-52 u5) — per-key isolation", () => {
it("rapid edits to key A do not delay key B's flush", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
// Schedule a save on "03"
void saveUserOverrides("03", { layout: "x" });
// Schedule a save on "04" at t=0
void saveUserOverrides("04", { layout: "y" });
vi.advanceTimersByTime(150);
// Keep extending "03"'s window
void saveUserOverrides("03", { layout: "x2" });
// "04" should still fire at t=300 (untouched after first call)
vi.advanceTimersByTime(150); // t=300
await drainMicrotasks();
const puts = fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
);
expect(puts.length).toBe(1);
expect(puts[0][0]).toBe("/api/user-overrides/04");
expect(JSON.parse(String((puts[0][1] as RequestInit).body))).toEqual({
layout: "y",
});
});
it("each key's PUT carries only that key's mutated axes", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "for-03" });
void saveUserOverrides("04", { frames: { "04-1": "frame_05" } });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const puts = fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
);
expect(puts.length).toBe(2);
const byUrl = new Map(
puts.map((c) => [
c[0],
JSON.parse(String((c[1] as RequestInit).body)) as Record<
string,
unknown
>,
]),
);
expect(byUrl.get("/api/user-overrides/03")).toEqual({ layout: "for-03" });
expect(byUrl.get("/api/user-overrides/04")).toEqual({
frames: { "04-1": "frame_05" },
});
});
});
// ============================================================================
// flushUserOverrides
// ============================================================================
describe("flushUserOverrides (IMP-52 u5)", () => {
it("with no arg, flushes ALL pending buckets immediately (no 300 ms wait)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "x" });
void saveUserOverrides("04", { layout: "y" });
expect(putCallsCount()).toBe(0);
const flushP = flushUserOverrides();
await drainMicrotasks();
await flushP;
expect(putCallsCount()).toBe(2);
});
it("with a key arg, flushes only that bucket; others stay pending", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "x" });
void saveUserOverrides("04", { layout: "y" });
await flushUserOverrides("03");
await drainMicrotasks();
const puts = fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
);
expect(puts.length).toBe(1);
expect(puts[0][0]).toBe("/api/user-overrides/03");
// "04" should still fire at the regular 300 ms boundary.
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(2);
});
it("is a no-op when no buckets are pending", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
await flushUserOverrides();
expect(fetchMock).not.toHaveBeenCalled();
});
it("resolves the original saveUserOverrides promise via the in-flight PUT", async () => {
fetchMock.mockResolvedValueOnce(mockResponse({ layout: "flushed" }));
const savePromise = saveUserOverrides("03", { layout: "flushed" });
const flushPromise = flushUserOverrides();
await drainMicrotasks();
await flushPromise;
await expect(savePromise).resolves.toEqual({ layout: "flushed" });
});
it("propagates PUT failure as caller rejection (flush itself swallows)", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({ error: "boom" }, false, 500),
);
const savePromise = saveUserOverrides("03", { layout: "x" });
// flush itself should not throw — the original waiter takes the rejection.
const flushPromise = flushUserOverrides();
await drainMicrotasks();
await expect(flushPromise).resolves.toBeUndefined();
await expect(savePromise).rejects.toThrow(/500/);
});
});
// ============================================================================
// type-level export sanity check (compile-time evidence; runtime no-op)
// ============================================================================
describe("UserOverridesPartial type (IMP-52 u5)", () => {
it("permits per-axis null sentinels and partial keys", () => {
// Compile-time only — if any of these stops being a valid assignment,
// the test suite fails at build with a TS error before this assertion
// runs. The expect() is a placebo to keep vitest happy.
const a: UserOverridesPartial = { layout: "x" };
const b: UserOverridesPartial = { layout: null };
const c: UserOverridesPartial = { frames: { unit: "tmpl" } };
const d: UserOverridesPartial = {};
const e: UserOverridesPartial = {
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
};
const f: UserOverridesPartial = { image_overrides: null };
expect([a, b, c, d, e, f]).toHaveLength(6);
});
});
// ============================================================================
// IMP-51 #79 u3 — image_overrides axis (5th axis) parity coverage
//
// Same debounce / coalescing / clear / per-key isolation guarantees as the
// 4 sibling axes (layout / frames / zone_geometries / zone_sections), but
// asserted explicitly so a regression in the type or the runtime allowlist
// fails here instead of in a downstream u8~u11 handler.
// ============================================================================
describe("saveUserOverrides (IMP-51 #79 u3) — image_overrides axis", () => {
it("PUT body carries only image_overrides when that is the sole mutated axis", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", {
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["image_overrides"]);
expect(body.image_overrides).toEqual({
"img-1": { x: 10, y: 20, w: 30, h: 25 },
});
expect("layout" in body).toBe(false);
expect("frames" in body).toBe(false);
expect("zone_geometries" in body).toBe(false);
expect("zone_sections" in body).toBe(false);
});
it("per-axis later-wins: same image_id mutated twice keeps the LAST value", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", {
image_overrides: { "img-1": { x: 0, y: 0, w: 50, h: 50 } },
});
void saveUserOverrides("03", {
image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } },
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({
image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } },
});
});
it("forwards null sentinel verbatim (clear all image_overrides on disk)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { image_overrides: null });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(lastPutBody()).toEqual({ image_overrides: null });
});
it("coalesces with sibling axes in a single PUT", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "two_zone_split" });
void saveUserOverrides("03", {
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({
layout: "two_zone_split",
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
});
});
});
@@ -0,0 +1,569 @@
// IMP-52 u10 — Frontend write-side regression coverage.
//
// Stage 2 unit u10 contract:
// 1) All 4 in-scope mutation handlers persist their axis.
// 2) zone_sizes is NOT persisted (handleLayoutResize stays in-memory).
// 3) Write-before-Generate ordering — flushUserOverrides forces pending
// PUTs to commit before the pipeline run begins.
// 4) Restore-on-reopen end-to-end — getUserOverrides → non-frame layering
// and post-loadRun frame remap compose into a single restored state.
//
// React Testing Library is NOT installed in this repo (devDependencies has
// vitest only). Home.tsx's mutation handlers live inside `useCallback`
// closures so they cannot be invoked from a test without mounting the
// component. We cover them with two complementary tactics:
// • Source-pattern grep on Home.tsx that pins the exact wiring shape per
// handler. A regression that drops or rewires a `saveUserOverrides`
// call fails here loudly.
// • End-to-end mocked-fetch tests on the `userOverridesApi` flow with the
// payload shapes that Home.tsx produces — proves the contract the
// handlers depend on still holds.
//
// File extension is `.ts` (no JSX). All tests run in vitest's default node
// environment; fetch is stubbed with vi.stubGlobal and timers are faked so
// the 300ms debounce in `saveUserOverrides` is deterministic.
import * as fs from "node:fs";
import * as path from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type Mock,
} from "vitest";
import {
__resetUserOverridesBuckets_FOR_TEST,
flushUserOverrides,
getUserOverrides,
saveUserOverrides,
type UserOverridesPartial,
} from "../src/services/userOverridesApi";
import {
applyPersistedNonFrameOverrides,
createInitialUserSelection,
deriveUserOverridesKey,
remapPersistedFramesToZoneFrames,
} from "../src/utils/slidePlanUtils";
import type { SlidePlan, Zone } from "../src/types/designAgent";
// ─── Source-pattern regression ─────────────────────────────────────────────
// Without RTL we can't dispatch a click and read `fetch.mock.calls`. Instead
// we read Home.tsx as text and assert each in-scope handler closure contains
// the exact wiring that Stage 2 u7 specified. This is brittle in a good way:
// if a handler is renamed or its `saveUserOverrides` call is moved/removed,
// the assertion fires with a clear "X handler does not persist Y axis"
// message instead of silently regressing in prod.
const HOME_TSX_PATH = path.resolve(
__dirname,
"..",
"src",
"pages",
"Home.tsx",
);
const HOME_TSX = fs.readFileSync(HOME_TSX_PATH, "utf-8");
/**
* Slice the `const <name> = useCallback(...)` block out of Home.tsx. The
* handlers are well-formed and end either at the next `const handle...`
* declaration or at the next top-level `const ` at 2-space indent.
*/
function sliceHandler(source: string, name: string): string {
const start = source.indexOf(`const ${name} = useCallback(`);
if (start === -1) {
throw new Error(`handler "${name}" not found in Home.tsx`);
}
// Find the next handler / top-level const after `start`.
const nextHandler = source.indexOf("\n const handle", start + 1);
const nextConst = source.indexOf("\n const ", start + 1);
const candidates = [nextHandler, nextConst].filter((i) => i > start);
const end = candidates.length > 0 ? Math.min(...candidates) : source.length;
return source.slice(start, end);
}
describe("Home.tsx write-side wiring (IMP-52 u10) — source pattern", () => {
it("handleSectionDrop persists zone_sections behind uploadedFile gate", () => {
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
// gate
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
// axis key + value source
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?zone_sections:\s*finalSelection\.overrides\.zone_sections/,
);
// key derivation
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
});
it("handleLayoutSelect persists `layout` axis behind uploadedFile gate", () => {
const block = sliceHandler(HOME_TSX, "handleLayoutSelect");
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?layout:\s*layoutId\s*\}/,
);
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
});
it("handleZoneResize persists merged zone_geometries behind uploadedFile gate", () => {
const block = sliceHandler(HOME_TSX, "handleZoneResize");
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
// merged geometry (not the partial delta) is persisted so the on-disk
// axis is a complete snapshot of all currently-resized zones.
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?zone_geometries:\s*mergedGeometries/,
);
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
});
it("handleFrameSelect persists frames-by-unit_id with default-frame gate", () => {
const block = sliceHandler(HOME_TSX, "handleFrameSelect");
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*&&\s*effectiveSlidePlan\s*\)/);
// unit_id derivation matches handleGenerate's CLI-forwarding contract
expect(block).toMatch(/z\.section_ids\.join\(\s*"\+"\s*\)/);
// default-frame gate (rewind fix from Codex #17 / Claude #18)
expect(block).toMatch(/overrideId\s*!==\s*defaultFrameId/);
// axis key
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?frames:\s*framesByUnitId/,
);
});
it("handleLayoutResize does NOT call saveUserOverrides (zone_sizes excluded)", () => {
const block = sliceHandler(HOME_TSX, "handleLayoutResize");
expect(block).not.toMatch(/saveUserOverrides/);
// Sanity: handleLayoutResize still writes zone_sizes in-memory.
expect(block).toMatch(/saveZoneSizes/);
});
it("handleGenerate does NOT call saveUserOverrides (read-only re: persistence layer)", () => {
const block = sliceHandler(HOME_TSX, "handleGenerate");
// handleGenerate forwards overrides through runPipeline → /api/run, not
// through /api/user-overrides. The persistence layer is owned by the
// four mutation handlers; Generate must not introduce a competing
// write path that could clobber a partially-edited bucket.
expect(block).not.toMatch(/saveUserOverrides\(/);
});
it("no handler in Home.tsx persists the zone_sizes axis", () => {
// Top-level regression: searching the whole file rules out a future
// accidental wiring inside a new handler we forgot to enumerate above.
expect(HOME_TSX).not.toMatch(
/saveUserOverrides\([\s\S]{0,200}?zone_sizes\s*:/,
);
});
});
// ─── Payload-shape contract via mocked fetch ───────────────────────────────
// Drive `saveUserOverrides` with the exact payload shapes each in-scope
// handler produces in Home.tsx. Asserts that (a) the PUT body matches what
// the on-disk schema (u1 / u4) accepts and (b) the partial-axis contract
// holds — only the mutated axis is sent, never a full snapshot.
type MockResponse = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
};
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
return { ok, status, json: async () => body };
}
let fetchMock: Mock;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
vi.useFakeTimers();
__resetUserOverridesBuckets_FOR_TEST();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
__resetUserOverridesBuckets_FOR_TEST();
});
async function drainMicrotasks(): Promise<void> {
for (let i = 0; i < 4; i++) {
await Promise.resolve();
}
}
function lastPutBody(): unknown {
const lastCall = fetchMock.mock.calls.at(-1);
if (!lastCall) throw new Error("fetch was not called");
const init = lastCall[1] as RequestInit | undefined;
if (!init?.body) throw new Error("fetch called without a body");
return JSON.parse(String(init.body));
}
describe("save payload contract per axis (IMP-52 u10)", () => {
it("section-drop payload: PUT body carries only zone_sections", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
// Shape produced by handleSectionDrop after moveSectionToZone.
const payload: UserOverridesPartial = {
zone_sections: {
top: ["03-1", "03-2"],
bottom: ["03-3"],
},
};
void saveUserOverrides("03_demo", payload);
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["zone_sections"]);
expect(body.zone_sections).toEqual(payload.zone_sections);
});
it("layout-select payload: PUT body carries only `layout` (string)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { layout: "two-column" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["layout"]);
expect(body.layout).toBe("two-column");
});
it("zone-resize payload: PUT body carries only zone_geometries (merged snapshot)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
const merged = {
top: { x: 0, y: 0, w: 1, h: 0.42 },
bottom_l: { x: 0, y: 0.42, w: 0.5, h: 0.58 },
bottom_r: { x: 0.5, y: 0.42, w: 0.5, h: 0.58 },
};
void saveUserOverrides("03_demo", { zone_geometries: merged });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["zone_geometries"]);
expect(body.zone_geometries).toEqual(merged);
});
it("frame-select payload: PUT body carries only frames (unit_id → template_id)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
// Shape produced by handleFrameSelect after the default-frame gate:
// only zones the user explicitly chose a non-default frame for.
const framesByUnitId = {
"03-1": "process_product_two_way",
"03-2+03-3": "three_parallel_requirements",
};
void saveUserOverrides("03_demo", { frames: framesByUnitId });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["frames"]);
expect(body.frames).toEqual(framesByUnitId);
});
it("frame-select payload with empty framesByUnitId still PUTs (replaces axis with {})", async () => {
// When the user reverts the last frame override back to the backend
// default, handleFrameSelect computes `framesByUnitId = {}`. The PUT
// path still fires so the on-disk `frames` axis is cleared to the empty
// object via u4's partial-merge replace semantics. Foreign axes
// (layout / zone_geometries / zone_sections) remain on disk.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { frames: {} });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(lastPutBody()).toEqual({ frames: {} });
});
});
// ─── zone_sizes axis is not part of the on-disk schema ─────────────────────
describe("zone_sizes axis exclusion (IMP-52 u10)", () => {
it("UserOverridesPartial type does not include zone_sizes at compile time", () => {
// Compile-time check: this assignment must be a TS error. The runtime
// assertion below is a placebo; the meaningful evidence is that the
// suite *builds*. If a future schema bump adds zone_sizes to
// UserOverrides, this comment serves as the migration touchpoint.
// @ts-expect-error — zone_sizes is intentionally not part of UserOverridesPartial
const _bad: UserOverridesPartial = { zone_sizes: { layout_group_1: [0.5, 0.5] } };
void _bad;
expect(true).toBe(true);
});
it("Home.tsx never imports a write helper that would persist zone_sizes", () => {
// handleLayoutResize delegates to saveZoneSizes (in-memory), not
// saveUserOverrides. Cross-check the import line and the handler body.
expect(HOME_TSX).toMatch(/import\s*\{[^}]*\bsaveZoneSizes\b[^}]*\}\s*from\s*"\.\.\/utils\/slidePlanUtils"/);
const block = sliceHandler(HOME_TSX, "handleLayoutResize");
expect(block).toMatch(/saveZoneSizes\(/);
expect(block).not.toMatch(/saveUserOverrides/);
});
});
// ─── Write-before-Generate ordering ────────────────────────────────────────
// The four mutation handlers schedule debounced PUTs (300ms). If the user
// hits Generate before the debounce fires, the persistence layer must not
// drop the pending writes. `flushUserOverrides` is the contract: callers can
// force-commit pending buckets before pipeline kickoff so the backend u2
// fallback reads the latest file.
describe("write-before-Generate ordering (IMP-52 u10)", () => {
// The service-level tests below prove the `flushUserOverrides` contract in
// isolation. The two source-pattern checks here pin the *real* Generate
// call site so a future refactor that drops the flush — re-exposing the
// 300ms debounce race against `runPipeline` / the u2 backend fallback —
// fails loudly. Without React Testing Library we cannot dispatch a click
// on the Generate button, so we read Home.tsx as text and assert (a) the
// import names `flushUserOverrides`, (b) the `handleGenerate` closure
// awaits the flush before it awaits `runPipeline`.
it("Home.tsx imports flushUserOverrides from userOverridesApi", () => {
expect(HOME_TSX).toMatch(
/import\s*\{[^}]*\bflushUserOverrides\b[^}]*\}\s*from\s*"\.\.\/services\/userOverridesApi"/,
);
});
it("handleGenerate awaits flushUserOverrides before awaiting runPipeline", () => {
const block = sliceHandler(HOME_TSX, "handleGenerate");
expect(block).toMatch(/await\s+flushUserOverrides\s*\(\s*\)/);
expect(block).toMatch(/await\s+runPipeline\s*\(/);
const flushIdx = block.search(/await\s+flushUserOverrides\s*\(/);
const runIdx = block.search(/await\s+runPipeline\s*\(/);
expect(flushIdx).toBeGreaterThan(-1);
expect(runIdx).toBeGreaterThan(-1);
expect(flushIdx).toBeLessThan(runIdx);
});
it("flushUserOverrides commits a pending PUT before its 300ms debounce fires", async () => {
fetchMock.mockResolvedValue(mockResponse({ layout: "two-column" }));
const savePromise = saveUserOverrides("03_demo", { layout: "two-column" });
// Without flush, the PUT would not fire for another 300ms.
expect(fetchMock).not.toHaveBeenCalled();
const flushPromise = flushUserOverrides();
await drainMicrotasks();
await flushPromise;
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/user-overrides/03_demo");
expect((init as RequestInit).method).toBe("PUT");
// Caller's promise resolves with the server-merged document — so a
// pre-Generate `await flushUserOverrides()` can be paired with
// `await savePromise` for stronger ordering if needed.
await expect(savePromise).resolves.toEqual({ layout: "two-column" });
});
it("flushUserOverrides (no arg) flushes pending writes across multiple MDX keys", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { layout: "two-column" });
void saveUserOverrides("04_demo", { frames: { "04-1": "tpl_a" } });
void saveUserOverrides("05_demo", {
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
});
await flushUserOverrides();
await drainMicrotasks();
const putUrls = fetchMock.mock.calls
.filter((c) => (c[1] as RequestInit).method === "PUT")
.map((c) => c[0]);
expect(putUrls).toEqual(
expect.arrayContaining([
"/api/user-overrides/03_demo",
"/api/user-overrides/04_demo",
"/api/user-overrides/05_demo",
]),
);
expect(putUrls).toHaveLength(3);
});
it("flushUserOverrides is a no-op when no writes are pending", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
await flushUserOverrides();
expect(fetchMock).not.toHaveBeenCalled();
});
it("post-flush, a new save schedules a fresh 300ms debounce window", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { layout: "two-column" });
await flushUserOverrides();
await drainMicrotasks();
expect(
fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
),
).toHaveLength(1);
// Second save after Generate completes — must not piggy-back on the
// already-flushed bucket; must re-arm a fresh debounce.
void saveUserOverrides("03_demo", { layout: "hero-detail" });
vi.advanceTimersByTime(299);
await drainMicrotasks();
expect(
fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
),
).toHaveLength(1);
vi.advanceTimersByTime(1);
await drainMicrotasks();
expect(
fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
),
).toHaveLength(2);
});
});
// ─── Restore-on-reopen — end-to-end compose ────────────────────────────────
// u6 covers the helpers in isolation. This test wires them together with a
// mocked GET response in the order Home.tsx invokes them at file-upload
// time (key derive → fetch persisted → layer non-frame axes pre-loadRun →
// remap frames post-loadRun) to pin the integration contract.
function makeZone(partial: {
id: string;
zone_id: string;
section_ids: string[];
default_frame_id?: string | null;
}): Zone {
return {
id: partial.id,
zone_id: partial.zone_id,
section_ids: partial.section_ids,
position: { x: 0, y: 0, width: 1, height: 1 },
internal_regions: [
{
id: `${partial.id}-r0`,
region_id: "region-single",
role: "primary",
content_type: "text_block",
ratio_estimate: 1,
content_unit_ids: [],
frame_match_strategy: {
kind: "frame_match",
frame_id: partial.default_frame_id ?? null,
display_strategy: "inline_full",
},
frame_candidates: [],
},
],
};
}
describe("restore-on-reopen end-to-end (IMP-52 u10)", () => {
it("getUserOverrides → non-frame layer + post-load frame remap composes a restored selection", async () => {
// GET returns the persisted file for "03_demo". The `layout` value
// must be a real LayoutPresetId — applyPersistedNonFrameOverrides
// validates against the 8-preset whitelist (slidePlanUtils.ts:30).
fetchMock.mockResolvedValueOnce(
mockResponse({
layout: "horizontal-2",
frames: { "03-1": "process_product_two_way" },
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.42 } },
zone_sections: { top: ["03-1"], bottom: ["03-2", "03-3"] },
}),
);
const key = deriveUserOverridesKey("03_demo.mdx");
expect(key).toBe("03_demo");
// Step 1: Home.tsx fetches at handleFileUpload time.
const persisted = await getUserOverrides(key);
expect(persisted.layout).toBe("horizontal-2");
// Step 2: pre-loadRun layering applies layout / zone_geometries /
// zone_sections onto a fresh selection. Frames are deferred because
// the unit_id key cannot be remapped without a slidePlan yet.
const seededSelection = applyPersistedNonFrameOverrides(
createInitialUserSelection(null),
persisted,
);
expect(seededSelection.overrides.layout_preset).toBe("horizontal-2");
expect(seededSelection.overrides.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.42 },
});
expect(seededSelection.overrides.zone_sections).toEqual({
top: ["03-1"],
bottom: ["03-2", "03-3"],
});
// Frames must NOT have been layered at this stage.
expect(seededSelection.overrides.zone_frames).toEqual({});
// Step 3: post-loadRun, Home.tsx has a slidePlan. Remap unit_id-keyed
// frames to region.id-keyed frames against the rebuilt plan.
const plan: SlidePlan = {
id: "plan-3",
title: "demo",
layout_preset: "horizontal-2",
zones: [
makeZone({
id: "z-top",
zone_id: "top",
section_ids: ["03-1"],
default_frame_id: "some_default_frame",
}),
makeZone({
id: "z-bot",
zone_id: "bottom",
section_ids: ["03-2", "03-3"],
default_frame_id: null,
}),
],
};
const remapped = remapPersistedFramesToZoneFrames(
plan,
persisted.frames,
);
expect(remapped).toEqual({
"z-top-r0": "process_product_two_way",
});
// Step 4: post-loadRun merge — Home.tsx layers `remapped` onto
// `createInitialUserSelection(slidePlan)` so the SlideCanvas
// override-vs-default preview indicator surfaces the restored choice.
const finalSelection = {
...applyPersistedNonFrameOverrides(
createInitialUserSelection(plan),
persisted,
),
};
finalSelection.overrides = {
...finalSelection.overrides,
zone_frames: { ...finalSelection.overrides.zone_frames, ...remapped },
};
expect(finalSelection.overrides.zone_frames["z-top-r0"]).toBe(
"process_product_two_way",
);
expect(finalSelection.overrides.layout_preset).toBe("horizontal-2");
expect(finalSelection.overrides.zone_sections).toEqual({
top: ["03-1"],
bottom: ["03-2", "03-3"],
});
});
it("missing persisted file (GET returns {}) leaves the selection at backend defaults", async () => {
fetchMock.mockResolvedValueOnce(mockResponse({}));
const persisted = await getUserOverrides(deriveUserOverridesKey("new_file.mdx"));
expect(persisted).toEqual({});
const plan: SlidePlan = {
id: "plan-x",
title: "fresh",
layout_preset: "single",
zones: [
makeZone({ id: "z-only", zone_id: "main", section_ids: ["x-1"] }),
],
};
const seeded = applyPersistedNonFrameOverrides(
createInitialUserSelection(plan),
persisted,
);
// No override applied → layout_preset, geometries, sections all from
// the slidePlan defaults; remap yields {} so no frames layered.
expect(seeded.overrides.layout_preset).toBe("single");
expect(seeded.overrides.zone_geometries).toEqual({});
expect(remapPersistedFramesToZoneFrames(plan, persisted.frames)).toEqual({});
});
});
+311
View File
@@ -204,12 +204,310 @@ function vitePluginStorageProxy(): Plugin {
};
}
// =============================================================================
// IMP-52 u3/u4 — user_overrides.json persistence (MDX-stem keyed store).
//
// On-disk layout: <DESIGN_AGENT_ROOT>/data/user_overrides/<key>.json. Mirrors
// the Python contract in src/user_overrides_io.py — same validate_key regex,
// same graceful-degrade (corrupt → {}) so backend pipeline entry fallback
// (u2) and the vite endpoints (u3 GET, u4 PUT) agree on every file.
//
// Helpers are named exports so vitest can drive handleGetUserOverrides /
// handlePutUserOverrides with mock req/res without booting a real dev
// server. vite still consumes the default `defineConfig` export below.
// =============================================================================
export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/;
// The five in-scope axes — exact mirror of KNOWN_AXES in
// src/user_overrides_io.py. Any payload key outside this allowlist is
// silently dropped by the PUT handler (u4) so the on-disk schema cannot
// drift from the backend pipeline (u2) contract. Foreign top-level keys
// already on disk are preserved verbatim (see mergeUserOverrides).
// IMP-51 (#79) u2: added `image_overrides` (image_id → {x,y,w,h}
// percent-of-slide coordinates).
export const KNOWN_USER_OVERRIDES_AXES = [
"layout",
"zone_geometries",
"zone_sections",
"frames",
"image_overrides",
] as const;
export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number];
// 1MB cap on PUT bodies. Override files in practice are < 10KB (5 axes,
// each a small dict). The cap is a safety net against runaway client
// loops, not a real schema constraint.
const USER_OVERRIDES_PUT_MAX_BYTES = 1_000_000;
export function isValidUserOverridesKey(key: string): boolean {
if (!key) return false;
if (key.includes("..")) return false;
if (key.includes("/") || key.includes("\\")) return false;
return USER_OVERRIDES_KEY_RE.test(key);
}
export function userOverridesPath(root: string, key: string): string {
return path.join(root, "data", "user_overrides", `${key}.json`);
}
// Minimal req/res shapes — node IncomingMessage / ServerResponse have many
// fields the handler does not touch, so we accept a structural subset for
// testability.
type GetReqLike = { method?: string; url?: string };
type PutReqLike = {
method?: string;
url?: string;
on(event: "data" | "end" | "error", cb: (...args: any[]) => void): unknown;
};
type ResLike = {
writeHead: (status: number, headers?: Record<string, string>) => void;
end: (body?: string) => void;
};
// IMP-52 u3 — GET /api/user-overrides/:key handler. Returns true when the
// handler took over the response, false when the caller should `next()`.
// Invariants:
// • method != GET → false (chain continues; u4 PUT may handle)
// • invalid key → 400 {"error":"invalid key"}
// • file missing → 200 {}
// • file unreadable/corrupt → 200 {} (graceful degrade, mirrors u1 load)
// • non-object JSON root → 200 {} (mirrors u1 load)
// • valid object JSON → 200 with parsed JSON body
export function handleGetUserOverrides(
req: GetReqLike,
res: ResLike,
root: string,
): boolean {
if (req.method !== "GET") return false;
const url = req.url || "";
const key = url.split("?")[0].replace(/^\//, "");
if (!isValidUserOverridesKey(key)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid key" }));
return true;
}
const filePath = userOverridesPath(root, key);
if (!fs.existsSync(filePath)) {
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end("{}");
return true;
}
let parsed: unknown;
try {
const raw = fs.readFileSync(filePath, "utf-8");
parsed = JSON.parse(raw);
} catch {
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end("{}");
return true;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end("{}");
return true;
}
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(parsed));
return true;
}
// IMP-52 u4 — pure merge function. Mirrors src/user_overrides_io.save():
// • Only KNOWN_USER_OVERRIDES_AXES present in `partial` are mutated.
// • Axes absent from `partial` are preserved verbatim from `existing`.
// • Foreign top-level keys in `existing` (future axes like zone_sizes)
// are preserved verbatim — allowlist guards what the PUT writes, NOT
// what the file already holds.
// • `partial[axis] = null` is the explicit clear sentinel (remove key).
// • Any non-axis keys in `partial` are silently dropped (allowlist).
export function mergeUserOverrides(
existing: Record<string, unknown>,
partial: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> = { ...existing };
for (const axis of KNOWN_USER_OVERRIDES_AXES) {
if (!(axis in partial)) continue;
const value = partial[axis];
if (value === null) {
delete merged[axis];
} else {
merged[axis] = value;
}
}
return merged;
}
// IMP-52 u4 — atomic file write via tmp + rename. Mirrors the
// `_atomic_write_json` semantics in src/user_overrides_io.py so a
// crashed/interrupted PUT cannot leave a half-written .json on disk
// (the next GET / pipeline-entry read would otherwise return {} via
// graceful degrade, silently losing the user's prior overrides).
export function atomicWriteUserOverrides(
filePath: string,
data: Record<string, unknown>,
): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const tmpName = path.join(
dir,
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
);
try {
fs.writeFileSync(
tmpName,
JSON.stringify(data, null, 2) + "\n",
"utf-8",
);
fs.renameSync(tmpName, filePath);
} catch (err) {
try {
fs.unlinkSync(tmpName);
} catch {
// best-effort cleanup; the rename source may not exist on early failure
}
throw err;
}
}
// IMP-52 u4 — PUT /api/user-overrides/:key handler. Returns true when the
// handler took over the response, false when the caller should `next()`.
// Invariants:
// • method != PUT → false (chain continues; GET runs first)
// • invalid key → 400 {"error":"invalid key"}
// • body > 1MB → 413 {"error":"payload too large"}
// • invalid JSON → 400 {"error":"invalid JSON"}
// • non-object JSON root → 400 {"error":"body must be a JSON object"}
// • write failure → 500 {"error":"write failed: ..."}
// • success → 200 with merged JSON body
//
// Existing-file read uses the same graceful-degrade rules as GET (corrupt
// JSON / non-object root → treat as empty {}) so a PUT cannot fail solely
// because a prior file is unparseable — the new payload replaces it.
export function handlePutUserOverrides(
req: PutReqLike,
res: ResLike,
root: string,
): boolean {
if (req.method !== "PUT") return false;
const url = req.url || "";
const key = url.split("?")[0].replace(/^\//, "");
if (!isValidUserOverridesKey(key)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid key" }));
return true;
}
let body = "";
let aborted = false;
req.on("data", (chunk: Buffer | string) => {
if (aborted) return;
body += typeof chunk === "string" ? chunk : chunk.toString();
if (body.length > USER_OVERRIDES_PUT_MAX_BYTES) {
aborted = true;
res.writeHead(413, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: "payload too large" }));
}
});
req.on("end", () => {
if (aborted) return;
let parsed: unknown;
try {
parsed = body.length > 0 ? JSON.parse(body) : {};
} catch {
res.writeHead(400, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: "invalid JSON" }));
return;
}
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
res.writeHead(400, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: "body must be a JSON object" }));
return;
}
const partial = parsed as Record<string, unknown>;
const filePath = userOverridesPath(root, key);
// Load existing — corrupt / non-object → {} so the PUT still succeeds
// and recovers the file to a clean state. Mirrors u1 load() graceful
// degrade.
let existing: Record<string, unknown> = {};
if (fs.existsSync(filePath)) {
try {
const raw = fs.readFileSync(filePath, "utf-8");
const ex = JSON.parse(raw);
if (
typeof ex === "object" &&
ex !== null &&
!Array.isArray(ex)
) {
existing = ex as Record<string, unknown>;
}
} catch {
// corrupt → treat as empty
}
}
const merged = mergeUserOverrides(existing, partial);
try {
atomicWriteUserOverrides(filePath, merged);
} catch (err) {
res.writeHead(500, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: `write failed: ${String(err)}` }));
return;
}
res.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify(merged));
});
req.on("error", () => {
if (aborted) return;
aborted = true;
res.writeHead(500, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: "request error" }));
});
return true;
}
// =============================================================================
// Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출
//
// Endpoints (vite dev middleware) :
// POST /api/run multipart/JSON body {filename, content} → run_id
// GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path}
// GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3)
// PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4)
//
// 환경 변수 (선택) :
// DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent
@@ -464,6 +762,19 @@ function vitePluginPhaseZApi(): Plugin {
fs.createReadStream(previewPath).pipe(res);
});
// ── GET / PUT /api/user-overrides/{key} → data/user_overrides/{key}.json ──
// IMP-52 u3 (GET) + u4 (PUT) — MDX-stem keyed user overrides. Logic
// lives in the pure helpers (handleGetUserOverrides / handlePutUserOverrides)
// so vitest can exercise them without booting vite. Both handlers
// return false when the HTTP method does not match, so they chain
// cleanly: GET first, then PUT, then next() for everything else
// (e.g., OPTIONS / preflight handled by upstream middleware).
server.middlewares.use("/api/user-overrides", (req, res, next) => {
if (handleGetUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
if (handlePutUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
next();
});
// ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ──
server.middlewares.use("/data/runs", (req, res, next) => {
if (req.method !== "GET") return next();
+299
View File
@@ -0,0 +1,299 @@
"""Catalog ↔ partial ↔ builder invariant audit CLI (IMP-#85 u3a / u3b).
Offline audit of `templates/phase_z2/catalog/frame_contracts.yaml` against
the on-disk frame partials and the runtime `PAYLOAD_BUILDERS` registry.
Reports diff surface so first-fix iteration sees the entire catalog drift,
not just the first failure (matches the boot-time invariant's aggregation
behavior in `_check_catalog_builder_invariant`).
Invariants (scope-locked per Stage 2):
I1 partial existence — `templates/phase_z2/families/{template_id}.html`
must exist for live (non-VP) contracts.
I2 builder declared — live contracts must declare a non-empty
`payload.builder`.
I3 builder registered — declared builders must be members of
`src.phase_z2_mapper.PAYLOAD_BUILDERS`.
I4 slot_payload refs — every key generated by the contract's builder
must appear as a `slot_payload.<key>` reference in
the partial. Direction A only (dead generated key).
Skipped when the partial uses dynamic bracket
access (`slot_payload[...]`) — those refs cannot be
resolved statically; the relevant generated keys
are presumed reachable via the dynamic form.
`visual_pending: true` contracts are skipped for I1I4 (data-driven from
catalog, no hard-coded frame allow-list; matches u2 invariant scope).
Exit codes:
0 — all invariants pass on live (non-VP) contracts.
1 — one or more violations reported.
Usage::
python scripts/audit_frame_invariants.py
python scripts/audit_frame_invariants.py --catalog <path> --partials-dir <path>
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from typing import Iterable
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
import yaml
DEFAULT_CATALOG_PATH = (
REPO_ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml"
)
DEFAULT_PARTIALS_DIR = REPO_ROOT / "templates" / "phase_z2" / "families"
def _format_path(path: Path) -> str:
try:
return str(path.relative_to(REPO_ROOT))
except ValueError:
return str(path)
def _is_visual_pending(contract: dict) -> bool:
return contract.get("visual_pending") is True
def _iter_live_contracts(catalog: dict) -> Iterable[tuple[str, dict]]:
for template_id, contract in catalog.items():
if not isinstance(contract, dict):
continue
if _is_visual_pending(contract):
continue
yield template_id, contract
def check_i1_partial_existence(
catalog: dict, partials_dir: Path
) -> list[str]:
"""I1 — Live contracts must have `families/{template_id}.html` on disk."""
violations: list[str] = []
for template_id, _contract in _iter_live_contracts(catalog):
partial_path = partials_dir / f"{template_id}.html"
if not partial_path.is_file():
violations.append(
f"I1 partial-missing: contract '{template_id}' has no "
f"partial file at {_format_path(partial_path)}."
)
return violations
def check_i2_builder_declared(catalog: dict) -> list[str]:
"""I2 — Live contracts must declare a non-empty `payload.builder`."""
violations: list[str] = []
for template_id, contract in _iter_live_contracts(catalog):
payload = contract.get("payload") or {}
if not isinstance(payload, dict):
violations.append(
f"I2 builder-undeclared: contract '{template_id}' has "
f"non-dict payload (type={type(payload).__name__})."
)
continue
builder_name = payload.get("builder")
if not builder_name:
violations.append(
f"I2 builder-undeclared: contract '{template_id}' is "
f"missing payload.builder."
)
return violations
def check_i3_builder_registered(
catalog: dict, registered_builders: set[str]
) -> list[str]:
"""I3 — Declared builders must be members of PAYLOAD_BUILDERS registry."""
violations: list[str] = []
for template_id, contract in _iter_live_contracts(catalog):
payload = contract.get("payload") or {}
if not isinstance(payload, dict):
continue
builder_name = payload.get("builder")
if not builder_name:
continue
if builder_name not in registered_builders:
violations.append(
f"I3 builder-unregistered: contract '{template_id}' "
f"references payload.builder='{builder_name}' not in "
f"PAYLOAD_BUILDERS."
)
return violations
_SLOT_PAYLOAD_DOT_RE = re.compile(r"slot_payload\.([A-Za-z_][A-Za-z0-9_]*)")
_SLOT_PAYLOAD_BRACKET_RE = re.compile(r"slot_payload\s*\[")
def extract_static_slot_refs(partial_text: str) -> set[str]:
"""Return the set of `slot_payload.<key>` dot-access references."""
return set(_SLOT_PAYLOAD_DOT_RE.findall(partial_text))
def partial_uses_dynamic_slot_access(partial_text: str) -> bool:
"""True if the partial dereferences `slot_payload[...]` (dynamic key)."""
return bool(_SLOT_PAYLOAD_BRACKET_RE.search(partial_text))
def expected_payload_keys(contract: dict) -> set[str]:
"""Statically compute the set of payload keys the contract's builder produces.
Mirrors `src.phase_z2_mapper`'s registered builders (IMP-#85 u3b). Returns
an empty set when the builder is unknown — I3 already flags that drift.
"""
payload = contract.get("payload") or {}
if not isinstance(payload, dict):
return set()
keys: set[str] = set()
title_spec = payload.get("title")
if isinstance(title_spec, dict) and title_spec.get("source"):
keys.add("title")
builder = payload.get("builder")
options = payload.get("builder_options") or {}
if not isinstance(options, dict):
options = {}
if builder == "items_with_role":
array_root = options.get("array_root")
if array_root:
keys.add(array_root)
elif builder == "process_product_pair":
for col in options.get("columns") or []:
if not isinstance(col, dict):
continue
if col.get("title_to"):
keys.add(col["title_to"])
if col.get("body_to"):
keys.add(col["body_to"])
elif builder == "quadrant_flat_slots":
pad_to = int(options.get("pad_to", 4))
label_key = options.get("label_key_pattern", "quadrant_{n}_label")
body_key = options.get("body_key_pattern", "quadrant_{n}_body")
for n in range(1, pad_to + 1):
keys.add(label_key.format(n=n))
keys.add(body_key.format(n=n))
elif builder == "cycle_intersect_3":
pad_to = int(options.get("pad_to", 3))
label_key = options.get("label_key_pattern", "circle_{n}_label")
for n in range(1, pad_to + 1):
keys.add(label_key.format(n=n))
keys.add("intersection")
elif builder == "compare_table_2col":
keys.update({"col_a_label", "col_b_label", "rows"})
elif builder == "paired_rows_4x2_slots":
label_key = options.get("label_key_pattern", "row_{r}_{side}_label")
body_key = options.get("body_key_pattern", "row_{r}_{side}_body")
rows = int(options.get("rows", 4))
sides = options.get("sides", ["left", "right"]) or []
for r in range(1, rows + 1):
for side in sides:
keys.add(label_key.format(r=r, side=side))
keys.add(body_key.format(r=r, side=side))
return keys
def check_i4_slot_payload_refs(
catalog: dict,
partials_dir: Path,
registered_builders: set[str],
) -> list[str]:
"""I4 — every generated payload key must be referenced by the partial.
Direction A only (dead key). Skipped when the partial uses dynamic
bracket access (`slot_payload[...]`) — generated keys are presumed
reached via the dynamic form and cannot be resolved statically.
Contracts already failing I1 (missing partial) or I3 (unregistered
builder) are skipped so the same drift is not double-reported.
"""
violations: list[str] = []
for template_id, contract in _iter_live_contracts(catalog):
payload = contract.get("payload") or {}
if not isinstance(payload, dict):
continue
builder_name = payload.get("builder")
if not builder_name or builder_name not in registered_builders:
continue
partial_path = partials_dir / f"{template_id}.html"
if not partial_path.is_file():
continue
partial_text = partial_path.read_text(encoding="utf-8")
if partial_uses_dynamic_slot_access(partial_text):
continue
static_refs = extract_static_slot_refs(partial_text)
expected = expected_payload_keys(contract)
orphans = sorted(expected - static_refs)
for key in orphans:
violations.append(
f"I4 generated-key-orphan: contract '{template_id}' builder "
f"'{builder_name}' produces payload key '{key}' but partial "
f"never references slot_payload.{key}."
)
return violations
def run_audit(
catalog_path: Path = DEFAULT_CATALOG_PATH,
partials_dir: Path = DEFAULT_PARTIALS_DIR,
) -> list[str]:
"""Load catalog + registry and aggregate I1-I4 violations.
Registry is imported here (not at module import) so the script can be
inspected without triggering the boot-time catalog invariant.
"""
from src.phase_z2_mapper import PAYLOAD_BUILDERS
catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) or {}
registered = set(PAYLOAD_BUILDERS.keys())
violations: list[str] = []
violations.extend(check_i1_partial_existence(catalog, partials_dir))
violations.extend(check_i2_builder_declared(catalog))
violations.extend(check_i3_builder_registered(catalog, registered))
violations.extend(check_i4_slot_payload_refs(catalog, partials_dir, registered))
return violations
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Audit Phase Z-2 catalog ↔ partials ↔ builder registry."
)
parser.add_argument(
"--catalog",
type=Path,
default=DEFAULT_CATALOG_PATH,
help="Path to frame_contracts.yaml",
)
parser.add_argument(
"--partials-dir",
type=Path,
default=DEFAULT_PARTIALS_DIR,
help="Directory containing families/{template_id}.html partials",
)
args = parser.parse_args(argv)
violations = run_audit(args.catalog, args.partials_dir)
if not violations:
print("audit_frame_invariants: PASS (I1-I4 clean on live contracts).")
return 0
print(
f"audit_frame_invariants: FAIL ({len(violations)} violation(s)):"
)
for v in violations:
print(f" - {v}")
return 1
if __name__ == "__main__":
sys.exit(main())
+264
View File
@@ -0,0 +1,264 @@
"""IMP-51 (#79) u4 — user-content image stamper for Phase Z final.html.
Annotates user-content ``<img>`` elements with a stable id + role
attribute so the frontend SlideCanvas (u8~u11) can attach drag/resize
handles and the backend CSS injector (u7) can re-apply persisted geometry
on the next render.
DOM selector contract (single point of truth shared across the axis) :
.slide img[data-image-role="user-content"]
This selector is mirrored verbatim in :
- ``Front/client/src/components/SlideCanvas.tsx`` (u8 handle attach target)
- ``Front/client/src/services/userOverridesApi.ts`` (u3 doc reference)
- ``src/phase_z2_pipeline.py`` u7 hook (CSS injector — pending unit)
Decorative imgs (frame backgrounds, figma assets, dx-figures, decorative
icons) are NOT stamped, so they are NOT matched by the selector and remain
unaffected. The allowlist that decides "what counts as user-content" is
passed in by the caller (typically ``stage0_normalized_assets["images"]``);
this module does not encode the source-of-truth itself.
Stable id contract :
image_id = "img-" + sha1(src)[:10]
Deterministic across renders so persisted ``image_overrides`` entries
(keyed on ``image_id`` per ``src/user_overrides_io.py`` u1) re-apply
automatically. Duplicate srcs in the same slide get an ordinal suffix
("-1", "-2", ...) appended in DOM order; the first occurrence has no
suffix.
Forward-compat : current Phase Z final.html emits zero user-content
``<img>`` elements (``stage0_normalized_assets["images"]`` is empty across
all recent verify runs). ``stamp_user_content_images(html, sources=())``
is a pure no-op in that case — returns ``(html, [])`` without scanning.
Guardrails :
- No-hardcoding : the allowlist is caller-supplied, never inferred from
sample filenames or path heuristics.
- Idempotent : stamping a previously-stamped tag is a no-op (the
``data-image-role`` probe short-circuits before re-injecting).
- AI-isolation : this module is pure deterministic Python; no LLM calls.
- Carve-out (IMP-46 #62) : brand-new module, does not touch the
#76 commit ``1186ad8`` cache region.
"""
from __future__ import annotations
import hashlib
import re
from typing import Iterable
USER_CONTENT_IMAGE_SELECTOR: str = '.slide img[data-image-role="user-content"]'
IMAGE_ROLE_ATTR: str = "data-image-role"
IMAGE_ROLE_VALUE: str = "user-content"
IMAGE_ID_ATTR: str = "data-image-id"
# Matches a single ``<img ...>`` tag. Permissive on attribute order and
# whitespace; captures the inner attribute string + an optional XHTML
# self-close slash. Phase Z renders well-formed Jinja2 output (no inline
# ``<`` in attribute values), so a regex is safe here without pulling in
# an HTML parser.
_IMG_TAG_RE = re.compile(
r"<img\b([^>]*?)(/?)>",
flags=re.IGNORECASE | re.DOTALL,
)
# Matches the ``src="..."`` or ``src='...'`` attribute. Group 1 = double,
# group 2 = single. Quote style is preserved by callers that re-emit the
# tag verbatim.
_SRC_ATTR_RE = re.compile(
r"""\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')""",
flags=re.IGNORECASE,
)
# Probe for an existing ``data-image-role`` attribute (any value, any
# quote) so re-stamping is idempotent.
_ROLE_ATTR_RE = re.compile(r"""\bdata-image-role\s*=""", flags=re.IGNORECASE)
def stable_image_id(src: str, ordinal: int = 0) -> str:
"""Return the deterministic ``image_id`` for ``src``.
``ordinal`` disambiguates repeated occurrences of the same ``src`` in
the same slide (0 = first occurrence, no suffix; 1 → ``-1``; ...).
"""
if not isinstance(src, str):
raise TypeError(f"src must be a string, got {type(src).__name__}: {src!r}")
if ordinal < 0:
raise ValueError(f"ordinal must be >= 0, got {ordinal}")
digest = hashlib.sha1(src.encode("utf-8")).hexdigest()[:10]
base = f"img-{digest}"
return base if ordinal == 0 else f"{base}-{ordinal}"
def stamp_user_content_images(
html: str,
sources: Iterable[str] = (),
) -> tuple[str, list[str]]:
"""Stamp user-content ``<img>`` tags in ``html`` with role + stable id.
``sources`` is the allowlist of ``src`` attribute values that count as
user-content (typically ``stage0_normalized_assets["images"]``). Any
``<img>`` whose ``src`` value is in ``sources`` is rewritten to include
``data-image-role="user-content"`` and ``data-image-id="<stable_id>"``.
Other ``<img>`` tags (decorative, figma, frame-internal) are left
unchanged byte-for-byte.
Returns ``(modified_html, stamped_image_ids)`` where the id list is
in DOM (left-to-right) order. The list may contain duplicates only
via the ordinal-suffix path (``img-<hash>``, ``img-<hash>-1``, ...);
ordering is what the caller persists as the canonical key sequence.
Forward-compat : empty / all-non-string ``sources`` → pure no-op
(``html`` returned unchanged, empty list). This is the current Phase
Z state since ``stage0_normalized_assets["images"]`` is empty.
"""
allow = {s for s in sources if isinstance(s, str) and s}
if not allow:
return html, []
stamped: list[str] = []
seen_ordinal: dict[str, int] = {}
def _replace(match: re.Match[str]) -> str:
attrs = match.group(1) or ""
self_close = match.group(2) or ""
src_match = _SRC_ATTR_RE.search(attrs)
if src_match is None:
return match.group(0)
src = src_match.group(1) if src_match.group(1) is not None else src_match.group(2)
if src not in allow:
return match.group(0)
if _ROLE_ATTR_RE.search(attrs):
return match.group(0)
ordinal = seen_ordinal.get(src, 0)
seen_ordinal[src] = ordinal + 1
image_id = stable_image_id(src, ordinal=ordinal)
stamped.append(image_id)
injected = (
f' {IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"'
f' {IMAGE_ID_ATTR}="{image_id}"'
)
return f"<img{injected}{attrs}{self_close}>"
new_html = _IMG_TAG_RE.sub(_replace, html)
return new_html, stamped
# ─── IMP-51 (#79) u7 — render-time CSS injection ──────────────────────────
# Marker comments wrap the injected ``<style>`` block so re-injection on a
# previously-injected document is idempotent (the wrapper is found by a
# simple substring probe and the inner CSS is replaced in place).
_IMP51_STYLE_MARKER_OPEN: str = "<!-- IMP-51 image_overrides start -->"
_IMP51_STYLE_MARKER_CLOSE: str = "<!-- IMP-51 image_overrides end -->"
_IMP51_STYLE_BLOCK_RE = re.compile(
re.escape(_IMP51_STYLE_MARKER_OPEN) + r".*?" + re.escape(_IMP51_STYLE_MARKER_CLOSE),
flags=re.DOTALL,
)
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", flags=re.IGNORECASE)
_BODY_OPEN_RE = re.compile(r"<body\b[^>]*>", flags=re.IGNORECASE)
def build_image_overrides_style(
image_overrides: dict,
stamped_ids: Iterable[str],
) -> str:
"""Build CSS rule text for persisted ``image_overrides``.
For every ``image_id`` that appears in BOTH ``stamped_ids`` (the DOM
order of stamps returned by :func:`stamp_user_content_images`) AND
``image_overrides`` (the persisted geometry mapping from ``u1``
``user_overrides_io``), emit one absolute-position rule of the form ::
.slide img[data-image-role="user-content"][data-image-id="<id>"] {
position: absolute;
left: <x>%; top: <y>%;
width: <w>%; height: <h>%;
}
Coordinates are ``%`` of the slide bounding box (slide-absolute, per
Stage 2 scope-lock). ``.slide`` already declares ``position: relative``
in ``templates/phase_z2/slide_base.html`` so the absolute coordinates
resolve against the slide frame.
Rules are emitted in ``stamped_ids`` order so the output is
byte-deterministic across renders (critical for diff-based verifiers).
Override entries for ids NOT in ``stamped_ids`` are silently dropped —
those keys cannot be produced via the SlideCanvas pathway (the
frontend only knows the ids actually present in the DOM). Per-entry
malformed geometries (non-dict / missing axis / non-coercible value)
are dropped silently; the whole batch is never rejected.
Returns ``""`` when no rules are emitted so the caller can skip
``<style>`` injection entirely (forward-compat no-op when Phase Z
final.html still emits zero user-content imgs).
"""
if not image_overrides:
return ""
rules: list[str] = []
for iid in stamped_ids:
geom = image_overrides.get(iid)
if not isinstance(geom, dict):
continue
try:
x = float(geom["x"])
y = float(geom["y"])
w = float(geom["w"])
h = float(geom["h"])
except (KeyError, TypeError, ValueError):
continue
rules.append(
f'.slide img[{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"]'
f'[{IMAGE_ID_ATTR}="{iid}"] {{ '
f"position: absolute; "
f"left: {x}%; top: {y}%; "
f"width: {w}%; height: {h}%; "
f"}}"
)
return "\n".join(rules)
def inject_image_overrides_style(html: str, css: str) -> str:
"""Inject a marker-wrapped ``<style>`` block carrying ``css`` into ``html``.
Empty ``css`` → ``html`` returned unchanged (no DOM mutation). This
preserves the byte-for-byte identity of forward-compat renders where
no overrides apply.
When a previously-injected marker block is present, its inner CSS is
replaced in place (idempotent re-injection — second call with the
same overrides produces an identical document).
Injection precedence when no existing marker is found :
1. Before the first ``</head>`` (case-insensitive)
2. Immediately after the first ``<body ...>`` open tag
3. At the start of the document
Phase Z ``slide_base.html`` always emits ``</head>`` so path 1 wins
for production renders; paths 2/3 are defensive fallbacks for
unusual fragment inputs (tests, partials).
"""
if not css:
return html
block = (
f"{_IMP51_STYLE_MARKER_OPEN}\n"
f"<style>\n{css}\n</style>\n"
f"{_IMP51_STYLE_MARKER_CLOSE}"
)
if _IMP51_STYLE_MARKER_OPEN in html:
return _IMP51_STYLE_BLOCK_RE.sub(lambda _m: block, html, count=1)
head_close = _HEAD_CLOSE_RE.search(html)
if head_close is not None:
idx = head_close.start()
return html[:idx] + block + "\n" + html[idx:]
body_open = _BODY_OPEN_RE.search(html)
if body_open is not None:
idx = body_open.end()
return html[:idx] + "\n" + block + html[idx:]
return block + "\n" + html
+7 -1
View File
@@ -50,6 +50,7 @@ def route_ai_fallback(
internal_region: dict[str, Any],
mdx_text: str,
client: AiFallbackClient | None = None,
fingerprints: dict | None = None,
) -> AiFallbackProposal | None:
"""Route a fallback request through cache → prompt → client → validate.
@@ -57,13 +58,18 @@ def route_ai_fallback(
not ``ai_adaptation_required`` — both gates short-circuit BEFORE any
prompt/client work, so the normal-path AI call count stays at 0
(PZ-1).
``fingerprints`` is forwarded into ``read_proposal`` so that
contract / partial / catalog SHA mismatches invalidate stale cache
entries (IMP-46 #62 Axis R). When ``None`` the cache layer skips
fingerprint comparison (legacy behaviour).
"""
if not settings.ai_fallback_enabled:
return None
route = v4_result.get("route") or v4_result.get("imp05_route_hint")
if route != V4_ROUTE_AI_ADAPTATION:
return None
cached = read_proposal(cache_key)
cached = read_proposal(cache_key, fingerprints=fingerprints)
if cached is not None:
validate_proposal(
cached,
+1
View File
@@ -200,6 +200,7 @@ def gather_step12_ai_repair_proposals(
figma_partial_json=figma_partial_json,
internal_region=internal_region,
mdx_text=mdx_text,
fingerprints=fingerprints,
)
except Exception as exc: # noqa: BLE001 — record + continue, no AI re-raise
record["ai_called"] = True
+241
View File
@@ -73,6 +73,247 @@ STEP17_AI_REPAIR_BLOCKED_REASON = (
)
# IMP-35 (#64) u4 — POPUP cascade AI split-decision contract (API gated).
#
# Step 17 POPUP escalation needs an AI hook to decide *what content* stays in
# the body (summary/subset) vs. moves into the <details> popup (full MDX).
# That hook is the AI split-decision contract. u4 ships the contract surface
# (function signature + record schema + cascade_stage + route_for_label +
# skip_reason) WITHOUT enabling the Anthropic API. The deterministic POPUP
# gate executor (u5) runs ahead of this contract and stamps
# popup_escalation_plan + has_popup; u4's hook is a forward-compatible
# placeholder so downstream wiring (u5 executor / future IMP activating the
# API) can rely on a stable schema. ``api_gated=True`` on every record makes
# the gate state machine-readable; ``ai_called`` stays False everywhere.
#
# Per feedback_ai_isolation_contract: AI = fallback path only. The contract
# function MUST NOT import route_ai_fallback, the u4 client (despite name
# collision — u4 here is the IMP-35 unit, not the Step 12 client module),
# or any anthropic SDK symbol. Structural import guards in the test surface
# already enforce this and continue to hold after this change.
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON = (
"step17_popup_split_decision_api_gated"
)
# IMP-35 (#64) u5 — deterministic POPUP gate executor (cascade-terminal).
#
# Runs AFTER the DETERMINISTIC stage exhausts and BEFORE the AI_REPAIR
# cascade stage (canonical OVERFLOW_CASCADE_ORDER). Per unit:
#
# 1. Idempotency (q2): if a unit carries ``has_popup=True`` already,
# ``run_step17_popup_gate`` short-circuits with
# ``gate_status="idempotent_short_circuit"``. No duplicate plan,
# no re-routing. Re-running Step 17 on already-escalated units is
# safe — the gate emits a deterministic record per unit but does
# NOT re-stamp the plan or flip the marker. The persistence of
# ``has_popup`` and ``popup_escalation_plan`` on the unit itself
# (see step 4 below) is what makes the second call observe the
# stamp from the first call and short-circuit correctly.
# 2. Classification: ``classification_for_unit(unit)`` returns the
# fit_classifier row associated with this unit (or ``None`` if the
# unit has no overflow on this run).
# 3. Plan: ``plan_for_classification(cls)`` is the router u3 stub
# (``src.phase_z2_router.plan_details_popup_escalation``). Only
# the categories in ``POPUP_ESCALATION_CATEGORIES`` of the router
# surface (currently ``structural_major_overflow`` and
# ``tabular_overflow``) emit a feasible plan; anything else falls
# through to ``gate_status="infeasible_category"`` so the gate
# never silently escalates the wrong overflow shape.
# 4. Feasible plan → record stamps ``popup_escalation_plan`` and
# flips ``has_popup=True`` in the returned record AND persists
# the same two fields on the unit via ``setattr`` (``unit.has_popup``
# and ``unit.popup_escalation_plan``). The unit-side persistence
# is the q2 idempotency contract: a second call to
# ``run_step17_popup_gate`` over the same unit reads
# ``unit.has_popup=True`` at step 1 and short-circuits before
# classification / plan callable invocation. The marker is also
# what u6 composition binding and u7 render wiring read from the
# unit downstream.
#
# AI isolation contract: NO Anthropic call inside this gate. The
# deterministic split between popup body (full MDX) and preview
# (summary/subset) is composed downstream from container px budgets
# (q3 — preview_chars derives from container px telemetry already on
# the retry_trace). The u4 AI hook (``gather_step17_popup_split_decisions``)
# sits at the same cascade stage but is API-gated (``api_gated=True``)
# and never invoked from this deterministic path. ``ai_called=False`` on
# every record this gate emits.
#
# cascade_stage="popup" on every record so Step 17 retry-trace consumers
# can multiplex DETERMINISTIC / POPUP / AI_REPAIR records without
# ambiguity. The schema mirrors :func:`gather_step17_popup_split_decisions`
# (unit_index / source_section_ids / frame_template_id / label /
# route_hint / provisional) PLUS u5-specific fields:
# ``gate_status`` / ``popup_escalation_plan`` / ``has_popup`` /
# ``skip_reason`` (only set for non-escalated gate_status values).
STEP17_POPUP_GATE_ESCALATED_REASON = "step17_popup_gate_escalated"
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON = (
"step17_popup_gate_idempotent_short_circuit"
)
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON = (
"step17_popup_gate_infeasible_category"
)
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON = (
"step17_popup_gate_no_classification_for_unit"
)
def run_step17_popup_gate(
units: Iterable[Any],
*,
classification_for_unit: Callable[[Any], dict | None],
route_for_label: Callable[[str | None], str | None],
plan_for_classification: Callable[[dict], dict],
) -> list[dict]:
"""Deterministic POPUP gate executor for Step 17 cascade (IMP-35 u5).
See module-level block comment (immediately above) for the full
contract — idempotency (q2), classification source, router u3 stub
coupling, AI isolation, and cascade_stage multiplexing.
Args:
units: provisional / non-provisional Step 17 units. The gate is
agnostic to provisional state; the marker ``has_popup`` flows
from this function regardless.
classification_for_unit: maps a unit to its fit_classifier
classification row (or ``None`` if the unit has no overflow).
Tests inject a fake dict / lookup; the pipeline composes
this from ``fit_classification.classifications`` matched by
``zone_position``.
route_for_label: same callable shape as
:func:`gather_step17_ai_repair_proposals` /
:func:`gather_step17_popup_split_decisions`. The route hint
is stamped on every record for downstream consumers.
plan_for_classification: the router u3 stub
(``src.phase_z2_router.plan_details_popup_escalation``).
Injected as a callable so this module stays decoupled from
the router surface and tests can stub the plan output.
Returns:
list[dict] — one record per unit. Records carry
``cascade_stage="popup"`` and ``ai_called=False`` everywhere.
Feasible-escalation records also carry
``popup_escalation_plan`` (the router u3 plan dict) and
``has_popup=True``. Non-escalation records carry a
``skip_reason`` enum.
"""
records: list[dict] = []
for index, unit in enumerate(units):
label = getattr(unit, "label", None)
already_escalated = bool(getattr(unit, "has_popup", False))
record: dict = {
"unit_index": index,
"source_section_ids": list(
getattr(unit, "source_section_ids", []) or []
),
"frame_template_id": getattr(unit, "frame_template_id", None),
"label": label,
"route_hint": route_for_label(label),
"provisional": bool(getattr(unit, "provisional", False)),
"cascade_stage": OverflowCascadeStage.POPUP.value,
"ai_called": False,
"has_popup": already_escalated,
"popup_escalation_plan": None,
"gate_status": None,
"skip_reason": None,
}
if already_escalated:
# q2 idempotency — short-circuit. The previously stamped
# popup_escalation_plan stays on the unit (carried by u6/u7
# composition); this gate does NOT re-emit it.
record["gate_status"] = "idempotent_short_circuit"
record["skip_reason"] = (
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
)
records.append(record)
continue
classification = classification_for_unit(unit)
if not classification:
record["gate_status"] = "no_classification"
record["skip_reason"] = STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON
records.append(record)
continue
plan = plan_for_classification(classification)
record["popup_escalation_plan"] = plan
if plan and plan.get("feasible"):
record["gate_status"] = "escalated"
record["has_popup"] = True
record["skip_reason"] = None
# q2 idempotency persistence — stamp the marker AND the plan
# on the unit itself so a second run of the gate over the
# same unit observes ``unit.has_popup=True`` at the top of
# the loop and short-circuits before re-invoking the
# classification / plan callables. The unit-side persistence
# is also what u6 composition binding and u7 render wiring
# read downstream.
setattr(unit, "has_popup", True)
setattr(unit, "popup_escalation_plan", plan)
else:
# Plan rejected by router (wrong category). Defensive guard —
# the gate must not silently escalate the wrong overflow
# shape (see router u3 plan_details_popup_escalation defensive
# guard).
record["gate_status"] = "infeasible_category"
record["skip_reason"] = (
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON
)
records.append(record)
return records
def gather_step17_popup_split_decisions(
units: Iterable[Any],
*,
route_for_label: Callable[[str | None], str | None],
) -> list[dict]:
"""Return one API-gated split-decision record per unit (POPUP cascade).
Schema mirrors :func:`gather_step17_ai_repair_proposals` so a Step 17
artifact consumer can multiplex DETERMINISTIC / POPUP / AI_REPAIR records
onto the same retry trace. POPUP-specific fields:
* ``cascade_stage`` — always ``"popup"``.
* ``api_gated`` — always ``True`` at u4. Future IMP activating the
Anthropic API for popup splitting will flip this to ``False`` for
units that traversed the deterministic POPUP gate (u5) without
resolving via summary-only.
* ``ai_called`` — always ``False`` at u4 (contract surface only).
* ``skip_reason`` — always
:data:`STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON`.
* ``split_decision`` — always ``None`` at u4. Once activated, this will
carry the AI-proposed ``{"body_preview": ..., "popup_full": ...}``
pair; u5 deterministic gate fills the same field deterministically
from container px budgets (preview_chars) and never invokes AI.
Per IMP-35 u4 binding contract: the API stays gated. No Anthropic call,
no route_ai_fallback import, no client instantiation. Structural import
tests in :mod:`tests.phase_z2_ai_fallback.test_step17` continue to lock
these guarantees.
"""
records: list[dict] = []
for index, unit in enumerate(units):
label = getattr(unit, "label", None)
record: dict = {
"unit_index": index,
"source_section_ids": list(
getattr(unit, "source_section_ids", []) or []
),
"frame_template_id": getattr(unit, "frame_template_id", None),
"label": label,
"route_hint": route_for_label(label),
"provisional": bool(getattr(unit, "provisional", False)),
"cascade_stage": OverflowCascadeStage.POPUP.value,
"ai_called": False,
"api_gated": True,
"skip_reason": STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON,
"split_decision": None,
"error": None,
}
records.append(record)
return records
def gather_step17_ai_repair_proposals(
units: Iterable[Any],
*,
+653
View File
@@ -315,6 +315,321 @@ def select_display_strategy_candidates(
return [s for s in order if s in eligible]
# ─── IMP-35 (#64) u6 — Composition popup binding (yaml strategy -> zone payload) ─
#
# Stage 2 binding contract (unit u6):
# Step 17 POPUP gate (u5 in src/phase_z2_ai_fallback/step17.py) stamps
# ``unit.has_popup=True`` AND ``unit.popup_escalation_plan=<plan>`` on
# composition units whose overflow category routes to
# ``details_popup_escalation``. u6 is the composition-side binding that
# translates the unit-side marker into a deterministic zone payload
# structure that u7 (pipeline composer -> render_slide wiring) reads to
# emit the ``<details>/<summary>`` markup u8 will add to slide_base.html.
#
# Inputs (unit-side, all duck-typed via getattr):
# has_popup — bool (False default; u5 sets True on
# feasible escalation only)
# popup_escalation_plan — dict | None (u3 router plan from
# plan_details_popup_escalation; carries
# feasible / category / rationale /
# needs_split_decision)
# raw_content — str (the source MDX content; popup body
# source per CLAUDE.md 자세히보기 원칙)
#
# Outputs (zone payload binding dict):
# display_strategy — catalog strategy id read from
# display_strategies.yaml (NOT hardcoded).
# ``inline_full`` when has_popup=False.
# ``inline_preview_with_details`` when
# has_popup=True (preview = excerpt from
# container px budget downstream; popup body
# preserves the FULL original).
# popup_body_source — str | None — the FULL raw_content. u7 passes
# this verbatim to the renderer; the popup
# body is the MDX 원문 (자세히보기 원칙),
# never summarized in the body branch.
# None when has_popup=False.
# detail_trigger — dict | None — placement + label read from
# the catalog strategy entry's
# ``detail_trigger``. None when has_popup=False.
# preserves_original — bool — echoed from the catalog entry.
# MUST be True for popup-binding strategies
# (absolute user lock — 오답노트 #5 /
# IMPROVEMENT-REDESIGN.md §3.6 line 110).
# has_popup — bool — echoed for downstream multiplex.
# popup_escalation_plan — dict | None — echoed verbatim (u5 plan).
# Provides traceability into the router
# category + rationale for downstream debug.
# strategy_meta — dict — full catalog entry (description /
# applies_to / forbidden_for / detail_trigger)
# so downstream traces can self-explain without
# re-reading the yaml.
#
# Guardrails honored:
# - feedback_ai_isolation_contract — NO AI call. Reads catalog + unit
# state only. The deterministic POPUP gate (u5) already established
# the marker; this function is pure composition-side binding.
# - feedback_no_hardcoding — strategy id is the ONLY name reference, and
# it is the catalog key (yaml is source of truth). detail_trigger
# placement / label come from the catalog entry, not literals.
# - MDX 원문 무손실 보존 — popup_body_source = full raw_content.
# u6 NEVER trims or summarizes; the body preview (excerpt from
# container px budget) is composed by u7 downstream.
# - Phase Z spacing 방향 — u6 binds a strategy that EXPANDS capacity
# (popup escalation) instead of shrinking common margins.
# Strategy id used when the unit carries no popup escalation marker.
# Catalog read — yaml is source of truth.
POPUP_BINDING_NO_POPUP_STRATEGY_ID = "inline_full"
# Strategy id used when the unit carries has_popup=True (deterministic
# choice — the preview body is a px-budget excerpt of the original, the
# popup body holds the FULL original per CLAUDE.md 자세히보기 원칙).
# u5 q3 — preview_chars deterministic from container px telemetry; that
# is an excerpt-from-original pattern, which matches
# ``inline_preview_with_details``. ``details_only`` (summary-only body)
# is the alternative future axis when an AI/summarizer is available.
POPUP_BINDING_ESCALATED_STRATEGY_ID = "inline_preview_with_details"
def bind_popup_display_strategy(unit) -> dict:
"""Bind catalog popup display strategy to a zone payload (IMP-35 u6).
Reads the unit-side ``has_popup`` + ``popup_escalation_plan`` markers
stamped by Step 17 POPUP gate (u5) and produces a zone payload dict
that u7 wires into the renderer. The catalog
(``display_strategies.yaml``) is the source of truth for both the
strategy id and the detail_trigger placement / label — no hardcoded
string literals.
Args:
unit: a CompositionUnit (or any duck-typed object exposing
``has_popup`` / ``popup_escalation_plan`` / ``raw_content``).
``has_popup`` defaults to False when the attribute is absent
(units that never went through the Step 17 POPUP gate).
Returns:
zone payload binding dict (see module-level u6 contract block
immediately above for the full schema).
Raises:
RuntimeError: if the chosen catalog strategy id is missing from
the loaded ``DISPLAY_STRATEGIES`` mapping. Defensive guard —
yaml drift would otherwise cause downstream KeyError on a
stale string literal. The constants
``POPUP_BINDING_NO_POPUP_STRATEGY_ID`` /
``POPUP_BINDING_ESCALATED_STRATEGY_ID`` must always resolve
against the catalog at import time.
"""
has_popup = bool(getattr(unit, "has_popup", False))
plan = getattr(unit, "popup_escalation_plan", None)
raw_content = getattr(unit, "raw_content", "") or ""
strategy_id = (
POPUP_BINDING_ESCALATED_STRATEGY_ID
if has_popup
else POPUP_BINDING_NO_POPUP_STRATEGY_ID
)
meta = DISPLAY_STRATEGIES.get(strategy_id)
if meta is None:
raise RuntimeError(
f"bind_popup_display_strategy: catalog drift — strategy id "
f"{strategy_id!r} is missing from display_strategies.yaml. "
f"Loaded keys: {sorted(DISPLAY_STRATEGIES)}."
)
if not has_popup:
return {
"display_strategy": strategy_id,
"popup_body_source": None,
"detail_trigger": None,
"preserves_original": bool(meta.get("preserves_original")),
"has_popup": False,
"popup_escalation_plan": None,
"strategy_meta": meta,
}
# has_popup=True path. preserves_original MUST be True per the catalog
# absolute user lock — defensive guard against yaml drift.
if not meta.get("preserves_original"):
raise RuntimeError(
f"bind_popup_display_strategy: catalog invariant violated — "
f"popup-binding strategy {strategy_id!r} has preserves_original="
f"{meta.get('preserves_original')!r}; MDX 원문 무손실 보존 "
f"requires preserves_original=True (오답노트 #5 / "
f"IMPROVEMENT-REDESIGN.md §3.6 line 110)."
)
trigger_meta = meta.get("detail_trigger") or {}
return {
"display_strategy": strategy_id,
# MDX 원문 무손실 보존 — popup body = full raw_content (verbatim).
"popup_body_source": raw_content,
"detail_trigger": {
"placement": trigger_meta.get("placement"),
"label": trigger_meta.get("label"),
},
"preserves_original": True,
"has_popup": True,
"popup_escalation_plan": plan,
"strategy_meta": meta,
}
# ─── IMP-35 (#64) u7 — Pipeline composer -> render_slide wiring ──
#
# Stage 2 wiring contract (unit u7):
# u6 (``bind_popup_display_strategy``) produced the deterministic zone
# binding from the unit-side marker stamped by Step 17 POPUP gate (u5).
# u7 wires that binding into the pipeline composer's zones_data so the
# render_slide call site (and downstream slide_base.html consumer u8)
# sees three uniform render-context field names per zone:
#
# has_popup : bool — escalation marker echo
# popup_html : str — popup body source (full ``raw_content`` per u6;
# u8 wraps it in ``<details>/<summary>``).
# ``None`` when has_popup=False.
# preview_text : str — px-budgeted excerpt of ``raw_content`` shown in
# the body / inline_preview slot. NEVER trims
# inside a line — line-boundary cut only — and
# the popup body retains the FULL original
# (MDX 원문 무손실 보존). ``None`` when
# has_popup=False.
#
# The full u6 binding is also echoed on the zone dict under
# ``popup_binding`` so downstream debug / catalog-aware consumers can
# self-explain without re-reading the yaml.
#
# Why the preview is a deterministic line-budget cut (u5 q3 resolution):
# The popup body holds the FULL original verbatim, so the preview loses
# no information — it just truncates at a deterministic boundary that
# fits the container height telemetry. Container telemetry source is the
# per-unit ``min_height_px`` (frame visual_hints), which is what the
# pipeline composer already knows at the zones_data append site.
#
# We never re-summarize, never AI-call, never reorder. Char-budget cut
# would risk splitting CJK words mid-character — line-boundary cut is
# the closest deterministic surface to ``raw_content`` semantics
# (MDX paragraph / bullet boundaries).
#
# Guardrails honored:
# - feedback_ai_isolation_contract — pure deterministic helper. No
# anthropic import, no AI fallback router path.
# - MDX 원문 무손실 보존 — preview is a CUT, never a rewrite; popup body
# stays equal to ``raw_content``.
# - feedback_no_hardcoding — line metric is parametric (line_height_px
# defaults to slide_base.html body line metric ~18 px = 11 px font *
# 1.6 line-height + ~0.4 px ascent guard). u9 will surface the literal
# value source.
# Line height in px used to convert a container-height budget into a
# line-count budget. Matches slide_base.html ``--font-body`` (11 px) at
# the ``.text-line`` line-height (1.6). Default — NOT a hardcoded magic
# constant: ``compute_popup_preview_text`` accepts an override so the
# downstream renderer (u8) or per-frame contracts can pass a tighter
# value if a frame uses a smaller body font.
POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX = 18.0
def compute_popup_preview_text(
raw_content: str,
container_height_px: float,
*,
line_height_px: float = POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX,
) -> str:
"""Px-budgeted preview excerpt of ``raw_content`` (IMP-35 u7).
Deterministic line-boundary cut — returns the leading lines of
``raw_content`` that fit within ``container_height_px`` at the slide
body line metric. Never trims inside a line (no mid-CJK-word cut);
the popup body (u6 ``popup_body_source``) retains the FULL original
verbatim so this excerpt loses no information.
Args:
raw_content: the unit's source MDX content; the popup body
source per CLAUDE.md 자세히보기 원칙.
container_height_px: container height telemetry. The pipeline
composer passes ``min_height_px`` (frame visual_hints) at
the zones_data append site. Non-positive values fall back
to returning the full content unchanged (popup gate would
not have fired without a real container budget anyway).
line_height_px: px per body line. Default matches slide_base.html
``.text-line`` (11 px font * 1.6 line-height + guard).
Overridable for tighter-font frames.
Returns:
The leading lines that fit the budget, joined verbatim. If the
content already fits, returns ``raw_content`` unchanged.
"""
if not raw_content:
return ""
if container_height_px <= 0 or line_height_px <= 0:
# No budget signal — return the full content unchanged. u5 POPUP
# gate would not have fired without a real container budget, so
# this branch is only reachable for non-popup units (where the
# preview is anyway unused — see compose_zone_popup_payload).
return raw_content
max_lines = int(container_height_px // line_height_px)
if max_lines < 1:
max_lines = 1
lines = raw_content.splitlines(keepends=False)
if len(lines) <= max_lines:
return raw_content
# Re-join with "\n" — splitlines drops the terminator so a verbatim
# round-trip of the leading lines is "\n".join(...). Preserves the
# exact head of raw_content up to the chosen line boundary.
return "\n".join(lines[:max_lines])
def compose_zone_popup_payload(unit, container_height_px: float) -> dict:
"""Compose the per-zone popup render-context payload (IMP-35 u7).
Reads u6 ``bind_popup_display_strategy(unit)`` and surfaces the three
uniform render-context field names the pipeline composer attaches to
each zone in ``zones_data``. The full u6 binding is also echoed
under ``popup_binding`` so downstream debug / u8 / u9 consumers can
self-explain without re-reading the yaml.
Args:
unit: a CompositionUnit (or any duck-typed object exposing
``has_popup`` / ``popup_escalation_plan`` / ``raw_content``).
container_height_px: container height telemetry. The pipeline
composer passes ``min_height_px`` at the zones_data append
site. The non-popup branch ignores the value (preview_text
is always None when has_popup=False).
Returns:
Dict with the four wiring keys (``has_popup``, ``popup_html``,
``preview_text``, ``popup_binding``). Spreadable into a zone
dict via ``zones_data.append({..., **payload})``.
"""
binding = bind_popup_display_strategy(unit)
has_popup = bool(binding.get("has_popup"))
if not has_popup:
return {
"has_popup": False,
"popup_html": None,
"preview_text": None,
"popup_binding": binding,
}
raw_content = getattr(unit, "raw_content", "") or ""
popup_html = binding.get("popup_body_source")
preview_text = compute_popup_preview_text(raw_content, container_height_px)
return {
"has_popup": True,
# popup body = FULL raw_content (u6 popup_body_source). u8 wraps
# this in <details>/<summary> markup on slide_base.html.
"popup_html": popup_html,
# body preview = px-budgeted line-boundary cut of raw_content.
# NEVER trims inside a line; popup body holds the FULL original
# so this excerpt loses no information.
"preview_text": preview_text,
# Full u6 binding echo — downstream debug surfaces (catalog
# detail_trigger placement, popup_escalation_plan category /
# rationale) without re-reading yaml.
"popup_binding": binding,
}
# ─── CompositionUnit ────────────────────────────────────────────
@dataclass
@@ -925,3 +1240,341 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
}
return units, preset, debug
# ─── IMP-48 — Re-split All-Reject Merges (#77, Stage 2 / u1~u3) ─────
def resplit_all_reject_merges(
units: list[CompositionUnit],
sections,
v4_lookup_fn,
v4_label_to_status: dict,
allowed_statuses: set[str],
*,
capacity_fit_fn=None,
v4_candidates_lookup_fn=None,
section_assignment_override: bool = False,
) -> tuple[list[CompositionUnit], dict]:
"""Re-split merged composition units whose rank-1 V4 label is ``reject``.
IMP-48 (#77) — Step 6 post-pass that decomposes a merged unit
(``parent_merged`` / ``parent_merged_inferred``) carrying ``label=reject``
into per-section singles, so child sections with non-reject rank-1 V4
evidence can flow through the normal use_as_is / light_edit / restructure
paths instead of being handed to IMP-47B (#76) as a single blob.
Stage 2 / u3 slice (current revision) :
u1 contract (detection scan + override skip + idempotent single-
exclusion) + u2 per-section Branch-1 rebuild (each rebuilt single
carries ``merge_type="single"`` + the section's OWN rank-1 V4
evidence via ``v4_lookup_fn`` + the section's original
``raw_content`` from ``sections``) are both preserved. u3 adds the
gating + swap path :
1. **Coverage equality** — every child section in
``source_section_ids`` MUST rebuild successfully. Any
``section_not_found`` / ``no_v4_match`` rebuild result short-
circuits that merged unit to ``reason="incomplete_rebuild"``.
2. **Beneficial split** — at least one rebuilt single MUST have
``label != "reject"`` (Stage 2 Q2 Codex YES — "≥1 section
gains non-reject frame"). Otherwise that merged unit short-
circuits to ``reason="no_beneficial_split"`` and IMP-47B (#76)
handles the merge directly.
3. **Layout cap (≤ 4 units)** — projected post-split unit count
(across ALL detected merges that would split) MUST be ≤ 4.
Otherwise EVERY would-be split is aborted with
``reason="layout_cap_exceeded"`` (Stage 2 Q2 default — keep
merged, no partial split; v0 ``select_layout_preset`` supports
1~4 units max).
4. **Telemetry** — every single produced by an APPLIED split has
``selection_path="resplit_from_merge"`` (Stage 1 Q3 YES,
additive field reuse — no schema add).
5. **Audit payload** — ``audit["applied"]`` reflects whether ANY
merge actually split. ``audit["split_units"]`` /
``audit["skipped_units"]`` capture per-merge decisions.
``audit["post_split_unit_count"]`` reflects the returned list
length. ``audit["post_split_layout_preset"]`` is filled via
``select_layout_preset(out_units)`` when ``applied=True``,
None otherwise (u5 also re-derives in pipeline scope).
``out_units`` is the post-resplit unit list (merged removed +
singles inserted, in original ordering). When no merge splits,
``out_units`` is byte-identical to input ``units`` and
``applied=False`` — the audit's ``skipped_reason`` becomes
``"no_split_applied"``.
Detection signal (★ no-hardcoding, AI=0) :
``merge_type ∈ {"parent_merged", "parent_merged_inferred"}``
AND ``label == "reject"``
AND ``len(source_section_ids) >= 2``
Signal uses only ``merge_type`` + ``label`` + section count — never
section_id, template_id, MDX filename, or sample identifier.
Override skip (Stage 2 Q1 — kwarg per Codex YES) :
``section_assignment_override=True`` makes the helper a no-op. User-
driven ``zoneSections`` (#6 IMP-06) is the ground truth and must not
be second-guessed by an automatic re-split.
Idempotency (max_retry=1, Stage 2 lock) :
u2's rebuilt units carry ``merge_type="single"``, which is excluded
from the detection filter by construction. A second pass through
this helper finds nothing — no inner loop, no recursion.
Frame-swap guardrail (★ feedback_ai_isolation_contract) :
u2 rebuilds each child section's single from its OWN rank-1 V4
evidence via ``v4_lookup_fn``. The merged unit's parent /
representative ``template_id`` is discarded along with the merge
itself — no swap of one section's frame onto another section.
Args:
units: composition units from ``plan_composition()``.
sections: original section list (forwarded to u2 for per-section
``raw_content`` lookup — merged units carry the joined string,
not the individual child source).
v4_lookup_fn: ``(section_id) -> V4Match | None`` (rank-1). Forwarded
to u2 — identical evidence source as ``plan_composition``.
v4_label_to_status: V4 label → Phase Z status mapping (forwarded).
allowed_statuses: auto-renderable status set (forwarded).
capacity_fit_fn: optional capacity fit injector (forwarded to u2).
v4_candidates_lookup_fn: optional Step 6-A candidates fn (forwarded).
section_assignment_override: True iff user supplied
``zoneSections`` / ``section_assignment_plan`` (IMP-06 chain).
Returns:
``(out_units, audit)`` :
``out_units`` = post-resplit units (u1: identical to input).
``audit`` = ``imp48_resplit`` payload following Stage 1 schema::
{
"applied": bool, # u1: always False
"split_units": [...], # u3 fills with per-section singles
"skipped_units": [...], # u3 fills with kept-merged + reason
"post_split_unit_count": int,
"post_split_layout_preset": Optional[str],
"skipped_reason": str, # u1: contract-stage reason
"detected_units": [...], # u1: u2's rebuild targets
}
"""
# ``allowed_statuses`` is forwarded for signature symmetry with
# ``plan_composition`` but unused inside the helper — Stage 2 / Codex YES
# fixed the beneficial-split threshold to ``single.label != "reject"``
# (Stage 1 contract "non-reject rank-1"). Future axes may widen the
# threshold using ``allowed_statuses``; until then the parameter is
# explicitly deleted to silence lint without losing the public contract.
del allowed_statuses
audit: dict = {
"applied": False,
"split_units": [],
"skipped_units": [],
"post_split_unit_count": len(units),
"post_split_layout_preset": None,
"detected_units": [],
"rebuild_attempts": [],
}
if section_assignment_override:
audit["skipped_reason"] = "section_assignment_override"
return units, audit
detected = [
u for u in units
if u.merge_type in {"parent_merged", "parent_merged_inferred"}
and u.label == "reject"
and len(u.source_section_ids) >= 2
]
audit["detected_units"] = [
{
"source_section_ids": list(u.source_section_ids),
"merge_type": u.merge_type,
"template_id": u.frame_template_id,
"label": u.label,
}
for u in detected
]
if not detected:
audit["skipped_reason"] = "no_detection"
return units, audit
# u2 — per-section Branch-1 rebuild for each detected merged-reject unit.
# Mirrors ``collect_candidates`` Branch 1 (single per section). Each rebuilt
# single carries the section's OWN rank-1 V4 evidence — the merged unit's
# parent/representative template_id is discarded along with the merge.
# ★ feedback_ai_isolation_contract : no frame swap (each section's own V4).
# ★ MDX_raw_content_invariant : raw_content taken from sections list.
# ★ idempotency : merge_type="single" excludes singles
# from re-detection on any later pass.
section_by_id = {s.section_id: s for s in sections}
def _v4_cands(section_id: str) -> list:
return v4_candidates_lookup_fn(section_id) if v4_candidates_lookup_fn else []
rebuild_attempts: list[dict] = []
for merged_unit in detected:
section_singles: list[dict] = []
for sid in merged_unit.source_section_ids:
section = section_by_id.get(sid)
if section is None:
section_singles.append({
"section_id": sid,
"build_result": "section_not_found",
"unit": None,
})
continue
match = v4_lookup_fn(sid)
if match is None:
section_singles.append({
"section_id": sid,
"build_result": "no_v4_match",
"unit": None,
})
continue
single = CompositionUnit(
source_section_ids=[sid],
merge_type="single",
frame_template_id=match.template_id,
frame_id=match.frame_id,
frame_number=match.frame_number,
confidence=match.confidence,
label=match.label,
phase_z_status=v4_label_to_status.get(match.label, "unknown"),
v4_rank=getattr(match, "v4_rank", None),
selection_path=getattr(match, "selection_path", "rank_1"),
fallback_reason=getattr(match, "fallback_reason", None),
raw_content=section.raw_content,
title=section.title,
v4_candidates=_v4_cands(sid),
provisional=getattr(match, "provisional", False),
)
_apply_capacity_fit(single, capacity_fit_fn)
score_candidate(single)
section_singles.append({
"section_id": sid,
"build_result": "ok",
"unit": single,
})
rebuild_attempts.append({
"merged_source_section_ids": list(merged_unit.source_section_ids),
"merged_merge_type": merged_unit.merge_type,
"merged_template_id": merged_unit.frame_template_id,
"section_singles": section_singles,
})
audit["rebuild_attempts"] = rebuild_attempts
# u3 — gating + swap path.
# Per-merge decision: split | skip(reason). Then a cumulative layout-cap
# check aborts ALL would-be splits if projected post-split count > 4
# (Stage 2 Q2 default — keep merged, no partial split; v0
# ``select_layout_preset`` supports 1~4 units max).
plans: list[dict] = []
for merged_unit, attempt in zip(detected, rebuild_attempts):
required_sids = set(merged_unit.source_section_ids)
built_sids = {
entry["section_id"]
for entry in attempt["section_singles"]
if entry["build_result"] == "ok"
}
if built_sids != required_sids:
# Some sections failed to rebuild — coverage equality violated.
# IMP-47B (#76) will handle the merged unit directly.
plans.append({
"merged": merged_unit,
"decision": "skip",
"reason": "incomplete_rebuild",
"missing": sorted(required_sids - built_sids),
})
continue
built_units = [
entry["unit"]
for entry in attempt["section_singles"]
if entry["build_result"] == "ok"
]
non_reject_count = sum(1 for u in built_units if u.label != "reject")
if non_reject_count == 0:
# No child section gains a non-reject frame — split is not
# beneficial. IMP-47B (#76) handles the merge directly.
plans.append({
"merged": merged_unit,
"decision": "skip",
"reason": "no_beneficial_split",
})
continue
plans.append({
"merged": merged_unit,
"decision": "split",
"singles": built_units,
"non_reject_count": non_reject_count,
})
# Cumulative layout-cap projection across all would-be splits.
projected_count = len(units)
for plan in plans:
if plan["decision"] == "split":
projected_count += len(plan["singles"]) - 1
if projected_count > 4:
for plan in plans:
if plan["decision"] == "split":
plan["decision"] = "skip"
plan["reason"] = "layout_cap_exceeded"
plan["projected_count"] = projected_count
# Build out_units by walking the input list once. Identity match by
# ``id(unit)`` keeps the swap deterministic and preserves order.
plan_by_unit_id = {id(plan["merged"]): plan for plan in plans}
out_units: list[CompositionUnit] = []
applied = False
for unit in units:
plan = plan_by_unit_id.get(id(unit))
if plan is None:
out_units.append(unit)
continue
if plan["decision"] == "split":
applied = True
for single in plan["singles"]:
# ★ Stage 1 Q3 YES — additive telemetry tag, no schema add.
# Overrides the v4 match's selection_path for split-produced
# singles only; non-resplit code paths are unaffected.
single.selection_path = "resplit_from_merge"
out_units.extend(plan["singles"])
audit["split_units"].append({
"merged_source_section_ids": list(plan["merged"].source_section_ids),
"merged_template_id": plan["merged"].frame_template_id,
"non_reject_count": plan["non_reject_count"],
"split_singles": [
{
"section_id": s.source_section_ids[0],
"template_id": s.frame_template_id,
"label": s.label,
"phase_z_status": s.phase_z_status,
}
for s in plan["singles"]
],
})
else: # skip
out_units.append(unit)
skip_entry: dict = {
"merged_source_section_ids": list(plan["merged"].source_section_ids),
"merged_template_id": plan["merged"].frame_template_id,
"reason": plan["reason"],
}
if plan["reason"] == "incomplete_rebuild":
skip_entry["missing_section_ids"] = list(plan["missing"])
if plan["reason"] == "layout_cap_exceeded":
skip_entry["projected_post_split_count"] = plan["projected_count"]
audit["skipped_units"].append(skip_entry)
audit["applied"] = applied
audit["post_split_unit_count"] = len(out_units)
if applied:
# ``select_layout_preset`` is deterministic on unit count (v0).
# u5 (pipeline) re-derives layout preset over the same out_units list;
# both values stay consistent by construction.
audit["post_split_layout_preset"] = select_layout_preset(out_units)
audit.pop("skipped_reason", None)
else:
audit["post_split_layout_preset"] = None
audit["skipped_reason"] = "no_split_applied"
return out_units, audit
+68 -12
View File
@@ -34,8 +34,12 @@ frame_reselect (V4 top-k 의 다른 frame)
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
```
`details_popup_escalation` 은 본 매핑에 *없음* — tabular_overflow / structural_major_overflow /
frame_reselect 실패 이후 단계에서 다룸 (별 step).
IMP-35 (#64) u2 — cascade terminal landed. `frame_reselect_insufficient`
(post-frame remeasure failure, classifier path locked in u1) now routes onto
`details_popup_escalation`. The status table records the popup action as
MISSING here; the actual executor stub + MISSING→IMPLEMENTED flip lives in
`src/phase_z2_router.py` (u3 surface), so this module advertises the cascade
terminal without claiming an implementation it does not own.
"""
from __future__ import annotations
@@ -74,6 +78,13 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
"font_step_compression salvage step failed — FONT_SIZE_STEPS exhausted "
"down to the floor without resolving overflow (or text_metrics missing)"
),
"frame_reselect_insufficient": (
"frame_reselect salvage step failed — V4 top-k alternate frame swap "
"re-rendered + post-frame remeasure (run_overflow_check) still fails. "
"IMP-35 (#64) u1 contract: emitted from salvage_steps[-1].action == "
"'frame_reselect' AND passed=False AND post_salvage_overflow present. "
"Routes to details_popup_escalation in u2 (cascade terminal)."
),
}
@@ -86,6 +97,12 @@ SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = {
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
"glue_compression": "glue_absorption_insufficient",
"font_step_compression": "font_step_insufficient",
# IMP-35 (#64) u1: post-frame remeasure failure. frame_reselect salvage step
# writes a salvage_steps entry with action='frame_reselect', passed=False,
# and post_salvage_overflow populated by run_overflow_check on the swapped
# frame's HTML. classifier reads that entry; u2 adds the NEXT_ACTION row
# that routes this onto details_popup_escalation.
"frame_reselect": "frame_reselect_insufficient",
}
@@ -98,6 +115,14 @@ NEXT_ACTION_BY_FAILURE: dict[str, str] = {
"glue_absorption_insufficient": "font_step_compression",
"font_step_insufficient": "layout_adjust",
"rerender_still_fails": "frame_reselect",
# IMP-35 (#64) u2 — cascade terminal. frame_reselect salvage exhausted
# (post-frame remeasure failed; classifier path gated on
# post_salvage_overflow per u1/q4) escalates onto details_popup_escalation.
# Popup body holds full MDX source; preview shows summary/subset
# (CLAUDE.md 자세히보기 원칙). Executor + MISSING→IMPLEMENTED flip lands
# in u3 (src/phase_z2_router.py); this module owns the cascade mapping
# only.
"frame_reselect_insufficient": "details_popup_escalation",
"not_attempted": "none",
}
@@ -127,6 +152,12 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
"frame/zone 조합 자체 부적합, V4 top-k 의 다른 frame 평가 (frame_reselect). "
"popup 직행은 아직 빠름 (tabular / structural_major 가 아닌 한)"
),
"frame_reselect_insufficient": (
"V4 top-k frame swap + 명시적 post-frame remeasure 까지 했는데도 overflow "
"잔존 → cascade terminal 인 details_popup_escalation 으로 escalate. "
"본문 = summary/subset, popup = MDX 원문 (자세히보기 원칙). "
"AI repair 진입 전 deterministic 마지막 단계."
),
"not_attempted": (
"retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요"
),
@@ -145,6 +176,12 @@ NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css
"layout_adjust": "MISSING",
"frame_reselect": "MISSING",
# IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The
# router executor stub + MISSING→IMPLEMENTED flip lives in
# src/phase_z2_router.py (u3). Keeping this entry as MISSING until u3
# lands prevents premature "popup ready" claims from the failure-router
# surface.
"details_popup_escalation": "MISSING",
"none": "n/a",
}
@@ -170,21 +207,40 @@ def classify_retry_failure(retry_trace: dict) -> Optional[dict]:
# case 0.7 : salvage chain attempted and ended in a salvage-level failure.
# zone_ratio_retry 가 먼저 실패한 뒤 _attempt_salvage_chain 이 가동된 path —
# 마지막 salvage step 의 action 으로 failure_type 을 분류한다. u3 가 routing.
#
# IMP-35 (#64) u1 — q4 explicit remeasure contract: the frame_reselect
# branch is gated on post_salvage_overflow being present on the salvage
# step. A bare passed=False flag with no remeasure payload is *not*
# sufficient to emit frame_reselect_insufficient (which routes to
# details_popup_escalation in u2). When the gate fails, the classifier
# falls through to lower-priority cases so the salvage trace surfaces as
# an unmatched defensive fallback instead of a spurious popup escalation.
salvage_steps = retry_trace.get("salvage_steps") or []
if salvage_steps:
last = salvage_steps[-1] or {}
if not last.get("passed"):
action = (last.get("action") or "").lower()
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
if ftype is not None:
reason = last.get("failure_reason") or ""
return {
"failure_type": ftype,
"classification_rule": (
f"salvage_steps[-1].action == {action!r} "
f"AND passed=False. raw failure_reason: {reason!r}"
),
}
frame_reselect_blocked = (
action == "frame_reselect"
and not last.get("post_salvage_overflow")
)
if not frame_reselect_blocked:
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
if ftype is not None:
reason = last.get("failure_reason") or ""
rule_suffix = (
" AND post_salvage_overflow present"
if action == "frame_reselect"
else ""
)
return {
"failure_type": ftype,
"classification_rule": (
f"salvage_steps[-1].action == {action!r} "
f"AND passed=False{rule_suffix}. "
f"raw failure_reason: {reason!r}"
),
}
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
if not retry_trace.get("retry_attempted"):
+65 -3
View File
@@ -42,6 +42,22 @@ class FitError(Exception):
"""
class BuilderMissingError(FitError):
"""Contract.payload.builder ↔ PAYLOAD_BUILDERS registry mismatch.
FitError subclass — pipeline 의 기존 `except FitError` 경로가 그대로
adapter_needed 로 라우팅 (mdx04 hard crash 차단, IMP-#85 u1).
"""
class CatalogInvariantError(Exception):
"""Catalog ↔ runtime registry drift detected at load time.
Boot-time invariant violation (IMP-#85 u2). Distinct from FitError:
runtime fallback 대상이 아니라 catalog wiring 결함 (fail-fast).
"""
# ─── Catalog loading ──────────────────────────────────────────────
_CATALOG_CACHE: dict | None = None
@@ -50,7 +66,9 @@ _CATALOG_CACHE: dict | None = None
def load_frame_contracts() -> dict:
global _CATALOG_CACHE
if _CATALOG_CACHE is None:
_CATALOG_CACHE = yaml.safe_load(CATALOG_PATH.read_text(encoding="utf-8")) or {}
catalog = yaml.safe_load(CATALOG_PATH.read_text(encoding="utf-8")) or {}
_check_catalog_builder_invariant(catalog)
_CATALOG_CACHE = catalog
return _CATALOG_CACHE
@@ -686,6 +704,50 @@ PAYLOAD_BUILDERS: dict[str, Callable] = {
}
# ─── Catalog builder invariant (IMP-#85 u2) ──────────────────────
def _check_catalog_builder_invariant(catalog: dict) -> None:
"""Every non-`visual_pending` contract must declare a registered builder.
`visual_pending: true` contracts are scaffolding records whose builders
are tracked as VP backlog (별 axis IMP-04b / #42) — skipped here so the
catalog can keep declaring them without breaking boot.
Violations are aggregated and raised together so first-fix iteration sees
the full drift surface, not just the first row.
Raises:
CatalogInvariantError — when one or more live (non-VP) contracts
either omit `payload.builder` or reference a name absent from
`PAYLOAD_BUILDERS`.
"""
violations: list[str] = []
for template_id, contract in catalog.items():
if not isinstance(contract, dict):
continue
if contract.get("visual_pending") is True:
continue
payload = contract.get("payload") or {}
builder_name = payload.get("builder") if isinstance(payload, dict) else None
if not builder_name:
violations.append(
f"Contract '{template_id}' (non-VP) missing payload.builder."
)
continue
if builder_name not in PAYLOAD_BUILDERS:
violations.append(
f"Contract '{template_id}' (non-VP) references payload.builder="
f"'{builder_name}' not in PAYLOAD_BUILDERS registry."
)
if violations:
raise CatalogInvariantError(
f"Catalog builder invariant violated "
f"({len(violations)} non-VP contract(s)):\n - "
+ "\n - ".join(violations)
+ f"\nRegistered builders: {sorted(PAYLOAD_BUILDERS.keys())}"
)
# ─── Generic mapper (single dispatch via builder) ────────────────
def _check_cardinality(contract: dict, units: list, section) -> None:
@@ -843,13 +905,13 @@ def map_with_contract(section, contract: dict) -> dict:
payload_spec = contract["payload"]
builder_name = payload_spec.get("builder")
if not builder_name:
raise ValueError(
raise BuilderMissingError(
f"Contract '{contract['template_id']}' missing payload.builder. "
f"available: {sorted(PAYLOAD_BUILDERS.keys())}"
)
builder = PAYLOAD_BUILDERS.get(builder_name)
if builder is None:
raise ValueError(
raise BuilderMissingError(
f"Contract '{contract['template_id']}' references payload.builder="
f"'{builder_name}' but PAYLOAD_BUILDERS has no such entry. "
f"available: {sorted(PAYLOAD_BUILDERS.keys())}"
+656 -11
View File
@@ -41,8 +41,10 @@ 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,
select_display_strategy_candidates,
select_layout_candidates,
select_region_layout_candidates,
@@ -56,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,
@@ -84,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 ──────────────────────────────────────────────────
@@ -1090,6 +1099,20 @@ def lookup_v4_all_judgments(
return out
def _is_visual_pending(template_id: str) -> bool:
"""IMP-#85 u4 — return True iff catalog marks contract as ``visual_pending``.
Data-driven from ``frame_contracts.yaml`` (no hard-coded frame allow-list).
Used by ``lookup_v4_candidates`` to exclude VP frames from the live
candidate set; ``lookup_v4_all_judgments`` raw telemetry stays untouched
(Step 7-A axis preserves full 32-frame evidence for the frontend).
"""
contract = get_contract(template_id)
if not isinstance(contract, dict):
return False
return contract.get("visual_pending") is True
def lookup_v4_candidates(
v4: dict,
section_id: str,
@@ -1103,6 +1126,7 @@ def lookup_v4_candidates(
v4_candidates = [
c for c in judgments_full32
if c["label"] != "reject"
and not visual_pending(c.template_id) # IMP-#85 u4
][:max_n]
Returns:
@@ -1114,6 +1138,11 @@ def lookup_v4_candidates(
lookup_v4_match() (rank-1) 그대로. Step 6 plan_composition()
호출처 무변. 함수는 Step 5 artifact + Step 9 application_plan input
위한 entry point.
IMP-#85 u4 — visual_pending frames are excluded from the live candidate
set (catalog scaffolding without registered builder would crash the
mapper). lookup_v4_all_judgments raw telemetry is intentionally NOT
gated here.
"""
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
@@ -1124,6 +1153,9 @@ def lookup_v4_candidates(
for j in judgments:
if j.get("label") == "reject":
continue
tid = j.get("template_id")
if tid and _is_visual_pending(tid):
continue
candidates.append(_v4_match_from_judgment(section_id, j))
if len(candidates) >= max_n:
break
@@ -1688,6 +1720,14 @@ def _override_to_grid_tracks(
R = len(rows_grid)
C = len(rows_grid[0])
# Hot-fix (2026-05-22): partial override 버그 fix — override 없는 track 은
# default 비율로 fallback. 이전엔 0 반환 → normalize 후 다른 track 이 모든 공간 흡수.
_default_result = _build_grid_dynamic_2d(preset, zones_data, gap=gap)
_default_widths = _default_result.get("widths_px", []) or []
_default_heights = _default_result.get("heights_px", []) or []
_sum_w = sum(_default_widths) if _default_widths else 1.0
_sum_h = sum(_default_heights) if _default_heights else 1.0
occupancy: list[tuple[dict, set[int], set[int]]] = []
for z in zones_data:
pos = z["position"]
@@ -1702,17 +1742,19 @@ def _override_to_grid_tracks(
single = [z for z, rr, _cc in occupancy if rr == {idx}]
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
key = "h"
_fallback = (_default_heights[idx] / _sum_h) if idx < len(_default_heights) and _sum_h else (1.0 / R)
else:
single = [z for z, _rr, cc in occupancy if cc == {idx}]
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
key = "w"
_fallback = (_default_widths[idx] / _sum_w) if idx < len(_default_widths) and _sum_w else (1.0 / C)
candidates = single or allspan
vals = [
float(override_zone_geometries[z["position"]][key])
for z in candidates
if z["position"] in override_zone_geometries
]
return max(vals) if vals else 0.0
return max(vals) if vals else _fallback
row_values = [_track_value(r, "row") for r in range(R)]
col_values = [_track_value(c, "col") for c in range(C)]
@@ -1792,10 +1834,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
if override_zone_geometries:
if layout_preset == "horizontal-2":
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
overridden_h = sum(
float(override_zone_geometries[p]["h"])
for p in positions if p in override_zone_geometries
)
non_overridden = [p for p in positions if p not in override_zone_geometries]
per_non = max(0.0, 1.0 - overridden_h) / max(len(non_overridden), 1)
ratios = []
for pos in positions:
geom = override_zone_geometries.get(pos)
ratios.append(float(geom["h"]) if geom else 0.0)
ratios.append(float(geom["h"]) if geom else per_non)
total = sum(ratios)
if total > 0:
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios]
@@ -1817,10 +1867,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
# cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
# PR 1 keeps fr-string cols for legacy preserve; widths_px is
# populated in pixels for _compute_per_zone_geometry length contract.
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
overridden_w = sum(
float(override_zone_geometries[p]["w"])
for p in positions if p in override_zone_geometries
)
non_overridden = [p for p in positions if p not in override_zone_geometries]
per_non = max(0.0, 1.0 - overridden_w) / max(len(non_overridden), 1)
ratios = []
for pos in positions:
geom = override_zone_geometries.get(pos)
ratios.append(float(geom["w"]) if geom else 0.0)
ratios.append(float(geom["w"]) if geom else per_non)
total = sum(ratios)
if total > 0:
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
@@ -2449,6 +2507,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,
@@ -2843,6 +2936,27 @@ def write_overflow_error(run_dir: Path, overflow: dict) -> Path:
# ─── Debug.json (single slide + zones[]) ───────────────────────
def _is_empty_shell_unit(u: CompositionUnit) -> bool:
"""IMP-87 u1 — true when a CompositionUnit is the IMP-30 u4 empty-shell
placeholder (frame_template_id="__empty__" / label="empty_shell").
Used by compute_slide_status to redefine `full_mdx_coverage` over
rendered content units only: an empty-shell unit attaches the aligned
section_ids to a `__empty__` frame for layout purposes, but the slide
surface carries no MDX content for those sections. Counting it as
coverage would violate feedback_artifact_status_naming (overall/coverage
must reflect actual content state, not pipeline completion).
"""
if getattr(u, "frame_template_id", None) == "__empty__":
return True
if getattr(u, "label", None) == "empty_shell":
return True
if getattr(u, "merge_type", None) == "empty_shell":
return True
return False
def compute_slide_status(sections: list[MdxSection],
units: list[CompositionUnit],
comp_debug: dict,
@@ -2871,10 +2985,21 @@ def compute_slide_status(sections: list[MdxSection],
Stage 1 Q3 + Codex #10 D4 lock.)
"""
aligned_ids = [s.section_id for s in sections]
covered = set()
# IMP-87 u1 — split coverage into content vs empty-shell cover.
# `covered` (legacy, kept for downstream display) still tracks every
# section attached to any selected unit. `content_covered` is the new
# honesty axis: only non-empty-shell units count as "rendered with
# content". Sections attached solely to an empty-shell placeholder
# are routed into `filtered_section_ids` so Step 20 cannot report
# full_mdx_coverage=True for an EMPTY-SHELL-only slide (the Case B
# honesty defect documented in IMP-87 Stage 1 / Codex #2).
covered: set = set()
content_covered: set = set()
for u in units:
covered.update(u.source_section_ids)
filtered_ids = sorted(set(aligned_ids) - covered)
if not _is_empty_shell_unit(u):
content_covered.update(u.source_section_ids)
filtered_ids = sorted(set(aligned_ids) - content_covered)
full_coverage = len(filtered_ids) == 0
visual_passed = bool(overflow.get("passed", False))
@@ -2964,7 +3089,34 @@ def compute_slide_status(sections: list[MdxSection],
"position": source_position,
})
if full_coverage and visual_passed:
# IMP-87 u2 — empty-shell vs content unit accounting (additive surface fields).
# Splits the selected units into the two classes that u1 already separates
# for coverage purposes, then promotes "no content unit at all" to an
# explicit overall enum value (EMPTY_SHELL_NO_CONTENT) so an EMPTY-SHELL-only
# slide cannot route through any of the four existing ladder outcomes that
# implicitly assume there is real content on the page.
# - empty_shell_units: units flagged by _is_empty_shell_unit (IMP-87 u1)
# - content_units : the remainder (real frame_template_id)
# - empty_shell_dominant: True when there is NO content unit and at least
# one empty-shell unit (the Case B mdx05 honesty defect surface)
empty_shell_units = [u for u in units if _is_empty_shell_unit(u)]
content_units = [u for u in units if not _is_empty_shell_unit(u)]
empty_shell_section_ids: set = set()
for u in empty_shell_units:
empty_shell_section_ids.update(getattr(u, "source_section_ids", []) or [])
content_rendered_section_ids: set = set()
for u in content_units:
content_rendered_section_ids.update(getattr(u, "source_section_ids", []) or [])
empty_shell_dominant = (len(content_units) == 0) and (len(empty_shell_units) >= 1)
if empty_shell_dominant:
# IMP-87 u2 — EMPTY_SHELL_NO_CONTENT precedes the legacy 4-way ladder.
# Even if visual_check passed (placeholder zones don't overflow) and
# filtered_section_ids only lists the IMP-87 u1 routed empty-shell
# sections, this slide carries zero MDX content and must not look
# shippable. Honors feedback_artifact_status_naming.
overall = "EMPTY_SHELL_NO_CONTENT"
elif full_coverage and visual_passed:
overall = "PASS"
elif full_coverage and not visual_passed:
overall = "RENDERED_WITH_VISUAL_REGRESSION"
@@ -3025,6 +3177,15 @@ def compute_slide_status(sections: list[MdxSection],
# IMP-30 u6 — additive provisional qualifiers (overall enum unchanged).
"provisional_first_render_count": len(provisional_first_render_units),
"provisional_first_render_units": provisional_first_render_units,
# IMP-87 u2 — empty-shell vs content unit accounting (additive surface).
# Names mirror provisional_first_render_*/fallback_selection_count shape
# so downstream readers (CLI tail in u3, status board UI, regression
# tests in u5) can pick them up without schema reshaping.
"empty_shell_unit_count": len(empty_shell_units),
"empty_shell_section_ids": sorted(empty_shell_section_ids),
"content_unit_count": len(content_units),
"content_rendered_section_ids": sorted(content_rendered_section_ids),
"empty_shell_dominant": empty_shell_dominant,
"overall": overall,
"note": (
"자동 파이프라인 결과 보고. review/UI 개념 X. final.html 파일명 != PASS 의미. "
@@ -3032,7 +3193,9 @@ def compute_slide_status(sections: list[MdxSection],
"adapter_needed_count > 0 = mapper 가 contract 와 안 맞아 자동 렌더 못 한 zone 존재. "
"content_truncated_count > 0 = builder 가 truncate 한 zone 존재 (rendered 됐지만 일부 콘텐츠 손실). "
"provisional_first_render_count > 0 = IMP-30 first-render invariant 가 작동한 unit 존재 "
"(empty_shell / chain_exhausted_provisional / 등 — needs user/AI adaptation)."
"(empty_shell / chain_exhausted_provisional / 등 — needs user/AI adaptation). "
"overall == EMPTY_SHELL_NO_CONTENT (IMP-87 u2) = content_unit_count==0 이고 "
"empty_shell_unit_count>=1 — 슬라이드에 실 콘텐츠 없음. PASS 보고 금지 (Case B honesty 가드)."
),
}
@@ -3379,6 +3542,7 @@ def run_phase_z2_mvp1(
override_frames: Optional[dict[str, str]] = None,
override_zone_geometries: Optional[dict[str, dict]] = None,
override_section_assignments: Optional[dict[str, list[str]]] = None,
override_image_overrides: Optional[dict[str, dict]] = None,
) -> Path:
"""MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary.
@@ -3392,6 +3556,15 @@ def run_phase_z2_mvp1(
으로 강제. unit_id = "+".join(source_section_ids) (e.g., "03-1"
또는 "03-1+03-2"). 매칭 unit v4_candidates 있는 entry
entry score / label 함께 갱신. 없으면 template_id 변경.
override_image_overrides : {image_id: {x, y, w, h}} IMP-51 (#79) u5 axis.
image_id = stable id stamped on user-content `<img>` tags by
``src/image_id_stamper.py`` (u4). x/y/w/h are percent-of-slide
coordinates (0100, slide-absolute). Forward-compat kwarg: the
render-time CSS injection that consumes this mapping lands in
u7; until u7 wires the consumer, accepting the kwarg keeps the
backend contract (KNOWN_AXES u1 + Vite allowlist u2 + typed
client u3 + stamper u4) end-to-end addressable from CLI without
diverging the function signature.
"""
mdx_path = Path(mdx_path)
if run_id is None:
@@ -3966,6 +4139,52 @@ def run_phase_z2_mvp1(
file=sys.stderr,
)
# IMP-48 (#77) — re-split merged-reject units into per-section singles.
# One-shot, deterministic (AI=0) post-pass. Fires AFTER all Step 6 settling
# chains (initial plan_composition / u12 mixed admission / u4 provisional
# retry / empty-shell) and AFTER section_assignment_plan is known, but
# BEFORE the Step 6 artifact write below — so the artifact reflects the
# post-resplit unit list. SKIPS when --override-section-assignments is
# active (IMP-06 / #6 is the ground truth). Helper guardrails (coverage
# equality / beneficial split / layout cap ≤ 4) keep mdx03 byte-identical
# (no-op on use_as_is / light_edit slides). u5 re-derives layout_preset
# below using the audit payload.
units, _imp48_audit = resplit_all_reject_merges(
units,
sections,
lookup_fn,
V4_LABEL_TO_PHASE_Z_STATUS,
MVP1_ALLOWED_STATUSES,
capacity_fit_fn=compute_capacity_fit,
v4_candidates_lookup_fn=candidates_lookup_fn,
section_assignment_override=section_assignment_plan is not None,
)
comp_debug["imp48_resplit"] = _imp48_audit
# u5 — re-derive layout_preset from helper audit (post-split count via
# select_layout_preset(out_units)). Helper guarantees post_split_unit_count
# ≤ 4 (layout cap abort), so the derived preset is always renderable by
# LAYOUT_PRESETS. Respect --override-layout when present (user's explicit
# choice wins over auto-redrive; mirrors the override gate above at L3697).
if _imp48_audit.get("applied"):
_imp48_post_preset = _imp48_audit.get("post_split_layout_preset")
if _imp48_post_preset and not layout_override_applied:
if _imp48_post_preset != layout_preset:
print(
f" [IMP-48] layout_preset re-derived: {layout_preset}"
f"{_imp48_post_preset} (post-split unit count="
f"{_imp48_audit.get('post_split_unit_count')})",
file=sys.stderr,
)
layout_preset = _imp48_post_preset
print(
f" [IMP-48] re-split applied — "
f"split={len(_imp48_audit.get('split_units', []))} "
f"skipped={len(_imp48_audit.get('skipped_units', []))} "
f"post_count={_imp48_audit.get('post_split_unit_count')} "
f"post_preset={_imp48_audit.get('post_split_layout_preset')!r}",
file=sys.stderr,
)
print(f" preset : {layout_preset} ({len(units)} units, composition v0 count-based)")
for u in units:
print(f" unit : {u.source_section_ids} merge={u.merge_type}"
@@ -4011,6 +4230,15 @@ def run_phase_z2_mvp1(
}
for u in units
],
# IMP-48 (#77) — re-split audit. Additive field. AI=0 deterministic
# one-shot post-pass on Step 6 settling result. applied=True means
# ≥1 parent_merged / parent_merged_inferred reject unit was split
# into per-section singles; selected_units already reflects the
# post-split list. Skipped reasons (incomplete_rebuild /
# no_beneficial_split / layout_cap_exceeded) keep the merged unit
# for IMP-47B (#76) AI handoff. section_assignment_override skip
# honors IMP-06 (#6) zoneSections ground truth.
"imp48_resplit": _imp48_audit,
},
step_status="done",
pipeline_path_connected=True,
@@ -4020,6 +4248,11 @@ def run_phase_z2_mvp1(
"composition v0 count-based — sections → candidates → score → greedy select. "
"Step 6-A (사용자 lock 2026-05-08): selected_units[i].v4_candidates 추가 "
"(non-reject max-6 후보 list, candidates[0] = 단일 frame_* 와 일관). "
"IMP-48 (#77, 2026-05-22): merged-reject 자동 분리 post-pass — "
"parent_merged / parent_merged_inferred + label=reject + ≥2 sections "
"→ per-section singles (each own rank-1 V4 evidence + raw_content 보존). "
"guardrails: coverage equality / beneficial split (≥1 non-reject) / "
"layout cap (≤4 units). imp48_resplit audit additive. "
"logic 무변 — runtime 결과 동일. Step 9 application_plan input."
),
)
@@ -4135,6 +4368,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__",
@@ -4144,6 +4382,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,
@@ -4265,15 +4504,87 @@ def run_phase_z2_mvp1(
try:
slot_payload = map_mdx_to_slots(synth_section, unit.frame_template_id)
except FitError as e:
_fit_error_str = str(e)
_unit_provisional = bool(getattr(unit, "provisional", False))
adapter_record = {
"position": position,
"source_section_ids": unit.source_section_ids,
"merge_type": unit.merge_type,
"template_id": unit.frame_template_id,
"reason": "fit_error",
"fit_error": str(e),
"fit_error": _fit_error_str,
}
adapter_needed_units.append(adapter_record)
# IMP-86 u1 — placeholder zones_data + debug_zone keep the failed
# unit's preset position so downstream build_layout_css /
# _compute_per_zone_geometry observe len(zones_data) == active
# layout preset's css_areas rows (R). Without this both arrays
# drift relative to R, raising
# ValueError("heights_px length N != grid rows R=M") at
# _compute_per_zone_geometry. Mirrors the IMP-30 empty-shell
# pattern: zones_data uses template_id="__empty__" so render_slide
# short-circuits to empty partial_html; debug_zone keeps the
# original V4 evidence and adapter_needed_units stays the
# authoritative adapter signal.
# IMP-86 u5 — per-record telemetry. adapter_needed=True +
# mapper_fit_error=<str(FitError)> + provisional mirror the
# adapter signal directly on each placeholder record so
# debug.json / final.html consumers can identify the adapter
# contract surface from the zones array alone, without joining
# against slide_status.adapter_needed_units. adapter_needed_units
# itself is unchanged (still the authoritative per-slide list).
_placeholder_popup = compose_zone_popup_payload(unit, 0)
zones_data.append({
"position": position,
"template_id": "__empty__",
"slot_payload": {},
"content_weight": {"score": 0},
"min_height_px": min_height_px,
"assignment_source": "imp86_u1_adapter_needed",
"section_assignment_override": False,
"provisional": _unit_provisional,
"adapter_needed": True,
"mapper_fit_error": _fit_error_str,
**_placeholder_popup,
})
debug_zones.append({
"position": position,
"source_section_ids": list(unit.source_section_ids),
"merge_type": unit.merge_type,
"title": unit.title,
"v4_rank1_frame_id": unit.frame_id,
"v4_rank1_frame_number": unit.frame_number,
"v4_template_id": unit.frame_template_id,
"v4_label": unit.label,
"v4_confidence": float(unit.confidence or 0.0),
"v4_selected_rank": unit.v4_rank,
"selection_path": unit.selection_path,
"fallback_reason": unit.fallback_reason,
"fallback_used": bool(unit.selection_path and "fallback" in unit.selection_path),
"phase_z_status": unit.phase_z_status,
"composition_score": float(unit.score or 0.0),
"composition_rationale": dict(unit.rationale or {}),
"composition_notes": list(unit.notes),
"mapper_type": "adapter_needed",
"contract_id": unit.frame_template_id,
"contract_frame_id": contract_frame_id,
"builder": builder_name,
"min_height_px": min_height_px,
"slot_payload_keys": [],
"content_truncated_count": None,
"assets_dir": None,
"content_weight": {"score": 0},
"placement_trace": placement_trace,
"assignment_source": "imp86_u1_adapter_needed",
"section_assignment_override": False,
"replaced_auto_unit": None,
"skipped_collided_auto_units": [],
"uncovered_section_ids": [],
"skipped_reason": "imp86_u1_adapter_needed_mapper_fit_error",
"provisional": _unit_provisional,
"adapter_needed": True,
"mapper_fit_error": _fit_error_str,
})
print(f" adapter : zone--{position} {unit.source_section_ids}"
f"{unit.frame_template_id} FitError → adapter_needed (skip render)")
continue
@@ -4314,6 +4625,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,
@@ -4323,6 +4643,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,
@@ -4378,6 +4699,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__",
@@ -4389,6 +4716,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,
@@ -4746,6 +5077,38 @@ def run_phase_z2_mvp1(
# 6. Build layout CSS — horizontal-2 = dynamic heights (regression preserve), 그 외 = fr default.
# Step D-ext : override_zone_geometries 가 들어오면 layout_css 강제.
# IMP-86 u2 — pre-build layout invariant guard. zones_data / debug_zones
# MUST be cardinality- and position-aligned with the active layout
# preset's css_areas tokens before build_layout_css derives heights_px
# / widths_px and _compute_per_zone_geometry validates them against R/C.
# If a mapper FitError path (IMP-86 u1) — or any future zone-drop path —
# forgets to append a placeholder, the resulting shape drift would
# surface as a confusing `heights_px length N != grid rows R=M`
# ValueError deep inside the geometry helper. This guard fails fast at
# the pipeline boundary with preset / expected positions / actual
# positions / count diagnostics so the root cause is obvious from the
# log line (factual_verification: value + path + upstream).
_active_preset = LAYOUT_PRESETS[layout_preset]
_expected_positions = _parse_css_areas(_active_preset["css_areas"])[1]
_actual_positions = [zd["position"] for zd in zones_data]
_debug_positions = [dz["position"] for dz in debug_zones]
if (
len(zones_data) != len(_expected_positions)
or len(debug_zones) != len(_expected_positions)
or sorted(_actual_positions) != sorted(_expected_positions)
or sorted(_debug_positions) != sorted(_expected_positions)
):
raise ValueError(
"phase_z2_pipeline pre-build layout invariant violation: "
f"layout_preset={layout_preset!r} "
f"css_areas={_active_preset['css_areas']!r} "
f"expected_positions={_expected_positions!r} "
f"zones_data_positions={_actual_positions!r} "
f"debug_zones_positions={_debug_positions!r} "
f"zones_count={len(zones_data)} "
f"debug_count={len(debug_zones)} "
f"expected_count={len(_expected_positions)}"
)
layout_css = build_layout_css(
layout_preset, zones_data, override_zone_geometries=override_zone_geometries
)
@@ -5288,6 +5651,36 @@ def run_phase_z2_mvp1(
# 7. Render single slide
html = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css)
# IMP-51 (#79) u4 + u7 — stamp user-content imgs with stable id /
# role attrs, then inject persisted `image_overrides` CSS so the
# next render re-applies the user-edited geometry.
#
# Forward-compat: `stage0_normalized_assets["images"]` is empty in
# every current Phase Z run (Q1 = A confirmed at Stage 1), so the
# stamper returns an empty `stamped_image_ids` list and the CSS
# builder short-circuits to "". The HTML is therefore byte-for-byte
# identical to the pre-IMP-51 output until Phase Z starts emitting
# user-content imgs (separate axis, out of scope for #79).
from src.image_id_stamper import (
build_image_overrides_style,
inject_image_overrides_style,
stamp_user_content_images,
)
_user_content_image_srcs = [
(entry.get("path") or entry.get("src") or "")
for entry in (stage0_normalized_assets.get("images") or [])
if isinstance(entry, dict)
]
html, _stamped_image_ids = stamp_user_content_images(
html, sources=_user_content_image_srcs,
)
if override_image_overrides:
_image_overrides_css = build_image_overrides_style(
override_image_overrides, _stamped_image_ids,
)
if _image_overrides_css:
html = inject_image_overrides_style(html, _image_overrides_css)
# 8. Write final.html
out_path = run_dir / "final.html"
out_path.write_text(html, encoding="utf-8")
@@ -5488,6 +5881,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",
@@ -5558,7 +5999,18 @@ def run_phase_z2_mvp1(
)
# Step 20 HTML — 최종 판정 시각 보고
_overall = slide_status.get("overall", "?")
_ov_class = "pass" if "PASS" in _overall else "fail" if "FAIL" in _overall or "REGRESSION" in _overall else "partial"
# IMP-87 u3 — EMPTY_SHELL_NO_CONTENT maps to fail styling (not partial).
# Placeholder-only slide is unshippable even when visual check passes,
# so final_status.html must render red, matching the CLI BLOCKED exit.
_ov_class = (
"pass" if "PASS" in _overall
else "fail" if (
"FAIL" in _overall
or "REGRESSION" in _overall
or _overall == "EMPTY_SHELL_NO_CONTENT"
)
else "partial"
)
_vfs = slide_status.get("visual_fail_reasons") or []
_vfs_html = (
"<ul>" + "".join(f"<li>{v}</li>" for v in _vfs) + "</ul>"
@@ -5685,6 +6137,34 @@ def run_phase_z2_mvp1(
print(f" error : {err_path}", file=sys.stderr)
sys.exit(1)
# IMP-87 u3 — EMPTY_SHELL_NO_CONTENT BLOCKED branch precedes the
# partial-coverage return so empty-shell-only runs cannot exit zero.
# Placeholder zones do not overflow, so visual_check_passed is True here;
# the diagnostic surfaces empty-shell counters from compute_slide_status.
if overall == "EMPTY_SHELL_NO_CONTENT":
print(
f"\n[Phase Z-2 IMP-87 u3] BLOCKED @ empty_shell_no_content ({overall})",
file=sys.stderr,
)
print(
f" empty_shell_unit_count = {slide_status.get('empty_shell_unit_count', 0)}",
file=sys.stderr,
)
print(
f" empty_shell_section_ids = {slide_status.get('empty_shell_section_ids') or []}",
file=sys.stderr,
)
print(
f" content_unit_count = {slide_status.get('content_unit_count', 0)}",
file=sys.stderr,
)
print(
" reason : every selected unit is empty_shell placeholder — "
"final.html 은 placeholder shell, ship 불가",
file=sys.stderr,
)
sys.exit(1)
if not slide_status["full_mdx_coverage"]:
print(
f"\n[Phase Z-2 MVP-1.5b] PARTIAL — visual check OK 지만 "
@@ -5757,6 +6237,27 @@ if __name__ == "__main__":
"--override-section-assignment bottom=03-2,03-3"
),
)
# IMP-51 (#79) u5 — image override CLI flag. IMAGE_ID = stable id stamped
# on user-content `<img>` tags by src/image_id_stamper.py (u4). X,Y,W,H =
# percent-of-slide coordinates (0100, slide-absolute), consistent with
# the typed client `ImageOverride` shape (u3, userOverridesApi.ts) and
# the persisted `image_overrides` axis (u1, KNOWN_AXES). The render-time
# CSS injection consuming this mapping lands in u7; u5 is the CLI surface.
parser.add_argument(
"--override-image",
dest="override_image_overrides",
action="append",
default=[],
metavar="IMAGE_ID=X,Y,W,H",
help=(
"user-content image 의 slide-absolute geometry 강제. IMAGE_ID = "
"src/image_id_stamper.py 가 stamp 한 `data-image-id` value "
"(e.g., img-1a2b3c4d5e). X,Y,W,H = percent-of-slide (0100, "
"slide-absolute) — typed client ImageOverride shape 와 일치. "
"multiple flags: --override-image img-abc=10,15,30,25 "
"--override-image img-def=50,15,40,40"
),
)
# IMP-46 u5 — auto-cache opt-in. When set, ``cache.save_proposal``
# bypasses the ``user_approved`` gate only (``visual_check_passed``
# is never bypassable). Source of truth is
@@ -5856,11 +6357,155 @@ if __name__ == "__main__":
_seen_sections_across_zones[sid] = zid
overrides_section_assignments[zid] = section_ids
# IMP-51 (#79) u5 — parse --override-image into dict[str, dict[str, float]].
# Mirrors --override-zone-geometry parsing pattern: each flag is
# IMAGE_ID=X,Y,W,H with 4 floats; multiple flags accumulate. Hard errors
# on missing `=` / wrong float count / non-numeric values / empty IMAGE_ID
# / duplicate IMAGE_ID. The on-disk schema (u1 KNOWN_AXES) and typed
# client (u3 ImageOverride) both expect percent-of-slide values in
# 0100; the CLI accepts floats without range clamping here so the
# error remains the user's mistake to read rather than a silent shift.
overrides_images: dict[str, dict[str, float]] = {}
for ov in args.override_image_overrides:
if "=" not in ov:
print(
f"[error] --override-image must be IMAGE_ID=X,Y,W,H, got: '{ov}'",
file=sys.stderr,
)
sys.exit(2)
iid, vals = ov.split("=", 1)
iid = iid.strip()
if not iid:
print(
f"[error] --override-image IMAGE_ID must be non-empty, got: '{ov}'",
file=sys.stderr,
)
sys.exit(2)
if iid in overrides_images:
print(
f"[error] --override-image duplicate IMAGE_ID '{iid}' "
f"(first assignment kept). Provide each image only once.",
file=sys.stderr,
)
sys.exit(2)
parts = vals.split(",")
if len(parts) != 4:
print(
f"[error] --override-image expects 4 floats X,Y,W,H, got: '{vals}'",
file=sys.stderr,
)
sys.exit(2)
try:
x, y, w, h = (float(p) for p in parts)
except ValueError:
print(
f"[error] --override-image floats parse fail: '{vals}'",
file=sys.stderr,
)
sys.exit(2)
overrides_images[iid] = {"x": x, "y": y, "w": w, "h": h}
# IMP-52 (#80) u2 — user_overrides.json persistence fallback.
# After argparse fully parses CLI flags, fill ONLY the axes the user
# did NOT pass on the command line. CLI payload always wins over the
# persisted file (Stage 2 lock: "CLI > file, 결손 축만 채움").
# MDX stem keys the persistence file; invalid stems / corrupt file
# degrade gracefully (warning to stderr + no override injected).
from src.user_overrides_io import (
InvalidOverrideKey,
load as _load_user_overrides,
validate_key as _validate_overrides_key,
)
_final_override_layout = args.override_layout
try:
_ov_key = _validate_overrides_key(Path(args.mdx_path).stem)
except InvalidOverrideKey as _exc:
print(
f"[user_overrides] warning: cannot derive persistence key from "
f"mdx_path {args.mdx_path!r}: {_exc}; skipping fallback.",
file=sys.stderr,
)
_ov_key = None
if _ov_key is not None:
_persisted = _load_user_overrides(_ov_key)
# layout — CLI None → fill from file (must be str).
if _final_override_layout is None:
_file_layout = _persisted.get("layout")
if isinstance(_file_layout, str) and _file_layout:
_final_override_layout = _file_layout
# frames — CLI empty → fill from file (must be dict[str, str]).
if not overrides_frames:
_file_frames = _persisted.get("frames")
if isinstance(_file_frames, dict):
overrides_frames = {
str(k): str(v)
for k, v in _file_frames.items()
if isinstance(k, str) and isinstance(v, str)
}
# zone_geometries — CLI empty → fill from file (dict[str, dict]).
if not overrides_geoms:
_file_geoms = _persisted.get("zone_geometries")
if isinstance(_file_geoms, dict):
_accepted: dict[str, dict] = {}
for _zid, _g in _file_geoms.items():
if (
isinstance(_zid, str)
and isinstance(_g, dict)
and all(k in _g for k in ("x", "y", "w", "h"))
):
try:
_accepted[_zid] = {
"x": float(_g["x"]),
"y": float(_g["y"]),
"w": float(_g["w"]),
"h": float(_g["h"]),
}
except (TypeError, ValueError):
continue
overrides_geoms = _accepted
# zone_sections — CLI empty → fill from file (dict[str, list[str]]).
if not overrides_section_assignments:
_file_sections = _persisted.get("zone_sections")
if isinstance(_file_sections, dict):
_accepted_sec: dict[str, list[str]] = {}
for _zid, _sec_list in _file_sections.items():
if isinstance(_zid, str) and isinstance(_sec_list, list):
_sids = [s for s in _sec_list if isinstance(s, str) and s]
if _sids:
_accepted_sec[_zid] = _sids
overrides_section_assignments = _accepted_sec
# image_overrides — CLI empty → fill from file (dict[str, dict]).
# IMP-51 (#79) u6 — mirrors zone_geometries validation: only accept
# mappings of {image_id: {x,y,w,h}} with float-coercible values.
if not overrides_images:
_file_images = _persisted.get("image_overrides")
if isinstance(_file_images, dict):
_accepted_img: dict[str, dict] = {}
for _iid, _g in _file_images.items():
if (
isinstance(_iid, str)
and _iid
and isinstance(_g, dict)
and all(k in _g for k in ("x", "y", "w", "h"))
):
try:
_accepted_img[_iid] = {
"x": float(_g["x"]),
"y": float(_g["y"]),
"w": float(_g["w"]),
"h": float(_g["h"]),
}
except (TypeError, ValueError):
continue
overrides_images = _accepted_img
run_phase_z2_mvp1(
args.mdx_path,
args.run_id,
override_layout=args.override_layout,
override_layout=_final_override_layout,
override_frames=overrides_frames or None,
override_zone_geometries=overrides_geoms or None,
override_section_assignments=overrides_section_assignments or None,
override_image_overrides=overrides_images or None,
)
+123 -2
View File
@@ -56,12 +56,24 @@ ACTION_RATIONALE: dict[str, str] = {
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
}
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18)
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18;
# IMP-35 u3 popup-stub 2026-05-23)
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
"layout_adjust": "MISSING",
"details_popup_escalation": "MISSING", # CLAUDE.md 의 <details> 원칙은 있음, runtime 미구현
# IMP-35 (#64) u3 — MISSING → IMPLEMENTED on the primary router surface.
# `plan_details_popup_escalation` (below) provides the deterministic stub
# that downstream units consume: u4 binds the AI split-decision contract
# in `src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP
# gate executor in `src/phase_z2_pipeline.py`. Router-level mapping is
# decoupled from orchestrator wiring (same precedent as the IMP-12 u7
# cascade actions below): IMPLEMENTED here reflects deterministic
# *surface availability* (importable stub), not whether a given pipeline
# run has invoked it. The failure_router companion surface
# (NEXT_ACTION_IMPLEMENTATION_STATUS in phase_z2_failure_router.py) keeps
# `details_popup_escalation` as MISSING until u5 lands the pipeline gate.
"details_popup_escalation": "IMPLEMENTED",
"frame_reselect": "PARTIAL", # IMP-05 pre-render rank-2/3 fallback implemented; post-render rerender trace-only
"adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch
"abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default
@@ -185,3 +197,112 @@ def route_fit_classification(fit_classification: dict) -> dict:
"MISSING 이면 그 action 은 실행 X 이고 기존 abort/status 흐름 (sys.exit(1)) 으로 종료."
),
}
# ─── IMP-35 (#64) u3 — details_popup_escalation deterministic stub ─
# Surface contract for the cascade-terminal popup escalation. This stub
# does NOT mutate HTML / CSS / MDX content; it emits the canonical plan
# marker that the Step 17 POPUP gate (u5) and the AI split-decision hook
# (u4) consume. Keeping the executor surface here (next to the primary
# ACTION_BY_CATEGORY mapping) lets the router report IMPLEMENTED for
# `details_popup_escalation` while u4/u5 are still landing.
#
# Contract (locked in Stage 2 IMPLEMENTATION_UNITS u3):
# - Inputs: classification dict (a single fit_classifier output row).
# The category MUST be one of the two ACTION_BY_CATEGORY
# rows that map onto `details_popup_escalation` —
# `structural_major_overflow` or `tabular_overflow`.
# Other categories raise the stub's defensive guard (so
# callers do not silently popup-escalate the wrong category).
# - Output: popup_escalation_plan dict with `feasible=True`,
# `stub=True`, the source category, the canonical
# ACTION_RATIONALE entry, and `needs_split_decision=True`
# to flag that u4 (AI hook) must run before u5 renders.
# - No side effects (no AI call, no MDX read, no HTML mutation).
#
# Guardrails honored:
# - feedback_ai_isolation_contract: stub is deterministic-with-data;
# no AI call inside the router surface.
# - Phase Z spacing 방향: stub does not shrink common margins; it
# expands capacity by routing content to popup downstream.
# - 자세히보기 원칙 (CLAUDE.md): plan carries the marker that u5 uses
# to put MDX 원문 in popup body and a summary/subset in preview.
# - 1 turn = 1 unit: this is router-surface only. u4/u5 own the
# downstream wiring on their respective files.
# Categories that legitimately escalate onto details_popup_escalation
# per the ACTION_BY_CATEGORY mapping above. Kept as a derived constant
# so the router cannot drift away from the single source of truth.
POPUP_ESCALATION_CATEGORIES: frozenset[str] = frozenset(
category
for category, action in ACTION_BY_CATEGORY.items()
if action == "details_popup_escalation"
)
def plan_details_popup_escalation(classification: dict) -> dict:
"""Cascade-terminal popup escalation plan stub (IMP-35 u3).
Returns a deterministic popup_escalation_plan marker. The actual
content split (popup_html / preview_text / has_popup payload) is
composed downstream: u4 binds the AI split-decision contract on
`src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP
gate executor on `src/phase_z2_pipeline.py`.
Args:
classification: a single fit_classifier classification dict.
Must contain a `category` key. Only the categories that
map onto `details_popup_escalation` in ACTION_BY_CATEGORY
(currently `structural_major_overflow` and `tabular_overflow`)
are accepted; any other category produces an
`feasible=False` plan with `failure_reason` so the caller
never silently popup-escalates the wrong overflow shape.
Returns:
popup_escalation_plan dict with at least:
action : "details_popup_escalation"
feasible : True/False (True for accepted categories)
stub : True (marks u3 surface; u4/u5 fill in)
category : echoed from input
rationale : canonical ACTION_RATIONALE entry
needs_split_decision : True (u4 AI hook must run before u5 renders)
mapping_source : "IMP-35 u3 plan_details_popup_escalation stub"
note : downstream-wiring pointer text
"""
category = (classification or {}).get("category")
base = {
"action": "details_popup_escalation",
"stub": True,
"category": category,
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
}
if category not in POPUP_ESCALATION_CATEGORIES:
return {
**base,
"feasible": False,
"needs_split_decision": False,
"rationale": "",
"failure_reason": (
f"category {category!r} does not map onto details_popup_escalation "
f"in ACTION_BY_CATEGORY. Accepted categories: "
f"{sorted(POPUP_ESCALATION_CATEGORIES)}. Defensive guard — "
f"router must not silently popup-escalate the wrong overflow shape."
),
"note": (
"u3 stub — caller passed a category that should not popup-escalate. "
"Honour the ACTION_BY_CATEGORY mapping at the router entry point."
),
}
return {
**base,
"feasible": True,
"needs_split_decision": True,
"rationale": ACTION_RATIONALE.get(category, ""),
"note": (
"u3 stub — actual content split planning lands in u4 "
"(AI split-decision contract on src/phase_z2_ai_fallback/step17.py) "
"and u5 (Step 17 POPUP gate executor on src/phase_z2_pipeline.py). "
"popup body = MDX 원문, preview = summary/subset (자세히보기 원칙)."
),
}
+176
View File
@@ -0,0 +1,176 @@
"""IMP-52 (#80) u1 — user_overrides.json persistence layer (backend IO).
Persists the CLI-wired override axes per MDX so a subsequent render
auto-restores user choices without re-clicking. Source of truth = MDX-keyed
file (stem of the MDX path), NOT ``data/runs/<run_id>/`` which mints a fresh
run_id per ``/api/run`` invocation.
Schema (5 axes; stable order; IMP-51 #79 u1 added ``image_overrides``):
{
"layout": <string|null>,
"zone_geometries": {<zone_id>: {"x": float, "y": float, "w": float, "h": float}},
"zone_sections": {<zone_id>: [<section_id>, ...]},
"frames": {<unit_id>: <template_id>},
"image_overrides": {<image_id>: {"x": float, "y": float, "w": float, "h": float}}
}
``image_id`` is the stable identifier emitted by the user-content image
stamper (IMP-51 u4) and matched via the selector
``.slide img[data-image-role="user-content"]``. Coordinates are
percent-of-slide (zone-agnostic, slide-absolute) to match the SlideCanvas
edit-mode handle conventions in IMP-51 u8~u11.
``unit_id`` is the convention already used by ``--override-frame`` :
``"+".join(source_section_ids)`` (e.g., ``"03-1"`` or ``"03-1+03-2"``).
Behavior :
- ``load(key)`` — file missing or corrupt → ``{}`` (warning to stderr on corrupt).
- ``save(key, partial)`` — merges only the supplied axes onto the existing
file, preserving (a) unknown top-level keys (foreign-key preserve) and
(b) axes not present in the partial payload. Atomic write via tmp+rename.
- ``override_path(key, root=None)`` — resolves the persistence path under
``data/user_overrides/<key>.json``.
Guardrails (refs : ``user_overrides_io`` Stage 2 lock) :
- Deterministic code, no AI fallback.
- ``key`` validation rejects path traversal / separators / dot-prefix.
- ``save`` is a deep-shallow merge — per-axis dict mutation does not delete
prior keys unless caller passes ``None`` for that axis (explicit clear).
"""
from __future__ import annotations
import json
import os
import re
import sys
import tempfile
from pathlib import Path
from typing import Any, Optional
# Persistence root — MDX-keyed, decoupled from data/runs/<run_id>/.
# Resolved at call time so tests can monkeypatch via ``root=`` parameter.
_PKG_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
# The five in-scope axes (IMP-51 #79 u1 added ``image_overrides``). Any
# other top-level key in the file is preserved but ignored by callers —
# keeps the file forward-compatible with future axes (e.g., zone_sizes)
# without a schema bump here.
KNOWN_AXES: tuple[str, ...] = (
"layout",
"zone_geometries",
"zone_sections",
"frames",
"image_overrides",
)
# Key validation — MDX stem must be safe for filesystem use. Allow
# alphanumerics, underscore, hyphen, and dot in the middle (sample stems
# are e.g. ``01``, ``03``, ``03__DX...``). Reject leading dot, path
# separators, and traversal.
_KEY_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
class InvalidOverrideKey(ValueError):
"""Raised when ``key`` is not a safe MDX stem."""
def validate_key(key: str) -> str:
"""Validate that ``key`` is a safe MDX stem; return it unchanged.
Rejects empty strings, path separators (``/`` ``\\``), traversal
(``..``), and leading dot. Callers should pass ``Path(mdx_path).stem``.
"""
if not isinstance(key, str) or not key:
raise InvalidOverrideKey(f"key must be a non-empty string, got: {key!r}")
if not _KEY_RE.match(key):
raise InvalidOverrideKey(
f"key must match {_KEY_RE.pattern!r} (alphanumerics, '_', '-', '.'; "
f"no leading dot, no separators); got: {key!r}"
)
if ".." in key:
raise InvalidOverrideKey(f"key must not contain '..'; got: {key!r}")
return key
def override_path(key: str, root: Optional[Path] = None) -> Path:
"""Resolve the on-disk path for ``key``'s override file."""
validate_key(key)
base = Path(root) if root is not None else DEFAULT_OVERRIDES_ROOT
return base / f"{key}.json"
def load(key: str, root: Optional[Path] = None) -> dict[str, Any]:
"""Load persisted overrides for ``key``.
Missing file → ``{}``. Corrupt JSON → warning to stderr + ``{}``.
Returns the raw mapping (including any foreign keys); callers should
pick the KNOWN_AXES they care about.
"""
path = override_path(key, root=root)
if not path.exists():
return {}
try:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
print(
f"[user_overrides_io] warning: failed to read {path} ({exc}); "
f"treating as empty.",
file=sys.stderr,
)
return {}
if not isinstance(data, dict):
print(
f"[user_overrides_io] warning: {path} is not a JSON object "
f"(got {type(data).__name__}); treating as empty.",
file=sys.stderr,
)
return {}
return data
def save(key: str, partial: dict[str, Any], root: Optional[Path] = None) -> Path:
"""Merge ``partial`` onto the persisted overrides for ``key`` and write atomically.
Merge semantics :
- Only keys present in ``partial`` are mutated. Other axes (including
foreign keys outside KNOWN_AXES) are preserved verbatim.
- For each axis present in ``partial``, the new value REPLACES the prior
value (no per-zone deep-merge). Callers that want to add a single
zone must read → mutate → save with the full updated axis dict.
- Pass ``None`` for an axis to clear it (remove the key from the file).
"""
if not isinstance(partial, dict):
raise TypeError(
f"partial must be a dict, got {type(partial).__name__}: {partial!r}"
)
path = override_path(key, root=root)
path.parent.mkdir(parents=True, exist_ok=True)
current = load(key, root=root)
for axis_key, axis_value in partial.items():
if axis_value is None:
current.pop(axis_key, None)
else:
current[axis_key] = axis_value
_atomic_write_json(path, current)
return path
def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
"""Write ``data`` to ``path`` atomically via tmp file + os.replace."""
fd, tmp_name = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
os.replace(tmp_name, path)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
@@ -23,6 +23,10 @@ three_parallel_requirements:
frame_id: 1171281190
family: three_parallel
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
rotation_eligible: true # P1: aspect-ratio container-query rotation
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
source_shape: top_bullets
cardinality:
strict: 3
@@ -79,6 +83,10 @@ process_product_two_way:
frame_id: 1171281210
family: two_column_h3
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
rotation_eligible: false # P1: 2-col compare table — rotation 부적합 opt-out
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
source_shape: h3_subsections
cardinality:
strict: 2 # F29 frame = 2 visual columns. ≠2 → fallback.
@@ -122,7 +130,7 @@ process_product_two_way:
body_parser: column_with_transform # 첫 top-bullet AS-IS/TO-BE 표 인식
- title_to: banner_right
body_to: product
body_parser: column_plain # 모든 section = 일반 text_lines
body_parser: column_with_transform # IMP-36 (Gitea #65 u2) P3 parity — process column 과 동일 transform 인식 (좌/우 대칭)
bim_issues_quadrant_four:
@@ -130,6 +138,10 @@ bim_issues_quadrant_four:
frame_id: 1171281193
family: bim_issues_quadrant
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
rotation_eligible: false # P1: 4-quadrant 고정 grid — rotation 부적합
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
source_shape: top_bullets
# F16 정책 = pad_to=4 + truncate>4 (legacy 와 동일). cardinality strict 화는 본 transition 범위 외.
# 향후 normal path 안정 후 strict 적용 + 위반 시 fallback path (FitError) 검토.
@@ -193,6 +205,10 @@ three_persona_benefits:
frame_id: 1171281191
family: cards
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
rotation_eligible: true # P1: aspect-ratio container-query rotation
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
source_shape: top_bullets
cardinality:
strict: 3 # 3 persona = strict.
@@ -258,6 +274,10 @@ construction_goals_three_circle_intersection:
frame_id: 1171281189
family: diagram
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
rotation_eligible: false # P1: 3-circle SVG diagram — rotation 부적합 (좌표 의존)
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
source_shape: top_bullets
cardinality:
strict: 3 # 3 메인 원 — strict.
@@ -328,6 +348,10 @@ construction_bim_three_usage:
frame_id: 1171281182
family: cards
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
rotation_eligible: false # P1: 3 stacked rows — rotation 부적합 (수평 row 구조)
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
source_shape: top_bullets
cardinality:
strict: 3
@@ -397,6 +421,10 @@ bim_dx_comparison_table:
frame_id: 1171281195
family: table
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
rotation_eligible: false # P1: 2-col compare table — rotation 부적합 opt-out (issue body 명시)
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
source_shape: top_bullets
# NOTE (Codex round 43 §F1-c) : top-level `cardinality.strict: 2` = *column 수*
# (col_a / col_b). data row 수 는 별 — `sub_zones.rows.cardinality` 의 `{min:1, max:12}`.
@@ -462,6 +490,10 @@ dx_sw_necessity_three_perspectives:
frame_id: 1171281198
family: cards
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
rotation_eligible: true # P1: aspect-ratio container-query rotation
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
source_shape: top_bullets
cardinality:
strict: 3 # 3 perspective columns
@@ -528,6 +560,10 @@ info_management_what_how_when:
frame_id: 1171281179
family: cards
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
rotation_eligible: true # P1: aspect-ratio container-query rotation
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
source_shape: top_bullets
cardinality:
strict: 3 # 3 sections (What / How / When)
@@ -587,6 +623,10 @@ sw_reality_three_emphasis:
frame_id: 1171281209
family: cards
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
rotation_eligible: false # P1: 미적용 (Stage 2 selection — future eligibility TBD)
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
source_shape: top_bullets
cardinality:
strict: 3
@@ -644,6 +684,10 @@ bim_current_problems_paired:
frame_id: 1171281194
family: cards
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
rotation_eligible: false # P1: 4x2 paired rows — rotation 부적합 (2-axis row×side 구조)
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
source_shape: top_bullets # mapper split_source allow-list 정합 (Codex round 60)
layout_variant: paired_rows_4x2_alternating_pills # runtime projection model
cardinality:
@@ -735,6 +779,10 @@ app_sw_package_vs_solution:
frame_id: 1171281203
family: table
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
rotation_eligible: false # P1: 2-col compare table — rotation 부적합 opt-out
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit (Stage 1 canonical source)
source_shape: h3_subsections # F29 와 동일 — 2 h3 subsection = 2 column.
cardinality:
strict: 2 # 2 column (Package / Solution) — NOT row count.
@@ -783,6 +831,10 @@ pre_construction_model_info_stacked:
frame_id: 1171281180
family: list
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
rotation_eligible: false # P1: 5-color cycle pill list — rotation 부적합
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
source_shape: top_bullets
cardinality:
min: 4 # analysis.md min 4 / Figma 5-color cycle design floor.
@@ -18,11 +18,18 @@ builder/parser 0.
- figma_to_html (1171281198) = source/evidence — 386-line index.html + assets/.
- Phase Z = runtime — 본 commit adds catalog + partial + smoke fixture.
PROMOTED — CSS :
- 3 column header bg : dark green (`#296B55` family, Figma green theme)
- header text white bold + green accent
- title gradient (#000 → #883700, F13/F14/F12/F11/F18 zone-title family)
- card border + bullet markers (green family)
PROMOTED — CSS (verbatim from figma_to_html_agent/blocks/1171281198/index.html) :
- 3 column header bg : two-stop vertical adaptation of upstream horizontal
banner gradient end-stops — start `rgb(15, 50, 30)` (upstream :54, stop 0%),
end `rgb(60, 52, 34)` (upstream :64, stop 100%). Adaptation surface =
direction (90deg → 180deg) + stop count (11 → 2); colors verbatim.
- header text white bold + green accent (white #fff verbatim, see upstream
:202 `color: #ffffff`)
- title gradient (#000 → #883700, F13/F14/F12/F11/F18 zone-title family;
shared zone-title token, not from this frame)
- card border + bullet markers : `#1d4d3e` (upstream :208 `.card-title-1`
`-webkit-text-stroke: 1.5px #1d4d3e`). Replaces earlier eyeballed
`#296B55` approximation (IMP-49 #78 u1).
NOT PROMOTED (P1 case-by-case, compact zone fit) :
- 상단 dark green banner (Figma 의 큰 visual 영역, MDX 의 *title 만* 핵심)
@@ -58,6 +65,12 @@ slots :
gap: 6px;
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
/* IMP-36 (Gitea #65 u6) P1 root — enable container queries on .f20b.
container-type: size unlocks cqh/cqi/cqw + aspect-ratio matching against
this element. container-name: f20b-root namespaces the @container rule
below (partial-fidelity lock per IMP-49 #78 — no cross-frame borrowing). */
container-type: size;
container-name: f20b-root;
}
.f20b__title {
font-size: var(--font-zone-title);
@@ -80,7 +93,7 @@ slots :
}
.f20b__col {
display: flex; flex-direction: column;
border: 2px solid #296B55; /* PROMOTED — green family from Figma */
border: 2px solid #1d4d3e; /* PROMOTED — verbatim from upstream :208 (.card-title-1 -webkit-text-stroke) */
border-radius: 8px;
overflow: hidden;
background: #fff;
@@ -89,7 +102,7 @@ slots :
/* header bar (top of each card, dark green per Figma) */
.f20b__header {
background: linear-gradient(180deg, #296B55 0%, #123328 100%); /* PROMOTED — Figma green theme */
background: linear-gradient(180deg, rgb(15, 50, 30) 0%, rgb(60, 52, 34) 100%); /* PROMOTED — verbatim end-stops from upstream :54 (0%) and :64 (100%); 11-stop horizontal banner adapted to 2-stop vertical card header */
color: #fff;
font-weight: 700;
font-size: var(--font-sub-title);
@@ -121,18 +134,47 @@ slots :
content: "\2713"; /* ✓ check mark — green theme */
position: absolute;
left: 0; top: 0;
color: #296B55; /* PROMOTED — green family */
color: #1d4d3e; /* PROMOTED — verbatim from upstream :208 (.card-title-1 -webkit-text-stroke) */
font-weight: 700;
}
/* IMP-36 (Gitea #65 u6) P2 body fit — line-height clamp scales with the
per-column bullet count via the inline `--max-body-lines` Jinja style on
each `.f20b__body`. 60cqh ≈ 3-col card body share of `.f20b` root height
(zone-title ~15cqh + card header ~10cqh + remaining ~75cqh, split 60cqh
for text lines + reserve for padding/gap). font-size unchanged
(guardrail #6 — Stage 2 spec). Fallback 3 = file-header default
"body 3-5 bullets per column". */
.f20b__body .text-line {
line-height: clamp(1.15em, calc(60cqh / var(--max-body-lines, 3)), 1.6em);
}
/* IMP-36 (Gitea #65 u6) P1 rotation rule — when the surrounding zone is
narrow (aspect-ratio < 1.5), collapse the 3-column grid to a single
column. The threshold matches the IMP-36 Stage 2 canonical (vertical-2 /
세로형). Card header / body / bullet styles remain unchanged. */
@container f20b-root (aspect-ratio < 1.5) {
.f20b__cols { grid-template-columns: 1fr; }
}
</style>
<!-- IMP-49 #78 u2 — namespace scope note :
`.f20b__*` is an AUTHORING-ORDINAL namespace (ordinal "20b" in this catalog's
authoring sequence), NOT the Figma frame_id 1171281198. The structural link
to the source frame is the `data-frame-id="1171281198"` attribute on the
root <div> below. Cross-frame `.fNb__` class reuse is forbidden — class names
MUST stay within their owning partial (see [[feedback_partial_figma_audit]]).
Selector names and catalog references (frame_contracts.yaml :492,:497,:502)
are intentionally unchanged in this unit. -->
<div class="f20b" data-frame-id="1171281198" data-template-id="dx_sw_necessity_three_perspectives">
<div class="f20b__title">{{ slot_payload.title }}</div>
<div class="f20b__cols">
{# 3 columns — quadrant_flat_slots produces perspective_N_label / perspective_N_body for N=1..3 #}
<div class="f20b__col">
<div class="f20b__header">{{ slot_payload.perspective_1_label | safe }}</div>
<div class="f20b__body">
{# IMP-36 (Gitea #65 u6) P2 — inline `--max-body-lines` per column; code composes (Phase Z guardrail #7), defensive fallback to 3 matches the CSS default. #}
<div class="f20b__body" style="--max-body-lines: {{ (slot_payload.perspective_1_body | length) if slot_payload.perspective_1_body else 3 }};">
{% if slot_payload.perspective_1_body %}
{% for line in slot_payload.perspective_1_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
@@ -140,7 +182,7 @@ slots :
</div>
<div class="f20b__col">
<div class="f20b__header">{{ slot_payload.perspective_2_label | safe }}</div>
<div class="f20b__body">
<div class="f20b__body" style="--max-body-lines: {{ (slot_payload.perspective_2_body | length) if slot_payload.perspective_2_body else 3 }};">
{% if slot_payload.perspective_2_body %}
{% for line in slot_payload.perspective_2_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
@@ -148,7 +190,7 @@ slots :
</div>
<div class="f20b__col">
<div class="f20b__header">{{ slot_payload.perspective_3_label | safe }}</div>
<div class="f20b__body">
<div class="f20b__body" style="--max-body-lines: {{ (slot_payload.perspective_3_body | length) if slot_payload.perspective_3_body else 3 }};">
{% if slot_payload.perspective_3_body %}
{% for line in slot_payload.perspective_3_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
@@ -48,6 +48,12 @@ slots : title, section_1/2/3_label, section_1/2/3_body
gap: 6px;
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
/* IMP-36 (Gitea #65 u7) P1 — container-type: size unlocks cqh/cqi/cqw +
aspect-ratio measurement on .f8b. container-name: f8b-root namespaces
the rotation rule below (IMP-49 partial-fidelity lock — no cross-frame
.fNb__ class borrowing). */
container-type: size;
container-name: f8b-root;
}
.f8b__title {
font-size: var(--font-zone-title);
@@ -110,6 +116,16 @@ slots : title, section_1/2/3_label, section_1/2/3_body
position: relative;
padding-left: 14px;
}
/* IMP-36 (Gitea #65 u7) P2 body fit — line-height cqh/clamp against the
bullet count rendered per column. font-size 미변경 (사용자 룰 + Stage 2
guardrail #6). 60cqh = approx body region share of .f8b after title +
section header (title ≈ 15cqh + per-col header ≈ 12cqh → body ≈ 60cqh).
fallback = 4 (file header L42 watch threshold "body 5+ bullets per
column" → typical < 5). Additive cascade override; does not mutate the
pre-existing .f8b__body .text-line block above. */
.f8b__body .text-line {
line-height: clamp(1.15em, calc(60cqh / var(--max-body-lines, 4)), 1.6em);
}
.f8b__body .text-line--bullet::before {
content: "•";
position: absolute;
@@ -119,6 +135,14 @@ slots : title, section_1/2/3_label, section_1/2/3_body
.f8b__col:nth-child(1) .f8b__body .text-line--bullet::before { color: #2563eb; }
.f8b__col:nth-child(2) .f8b__body .text-line--bullet::before { color: #ea580c; }
.f8b__col:nth-child(3) .f8b__body .text-line--bullet::before { color: #16a34a; }
/* IMP-36 (Gitea #65 u7) P1 rotation rule — when zone aspect-ratio narrows
below 1.5 (vertical-2 narrow / 임의 세로형 geometry), flip the 3-column
grid to single column. Card header / body / bullet styles unchanged —
only grid-template-columns flips from 1fr 1fr 1fr to 1fr. */
@container f8b-root (aspect-ratio < 1.5) {
.f8b__cols { grid-template-columns: 1fr; }
}
</style>
<div class="f8b" data-frame-id="1171281179" data-template-id="info_management_what_how_when">
@@ -126,7 +150,7 @@ slots : title, section_1/2/3_label, section_1/2/3_body
<div class="f8b__cols">
<div class="f8b__col">
<div class="f8b__header">{{ slot_payload.section_1_label | safe }}</div>
<div class="f8b__body">
<div class="f8b__body" style="--max-body-lines: {{ (slot_payload.section_1_body | length) if slot_payload.section_1_body else 4 }};">
{% if slot_payload.section_1_body %}
{% for line in slot_payload.section_1_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
@@ -134,7 +158,7 @@ slots : title, section_1/2/3_label, section_1/2/3_body
</div>
<div class="f8b__col">
<div class="f8b__header">{{ slot_payload.section_2_label | safe }}</div>
<div class="f8b__body">
<div class="f8b__body" style="--max-body-lines: {{ (slot_payload.section_2_body | length) if slot_payload.section_2_body else 4 }};">
{% if slot_payload.section_2_body %}
{% for line in slot_payload.section_2_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
@@ -142,7 +166,7 @@ slots : title, section_1/2/3_label, section_1/2/3_body
</div>
<div class="f8b__col">
<div class="f8b__header">{{ slot_payload.section_3_label | safe }}</div>
<div class="f8b__body">
<div class="f8b__body" style="--max-body-lines: {{ (slot_payload.section_3_body | length) if slot_payload.section_3_body else 4 }};">
{% if slot_payload.section_3_body %}
{% for line in slot_payload.section_3_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
@@ -34,6 +34,12 @@ slots: title, pillars[].{label, color_class, sections[].{heading, bullets[]}}
gap: 4px;
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
/* IMP-36 (Gitea #65 u4) P1 — partial-side container query root.
container-type: size 로 aspect-ratio 측정 가능 (cqh / cqi / cqw 도
동일 root 기준). container-name: f13b-root 는 frame_contracts.yaml
rotation_eligible: true 와 짝. */
container-type: size;
container-name: f13b-root;
}
.f13b__title {
font-size: var(--font-zone-title);
@@ -126,6 +132,22 @@ slots: title, pillars[].{label, color_class, sections[].{heading, bullets[]}}
}
/* desc 안 .text-line 색 override */
.f13b__desc .text-line { color: #3E3523; }
/* IMP-36 (Gitea #65 u4) P2 — body fit via cqh + clamp + --max-body-lines.
section 의 text_lines 개수가 늘면 line-height 가 비례로 축소. font-size
미변경 (사용자 룰). --max-body-lines fallback = 4 (section 평균 줄 수).
20cqh = 한 section 이 차지하는 .f13b 컨테이너 비율 근사치 (3 section /
col, body 영역 ≈ 80cqh → 25cqh/section 중 line 영역 ≈ 20cqh). */
.f13b__desc {
line-height: clamp(1.2em, calc(20cqh / var(--max-body-lines, 4)), 1.6em);
}
/* IMP-36 (Gitea #65 u4) P1 — aspect-ratio < 1.5 rotation rule. zone 의
가로:세로 비가 1.5 미만으로 좁아지면 (vertical-2 narrow / 또는 임의
세로형 geometry) 3-col grid 가 1-col stack 으로 회전. */
@container f13b-root (aspect-ratio < 1.5) {
.f13b__cols { grid-template-columns: 1fr; }
}
</style>
<div class="f13b" data-frame-id="1171281190" data-template-id="three_parallel_requirements">
@@ -144,7 +166,7 @@ slots: title, pillars[].{label, color_class, sections[].{heading, bullets[]}}
<div class="f13b__section">
<div class="f13b__heading">{{ section.heading | safe }}</div>
{% if section.text_lines %}
<div class="f13b__desc">
<div class="f13b__desc" style="--max-body-lines: {{ section.text_lines | length }};">
{% for line in section.text_lines %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
</div>
{% endif %}
@@ -70,6 +70,13 @@ Asset path runtime resolution :
gap: 6px;
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
/* IMP-36 (Gitea #65 u5) P1 — partial-side container query root.
container-type: size 로 aspect-ratio 측정 가능 (cqh / cqi / cqw 도
동일 root 기준). container-name: f14b-root 는 frame_contracts.yaml
rotation_eligible: true 와 짝. Circle badge (.f14b__badge aspect-ratio
1/1) 는 별도 element — 본 root 의 aspect-ratio 측정 대상 아님. */
container-type: size;
container-name: f14b-root;
}
.f14b__title {
font-size: var(--font-zone-title);
@@ -176,6 +183,22 @@ Asset path runtime resolution :
position: relative;
padding-left: 14px;
}
/* IMP-36 (Gitea #65 u5) P2 — body fit via cqh + clamp + --max-body-lines.
persona.body 의 bullet 개수가 늘면 line-height 가 비례로 축소. font-size
미변경 (사용자 룰). --max-body-lines fallback = 7 (Figma 원본 frame 평균
bullet 수, file header L8 참조). 60cqh = .f14b__body 영역 비율 근사치
(title ≈ 15cqh + badge ≈ 18cqh + photo ≈ 7cqh → body ≈ 60cqh). 본 clamp
은 .text-line 의 var(--lh-body) 를 override (cascade 우선순위). */
.f14b__body .text-line {
line-height: clamp(1.15em, calc(60cqh / var(--max-body-lines, 7)), 1.6em);
}
/* IMP-36 (Gitea #65 u5) P1 — aspect-ratio < 1.5 rotation rule. zone 의
가로:세로 비가 1.5 미만으로 좁아지면 3-col grid 가 1-col stack 으로
회전. Circle badge (.f14b__badge aspect-ratio 1/1) 는 col 내부 element
이므로 회전 후에도 원형 유지. */
@container f14b-root (aspect-ratio < 1.5) {
.f14b__cols { grid-template-columns: 1fr; }
}
.f14b__body .text-line--bullet::before {
content: "\2713";
position: absolute;
@@ -226,7 +249,7 @@ Asset path runtime resolution :
</div>
{# body — bullets with CSS check marker #}
<div class="f14b__body">
<div class="f14b__body" style="--max-body-lines: {{ (persona.body | length) if persona.body else 7 }};">
{% if persona.body %}
{% for line in persona.body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
@@ -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
+76 -1
View File
@@ -290,6 +290,71 @@
font-family: monospace;
z-index: 3;
}
/* ── IMP-35 u8 : popup details/summary (Step 17 POPUP gate escalation) ──
When the Step 17 POPUP gate escalates a unit (zone.has_popup=True),
slide_base renders a JS-free <details>/<summary> wrapper in the zone.
The body of the frame stays as zone.partial_html (the FIT-version of
content); the popup body holds the FULL original raw_content (MDX 원문
무손실 보존 — 오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110).
Placement (default top-right) is read from
zone.popup_binding.detail_trigger.placement
(templates/phase_z2/regions/display_strategies.yaml). HTML-native
<details> per CLAUDE.md 자세히보기 contract — no JavaScript. */
.zone__popup-details {
position: absolute;
z-index: 5;
font-family: 'Pretendard', sans-serif;
}
.zone__popup-details--top-right {
top: 4px;
right: 4px;
}
.zone__popup-details--top-left {
top: 4px;
left: 4px;
}
.zone__popup-details--bottom-right {
bottom: 4px;
right: 4px;
}
.zone__popup-details--bottom-left {
bottom: 4px;
left: 4px;
}
.zone__popup-summary {
list-style: none;
cursor: pointer;
padding: 2px 6px;
background: rgba(30, 41, 59, 0.85);
color: #fff;
border-radius: 2px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1.2;
user-select: none;
}
.zone__popup-summary::-webkit-details-marker { display: none; }
.zone__popup-summary::marker { content: ""; }
.zone__popup-body {
position: absolute;
top: 22px;
right: 0;
width: 360px;
max-height: 280px;
overflow: auto;
padding: 8px 10px;
background: #fff;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 3px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
white-space: pre-wrap;
word-break: keep-all;
font-size: 10px;
line-height: 1.5;
color: #1e293b;
}
</style>
</head>
<body>
@@ -301,9 +366,19 @@
<div class="slide-body">
<div class="layout-{{ layout_preset }}">
{% for zone in zones %}
<div class="zone{% if zone.provisional %} zone--provisional{% endif %}" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}"{% if zone.provisional %} data-provisional="1"{% endif %} style="grid-area: {{ zone.position }};">
<div class="zone{% if zone.provisional %} zone--provisional{% endif %}" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}"{% if zone.provisional %} data-provisional="1"{% endif %}{% if zone.has_popup %} data-has-popup="1"{% endif %} style="grid-area: {{ zone.position }};">
{% if zone.provisional %}<span class="zone__needs-adaptation-badge" aria-label="needs user or AI adaptation">needs adaptation</span>{% endif %}
{{ zone.partial_html | safe }}
{% if zone.has_popup %}
{% set _popup_trigger = (zone.popup_binding.detail_trigger if zone.popup_binding else None) or {} %}
{% set _popup_placement = _popup_trigger.placement or 'top-right' %}
{% set _popup_label = _popup_trigger.label or 'details' %}
{% set _popup_strategy = (zone.popup_binding.display_strategy if zone.popup_binding else 'inline_preview_with_details') %}
<details class="zone__popup-details zone__popup-details--{{ _popup_placement }}" data-display-strategy="{{ _popup_strategy }}" data-popup-placement="{{ _popup_placement }}">
<summary class="zone__popup-summary">{{ _popup_label }}</summary>
<div class="zone__popup-body">{{ zone.popup_html }}</div>
</details>
{% endif %}
</div>
{% endfor %}
</div>
+112
View File
@@ -0,0 +1,112 @@
"""IMP-#85 u7 — pytest env isolation for src.config defaults.
This conftest.py runs BEFORE any test module is imported by pytest.
Setting ``os.environ["AI_FALLBACK_*"]`` here overrides values that the
live ``.env`` file would otherwise inject through ``pydantic-settings``
(priority: init args > os.environ > env_file). The ``src.config``
module-level ``settings = Settings()`` singleton is therefore built
against the test-clean environment when src.config is first imported
during test collection.
Scope (per Stage 2 plan u7):
* Restore the default-OFF contract for ``ai_fallback_enabled`` so
``tests/test_phase_z2_ai_fallback_config.py`` and
``tests/test_imp47b_step12_ai_wiring.py`` (which lock the
flag-off short-circuit) match the source-of-truth default in
``src/config.py``.
* Restore the default-OFF contract for ``ai_fallback_auto_cache``.
Out of scope:
* Touching ``ANTHROPIC_API_KEY`` / ``KEI_API_URL`` / ``LOG_LEVEL``.
* Resetting the ``src.config.settings`` singleton mid-session.
Tests that need to flip ``settings.ai_fallback_enabled`` at
runtime mutate the singleton directly (mirrors the production
``--auto-cache`` CLI path in ``src/phase_z2_pipeline.py``).
IMP-35 baseline-red invariance carve-out
========================================
The IMP-35 baseline-red invariance gate at
``tests/phase_z2/test_imp35_baseline_red_invariance.py`` spawns a child
pytest subprocess that targets ONLY the two baseline-area files:
tests/test_imp47b_step12_ai_wiring.py
tests/test_phase_z2_ai_fallback_config.py
That gate's binding contract (Stage 2 u11 lock) is that those four
registered known-red tests STAY RED until a follow-up issue
deregisters them. If this conftest blindly forces
``AI_FALLBACK_ENABLED=false`` in the gate's subprocess, the
``test_ai_fallback_master_flag_default_off`` registered red flips
green and the invariance gate trips — a real cross-issue contract
conflict (see Codex #8 Stage 3 verification of IMP-#85 u7).
The carve-out below detects that exact subprocess signature
(positional ``.py`` targets are entirely baseline-area files) and
skips env isolation, leaving the gate's child process in its native
``.env``-loaded state. Every other pytest invocation — full-suite
``pytest -q tests``, the IMP-#85 smoke targets, single-file dev runs
on non-baseline files — still gets the default-OFF isolation.
Per ``feedback_demo_env_toggle_policy``: demo activation belongs in
``.env`` only. The override below is test-scoped (lives under
``tests/``) and never propagates into ``src/`` or ``vite.config``.
"""
from __future__ import annotations
import os
import sys
# File suffixes (basenames) of the IMP-35 baseline-red area files.
# The IMP-35 gate spawns its subprocess with these as the sole positional
# pytest targets. Suffix matching is used so the detection is robust
# across Windows/POSIX path separators and absolute/relative cwd.
_IMP35_BASELINE_AREA_FILE_SUFFIXES: tuple[str, ...] = (
"test_imp47b_step12_ai_wiring.py",
"test_phase_z2_ai_fallback_config.py",
)
def _is_imp35_baseline_subprocess() -> bool:
"""True iff the current pytest argv targets ONLY IMP-35 baseline-area files.
The IMP-35 baseline-red invariance gate
(``tests/phase_z2/test_imp35_baseline_red_invariance.py``) runs:
python -m pytest -q --tb=no -p no:cacheprovider \\
tests/test_imp47b_step12_ai_wiring.py \\
tests/test_phase_z2_ai_fallback_config.py
The two trailing positional ``.py`` arguments are the signature.
We compare on basename suffix so the check is path-separator and
cwd agnostic.
Returning True here suppresses the ``AI_FALLBACK_*`` env override
so the baseline-red registry contract (Stage 2 u11 lock) holds for
the gate's child process while every other invocation
(full-suite, IMP-#85 smokes, mixed-target dev runs) still gets the
default-OFF isolation.
"""
file_targets = [arg for arg in sys.argv[1:] if arg.endswith(".py")]
if not file_targets:
return False
return all(
any(
arg.replace("\\", "/").endswith(suffix)
for suffix in _IMP35_BASELINE_AREA_FILE_SUFFIXES
)
for arg in file_targets
)
if _is_imp35_baseline_subprocess():
# Drop any inherited AI_FALLBACK_* values so the gate's child process
# falls back to the live ``.env`` (AI_FALLBACK_ENABLED=true) — the
# exact precondition under which the four registered baseline-red
# tests are red. ``pop`` is no-op when the key is absent, so a
# developer running the gate manually with a clean environment is
# unaffected.
os.environ.pop("AI_FALLBACK_ENABLED", None)
os.environ.pop("AI_FALLBACK_AUTO_CACHE", None)
else:
os.environ["AI_FALLBACK_ENABLED"] = "false"
os.environ["AI_FALLBACK_AUTO_CACHE"] = "false"
@@ -2,11 +2,27 @@
Stage 1 finding: line 564 previously referenced a non-existent ID ("IMP-31").
The legitimate slot is IMP-17 (Gitea #17, carve-out — AI fallback only, normal path 밖).
Line 565 (IMP-29 frontend zone-level override) must remain untouched.
The reject anchor previously referenced IMP-29 (frontend zone-level override); it has
since been superseded by IMP-47B u1 (2026-05-21) which corrects the reject disposition
to AI re-construction over the rank-1 reject frame.
Anchor re-pin (2026-05-20, IMP-30 u1 follow-up): V4Match.provisional field added at
src/phase_z2_pipeline.py:179-184 shifted the route-hint table down by six lines.
Pinned line numbers updated from 564/565 → 570/571 to track the actual anchor location.
Pinned line numbers were updated 564/565 → 570/571.
Anchor re-pin (2026-05-22, IMP-36 u1 / Gitea #65 Stage 2): IMP-47B supersession at
src/phase_z2_pipeline.py:579-582 expanded the reject hint comment by four lines, which
shifted only the post-comment table downward. The restructure anchor itself moved from
570 → 578 because additional comment context was inserted between the table header and
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
"""
@@ -20,14 +36,17 @@ def _lines() -> list[str]:
return PIPELINE.read_text(encoding="utf-8").splitlines()
def test_line_570_references_imp17_not_imp31():
line = _lines()[569] # 1-indexed line 570
assert "restructure" in line, f"line 570 anchor drifted: {line!r}"
assert "IMP-17" in line, f"line 570 must reference IMP-17 (carve-out): {line!r}"
assert "IMP-31" not in line, f"line 570 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_571_still_references_imp29():
line = _lines()[570] # 1-indexed line 571
assert "reject" in line, f"line 571 anchor drifted: {line!r}"
assert "IMP-29" in line, f"line 571 must still reference IMP-29 frontend override: {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 587 must reference IMP-47B (supersedes prior IMP-29 reject disposition): "
f"{line!r}"
)
@@ -0,0 +1,17 @@
# IMP-#85 u5 fixture — non-VP contract whose payload.builder is absent from
# `PAYLOAD_BUILDERS`. Drives the u2 boot invariant + audit I3 negative paths.
#
# Scope (Stage 2 lock): regression coverage only. Not a runtime catalog entry.
# Frame id is in the 9999xxx range so any accidental cross-reference is obvious.
imp85_u5_missing_builder_frame:
template_id: imp85_u5_missing_builder_frame
frame_id: 9999001
family: imp85_u5_fixture
source_shape: top_bullets
cardinality:
strict: 3
payload:
title:
source: section.title
builder: definitely_not_a_registered_builder_imp85_u5
@@ -0,0 +1,23 @@
# IMP-#85 u5 fixture — non-VP contract whose `items_with_role` builder produces
# a `slot_payload.<array_root>` key the partial never references. Drives the
# audit I4 (generated-key-orphan) negative path.
#
# Scope (Stage 2 lock): regression coverage only. The corresponding partial is
# written into a tmp dir by the test (it must NOT use `slot_payload[...]`
# bracket access, otherwise I4 suppresses correctly and the assertion fails).
imp85_u5_undeclared_slot_frame:
template_id: imp85_u5_undeclared_slot_frame
frame_id: 9999002
family: imp85_u5_fixture
source_shape: top_bullets
cardinality:
strict: 3
payload:
title:
source: section.title
builder: items_with_role
builder_options:
item_parser: pillar_item
array_root: orphan_array_root_imp85_u5
role_field: color_class
@@ -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
@@ -99,3 +99,63 @@ def test_fr_default_single_returns_full_body():
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
def _placeholder_zone(position: str) -> dict:
# IMP-86 u3 — mirror the u1 mapper-FitError placeholder zones_data
# record shape (`src/phase_z2_pipeline.py:4459-4469`) used to keep the
# failed unit's preset position in zones_data so build_layout_css /
# _compute_per_zone_geometry observe len(zones_data) == active preset's
# css_areas rows (R). content_weight.score == 0 ensures the placeholder
# does not steal weight from the surviving normal zone.
return {
"position": position,
"template_id": "__empty__",
"slot_payload": {},
"content_weight": {"score": 0},
"min_height_px": 100,
"assignment_source": "imp86_u1_adapter_needed",
"section_assignment_override": False,
"provisional": False,
}
def test_horizontal_2_normal_plus_placeholder_preserves_R2_cardinality():
"""IMP-86 u3 — horizontal-2 with one mapper-success zone + one
mapper-FitError placeholder (per IMP-86 u1) must keep heights_px /
debug_zones / per_zone cardinality locked at R=2.
Reproduces the bug scenario in the issue body (mdx03 reject override
where 03-2 hits adapter_needed) at the helper level: if the FitError
path forgets to append a placeholder, heights_px length 1 vs R=2
raises ValueError at `_compute_per_zone_geometry`. With the u1
placeholder, all three artifacts (heights_px, debug_zones, per_zone)
stay at length 2 and the geometry helper succeeds.
"""
zones = [_zone("top", 1.0), _placeholder_zone("bottom")]
layout_css = build_layout_css("horizontal-2", zones)
debug_zones = [{"position": "top"}, {"position": "bottom"}]
# heights_px length is locked to R=2 (parsed from preset css_areas).
assert layout_css["areas"] == '"top" "bottom"'
assert len(layout_css["heights_px"]) == 2
# widths_px length is locked to C=1.
assert len(layout_css["widths_px"]) == 1
# Placeholder zone (score=0) gets its min_height_px (100) and the
# surviving normal zone absorbs the remaining body height after gap.
assert layout_css["heights_px"][1] == 100
assert (
layout_css["heights_px"][0] + layout_css["heights_px"][1] + GRID_GAP
== SLIDE_BODY_HEIGHT
)
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
# per_zone cardinality matches debug_zones (which matches R=2).
assert len(per_zone) == 2
assert [pz["position"] for pz in per_zone] == ["top", "bottom"]
assert per_zone[0]["zone_height_px"] == layout_css["heights_px"][0]
assert per_zone[1]["zone_height_px"] == layout_css["heights_px"][1]
# Both zones share the single column => width == SLIDE_BODY_WIDTH.
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
assert per_zone[1]["zone_width_px"] == SLIDE_BODY_WIDTH
@@ -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."
)
@@ -0,0 +1,339 @@
"""IMP-35 (#64) u11 — baseline-red invariance gate.
Stage 2 binding contract (unit u11):
IMP-35 inherits a four-test red baseline from prior phases that is
explicitly OUT OF SCOPE for this issue:
1. tests/test_imp47b_step12_ai_wiring.py
::test_mixed_units_classified_by_route_and_provisional_flag
2. tests/test_imp47b_step12_ai_wiring.py
::test_reject_provisional_unit_reaches_router_short_circuit
3. tests/test_imp47b_step12_ai_wiring.py
::test_step12_ai_repair_artifact_writes_json_serialisable_records
4. tests/test_phase_z2_ai_fallback_config.py
::test_ai_fallback_master_flag_default_off
u11 does NOT fix these. u11 LOCKS the count + identity of the
baseline-red set so that IMP-35 cannot silently grow the red surface
while the issue is in-flight. A follow-up issue (Stage 2 plan
`follow_up_candidates`) tracks the actual repair.
Invariance semantics:
- The exact four baseline-red node ids resolve to real, collectible
pytest items (a rename / delete is caught up front; the gate cannot
be defeated by silently removing the failing test).
- Running pytest on the BROADER baseline-area files
(``tests/test_imp47b_step12_ai_wiring.py`` +
``tests/test_phase_z2_ai_fallback_config.py``) yields EXACTLY four
FAILED node ids and zero ERROR node ids; the FAILED set is exactly
the documented baseline-red set.
- A NEW red introduced by IMP-35 in the baseline area flips the
FAILED count above four AND/OR introduces an extra FAILED node id
that is not in the baseline set; either branch fails this gate.
AI isolation contract (`feedback_ai_isolation_contract`):
The invariance gate runs pytest in a child process and parses stdout.
It must NOT import the Anthropic SDK and must NOT route through
``route_ai_fallback``. The structural import test below locks this.
Stage 2 plan source: Stage 2 exit report u11 — "u11 acknowledges the
current four red baseline tests as pre-existing and adds an invariance
gate so IMP-35 cannot worsen them."
"""
from __future__ import annotations
import ast
import re
import subprocess
import sys
from pathlib import Path
# === BASELINE-RED REGISTRY (frozen by Stage 2 u11 contract) ===
#
# Order is informational only; the gate compares as a set. Each entry
# is a fully-qualified pytest node id resolvable from the repo root.
IMP35_BASELINE_RED_NODE_IDS: tuple[str, ...] = (
"tests/test_imp47b_step12_ai_wiring.py"
"::test_mixed_units_classified_by_route_and_provisional_flag",
"tests/test_imp47b_step12_ai_wiring.py"
"::test_reject_provisional_unit_reaches_router_short_circuit",
"tests/test_imp47b_step12_ai_wiring.py"
"::test_step12_ai_repair_artifact_writes_json_serialisable_records",
"tests/test_phase_z2_ai_fallback_config.py"
"::test_ai_fallback_master_flag_default_off",
)
# Files that own the baseline-red set. The "no-new-red in baseline area"
# axis runs pytest on this set and checks that ONLY the registry above
# fails.
IMP35_BASELINE_RED_AREA_FILES: tuple[str, ...] = (
"tests/test_imp47b_step12_ai_wiring.py",
"tests/test_phase_z2_ai_fallback_config.py",
)
# === Repo root resolution (subprocess CWD anchor) ===
# tests/phase_z2/<this file>.py -> parents[2] = repo root.
_REPO_ROOT: Path = Path(__file__).resolve().parents[2]
# === pytest stdout parsers ===
# Matches lines like:
# FAILED tests/test_imp47b_step12_ai_wiring.py::test_xxx
# and:
# FAILED tests/test_imp47b_step12_ai_wiring.py::test_xxx - AssertionError: ...
# The capture group is the bare node id (no trailing failure detail).
_FAILED_LINE_RE = re.compile(r"^FAILED\s+(\S+?)(?:\s+-\s+.*)?$", re.MULTILINE)
# Matches lines like:
# ERROR tests/test_xxx.py::test_yyy
_ERROR_LINE_RE = re.compile(r"^ERROR\s+(\S+?)(?:\s+-\s+.*)?$", re.MULTILINE)
# Matches the pytest tail summary line (sub-second timing field varies):
# 4 failed, 6 passed in 2.27s
_TAIL_SUMMARY_RE = re.compile(
r"^(?P<body>.*?)\s+in\s+\d+(?:\.\d+)?s\s*$", re.MULTILINE
)
def _run_pytest_collect_only(node_ids: tuple[str, ...]) -> subprocess.CompletedProcess:
"""Run ``pytest --collect-only -q`` against the supplied node ids.
Used to confirm the baseline-red registry resolves to real, currently
collectible tests. If a test is renamed / moved / deleted out from
under the registry, pytest's collection failure is the signal.
"""
return subprocess.run(
[
sys.executable,
"-m",
"pytest",
"--collect-only",
"-q",
*node_ids,
],
cwd=_REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
def _run_pytest_quiet(targets: tuple[str, ...]) -> subprocess.CompletedProcess:
"""Run ``pytest -q --tb=no -p no:cacheprovider`` against ``targets``.
``-p no:cacheprovider`` keeps the gate hermetic across reruns; the
parent pytest invocation that triggers this child process must not
poison or be poisoned by the child's cache state.
"""
return subprocess.run(
[
sys.executable,
"-m",
"pytest",
"-q",
"--tb=no",
"-p",
"no:cacheprovider",
*targets,
],
cwd=_REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
def _parse_failed_node_ids(stdout: str) -> set[str]:
"""Extract the set of FAILED node ids from pytest's ``--tb=no -q`` stdout."""
return {match.group(1) for match in _FAILED_LINE_RE.finditer(stdout)}
def _parse_error_node_ids(stdout: str) -> set[str]:
"""Extract the set of ERROR node ids from pytest's ``--tb=no -q`` stdout."""
return {match.group(1) for match in _ERROR_LINE_RE.finditer(stdout)}
# === Tests ===
def test_imp35_baseline_red_registry_has_exactly_four_node_ids() -> None:
"""The baseline-red registry is a frozen four-tuple (Stage 2 u11 lock)."""
assert len(IMP35_BASELINE_RED_NODE_IDS) == 4
assert len(set(IMP35_BASELINE_RED_NODE_IDS)) == 4, (
"IMP-35 baseline-red registry must not contain duplicate node ids; "
"duplicates would silently weaken the invariance gate."
)
def test_imp35_baseline_red_registry_node_ids_are_well_formed() -> None:
"""Each baseline-red node id must look like ``tests/<file>.py::<test>``."""
for node_id in IMP35_BASELINE_RED_NODE_IDS:
assert node_id.startswith("tests/"), (
f"IMP-35 baseline-red registry node id {node_id!r} must live "
"under tests/ — registry entries point at repo-rooted node ids."
)
assert ".py::" in node_id, (
f"IMP-35 baseline-red registry node id {node_id!r} must use the "
"<file>.py::<test_name> pytest node id grammar."
)
def test_imp35_baseline_red_registry_files_match_area_inventory() -> None:
"""Registry node ids must all live in declared baseline-area files.
Locks the cross-axis link between :data:`IMP35_BASELINE_RED_NODE_IDS`
and :data:`IMP35_BASELINE_RED_AREA_FILES` — adding a registry entry
without expanding the area sweep (or vice versa) is the kind of
half-wiring that would silently let the gate miss new reds.
"""
declared_files = set(IMP35_BASELINE_RED_AREA_FILES)
for node_id in IMP35_BASELINE_RED_NODE_IDS:
file_part, _, _ = node_id.partition("::")
assert file_part in declared_files, (
f"IMP-35 baseline-red registry entry {node_id!r} references "
f"{file_part!r}, which is not in IMP35_BASELINE_RED_AREA_FILES. "
"Update both lists together or the area sweep will miss new reds."
)
def test_imp35_baseline_red_node_ids_resolve_to_collectible_tests() -> None:
"""``pytest --collect-only`` must resolve every baseline-red node id.
A failure here means a baseline-red test was renamed / deleted /
moved out from under the gate; the registry must be updated in the
same commit (or, if the test was fixed, the follow-up issue must
deregister it).
"""
result = _run_pytest_collect_only(IMP35_BASELINE_RED_NODE_IDS)
# ``pytest --collect-only`` exits 0 on full collection, 2/4/5 on
# collection errors. Exit code 5 = no tests collected ("not found").
assert result.returncode in (0,), (
"pytest --collect-only failed for the IMP-35 baseline-red "
f"registry (rc={result.returncode}).\n"
f"STDOUT:\n{result.stdout}\n"
f"STDERR:\n{result.stderr}"
)
def test_imp35_baseline_red_invariance_gate_failed_set_matches_registry() -> None:
"""Running pytest on the baseline area must FAIL EXACTLY the registry.
This is the core invariance contract. If IMP-35 work breaks a 5th
test in the baseline area, the FAILED set diverges from the registry
and this gate trips. If IMP-35 accidentally fixes one of the four,
the FAILED set shrinks below four and this gate also trips — at
which point the registry is removed from the failing test (the
follow-up issue deregisters it) and the gate is re-locked.
"""
result = _run_pytest_quiet(IMP35_BASELINE_RED_AREA_FILES)
# The baseline area is currently red: pytest MUST exit non-zero. A
# zero return code here would mean the baseline magically went green
# (or the parser missed the failures); both branches require human
# review before the registry is updated.
assert result.returncode != 0, (
"IMP-35 baseline-red area is expected to fail (4 known reds). "
"A clean pytest exit means either the baseline was unexpectedly "
"fixed (deregister via follow-up issue) or the gate's subprocess "
"did not reach the failing tests.\n"
f"STDOUT:\n{result.stdout}\n"
f"STDERR:\n{result.stderr}"
)
failed_ids = _parse_failed_node_ids(result.stdout)
error_ids = _parse_error_node_ids(result.stdout)
expected = set(IMP35_BASELINE_RED_NODE_IDS)
assert error_ids == set(), (
"IMP-35 baseline-red invariance gate found ERROR-state tests "
f"in the baseline area (expected zero): {sorted(error_ids)}.\n"
f"STDOUT:\n{result.stdout}"
)
assert failed_ids == expected, (
"IMP-35 baseline-red invariance gate detected drift between the "
"registered baseline-red set and the actual pytest FAILED set.\n"
f" registered (expected): {sorted(expected)}\n"
f" actual (observed): {sorted(failed_ids)}\n"
f" unexpected new reds: {sorted(failed_ids - expected)}\n"
f" unexpectedly green: {sorted(expected - failed_ids)}\n"
"If new reds appear above, IMP-35 has silently grown the red "
"surface (u11 contract violation). If reds are unexpectedly "
"green, the follow-up issue must deregister them.\n"
f"STDOUT:\n{result.stdout}"
)
def test_imp35_baseline_red_invariance_gate_failed_count_is_exactly_four() -> None:
"""Count-only assertion: the baseline area has exactly four FAILED nodes.
Complements the identity check above. Even if a parser bug or
output-format change ever weakens the identity check, the bare count
still catches the "did a new red sneak in?" failure mode.
"""
result = _run_pytest_quiet(IMP35_BASELINE_RED_AREA_FILES)
failed_ids = _parse_failed_node_ids(result.stdout)
assert len(failed_ids) == 4, (
"IMP-35 baseline-red invariance gate expected exactly 4 FAILED "
f"node ids in the baseline area; observed {len(failed_ids)}: "
f"{sorted(failed_ids)}.\n"
f"STDOUT:\n{result.stdout}"
)
def test_imp35_baseline_red_invariance_module_has_no_ai_imports() -> None:
"""AI isolation contract — u11 invariance gate must stay pure stdlib.
Mirrors the structural import lock used by u6 / u7 / u10. The gate
is deterministic-with-data (subprocess pytest + regex parse); any
Anthropic SDK import or route through the AI fallback router would
violate the ``feedback_ai_isolation_contract`` lock.
The check is AST-based so the assertion bodies (which reference
forbidden tokens by name) do not self-trigger a string-substring
false positive.
"""
forbidden_module_prefix = "anthropic"
forbidden_attr_substring = "route_ai_fallback"
module_source = Path(__file__).read_text(encoding="utf-8")
tree = ast.parse(module_source)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
root = alias.name.split(".", 1)[0]
assert root != forbidden_module_prefix, (
"IMP-35 u11 invariance gate must not import the "
f"Anthropic SDK (found ``import {alias.name}``)."
)
elif isinstance(node, ast.ImportFrom):
if node.module is None:
continue
root = node.module.split(".", 1)[0]
assert root != forbidden_module_prefix, (
"IMP-35 u11 invariance gate must not import from the "
f"Anthropic SDK (found ``from {node.module} import ...``)."
)
for alias in node.names:
assert forbidden_attr_substring not in alias.name, (
"IMP-35 u11 invariance gate must not route through the "
"AI fallback router (found "
f"``from {node.module} import {alias.name}``)."
)
elif isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name):
assert forbidden_attr_substring not in func.id, (
"IMP-35 u11 invariance gate must not call into the "
f"AI fallback router (found call to ``{func.id}``)."
)
elif isinstance(func, ast.Attribute):
assert forbidden_attr_substring not in func.attr, (
"IMP-35 u11 invariance gate must not call into the "
f"AI fallback router (found call to ``.{func.attr}``)."
)
@@ -0,0 +1,166 @@
"""IMP-36 (Gitea #65) — P1/P2 fit/rotation generalization static checks.
Coupled with u2 (frame_contracts.yaml two-bool axis + F29 P3 parity).
Asserts:
(1) contract-level axis booleans on the 13 partial-backed contracts and
their absence on the 19 builder-only contracts;
(2) F29 P3 parity (both columns declare column_with_transform);
(3) partial-side CSS signatures —
P1 (rotation_eligible=true) → ``container-name: f<N>b-root`` +
``container-type: size`` + ``@container <name> (aspect-ratio < 1.5)``.
P2 (body_fit_pattern2=true) → ``--max-body-lines`` + ``cqh`` + ``clamp(``
in the body line-height clamp.
Per Stage 2 plan the partial-side P1/P2 assertions for F13/F14/F20/F8 begin
passing only after u4-u7 land. F23 (Stage 1 canonical P2 source) already
satisfies P2 at u3 time. F23 explicitly stays P1=false (no rotation rule)
per the in-file lock at templates/phase_z2/families/app_sw_package_vs_solution.html
line 64.
"""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).resolve().parents[2]
CONTRACTS_PATH = ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml"
FAMILIES_DIR = ROOT / "templates" / "phase_z2" / "families"
EXPECTED_P1_TRUE = {
"three_parallel_requirements",
"three_persona_benefits",
"dx_sw_necessity_three_perspectives",
"info_management_what_how_when",
}
EXPECTED_P1_FALSE = {
"app_sw_package_vs_solution",
"bim_current_problems_paired",
"bim_dx_comparison_table",
"bim_issues_quadrant_four",
"construction_bim_three_usage",
"construction_goals_three_circle_intersection",
"pre_construction_model_info_stacked",
"process_product_two_way",
"sw_reality_three_emphasis",
}
EXPECTED_P2_TRUE = EXPECTED_P1_TRUE | {"app_sw_package_vs_solution"}
EXPECTED_P2_FALSE = EXPECTED_P1_FALSE - {"app_sw_package_vs_solution"}
# P1 container-name convention = f<frame_id>b-root, declared in Stage 2 plan.
CONTAINER_NAMES = {
"three_parallel_requirements": "f13b-root",
"three_persona_benefits": "f14b-root",
"dx_sw_necessity_three_perspectives": "f20b-root",
"info_management_what_how_when": "f8b-root",
}
@pytest.fixture(scope="module")
def contracts() -> dict:
return yaml.safe_load(CONTRACTS_PATH.read_text(encoding="utf-8"))
@pytest.fixture(scope="module")
def partial_files() -> set[str]:
return {p.stem for p in FAMILIES_DIR.glob("*.html")}
# ─── contract metadata axis ────────────────────────────────────────────────
def test_partial_backed_thirteen_carry_both_flags(contracts, partial_files):
partial_backed = {k for k in contracts if k in partial_files}
assert len(partial_backed) == 13, sorted(partial_backed)
missing = [
tid
for tid in partial_backed
if "rotation_eligible" not in contracts[tid]
or "body_fit_pattern2" not in contracts[tid]
]
assert missing == [], missing
bad_type = [
tid
for tid in partial_backed
if not isinstance(contracts[tid]["rotation_eligible"], bool)
or not isinstance(contracts[tid]["body_fit_pattern2"], bool)
]
assert bad_type == [], bad_type
def test_builder_only_nineteen_carry_neither_flag(contracts, partial_files):
builder_only = {k for k in contracts if k not in partial_files}
assert len(builder_only) == 19, sorted(builder_only)
leaked = [
tid
for tid in builder_only
if "rotation_eligible" in contracts[tid] or "body_fit_pattern2" in contracts[tid]
]
assert leaked == [], leaked
def test_rotation_eligible_true_set(contracts):
actual = {k for k, v in contracts.items() if v.get("rotation_eligible") is True}
assert actual == EXPECTED_P1_TRUE
def test_rotation_eligible_false_set(contracts):
actual = {k for k, v in contracts.items() if v.get("rotation_eligible") is False}
assert actual == EXPECTED_P1_FALSE
def test_body_fit_pattern2_true_set(contracts):
actual = {k for k, v in contracts.items() if v.get("body_fit_pattern2") is True}
assert actual == EXPECTED_P2_TRUE
def test_body_fit_pattern2_false_set(contracts):
actual = {k for k, v in contracts.items() if v.get("body_fit_pattern2") is False}
assert actual == EXPECTED_P2_FALSE
def test_f29_columns_both_with_transform(contracts):
"""P3 parity — F29 (process_product_two_way) columns[*].body_parser symmetry."""
cols = contracts["process_product_two_way"]["payload"]["builder_options"]["columns"]
parsers = [c.get("body_parser") for c in cols]
assert parsers == ["column_with_transform", "column_with_transform"], parsers
# ─── partial-side CSS axis (u4-u7 progressively satisfy) ───────────────────
@pytest.mark.parametrize("tid", sorted(EXPECTED_P1_TRUE))
def test_p1_partial_declares_aspect_ratio_rotation(tid):
"""P1=true partials declare ``container-name``/``container-type`` and an
``@container <name> (aspect-ratio < 1.5)`` rotation rule. Satisfied by
F13/F14/F20/F8 in u4-u7."""
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
name = CONTAINER_NAMES[tid]
assert f"container-name: {name}" in css, tid
assert "container-type: size" in css, tid
assert f"@container {name} (aspect-ratio < 1.5)" in css, tid
@pytest.mark.parametrize("tid", sorted(EXPECTED_P1_FALSE))
def test_p1_false_partial_has_no_rotation_rule(tid):
"""P1=false partials must not declare ``aspect-ratio < 1.5`` rotation
rule. F23 may still keep its own container-name for P2 cqh — only the
rotation rule signature is forbidden here."""
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
assert "aspect-ratio < 1.5" not in css, tid
@pytest.mark.parametrize("tid", sorted(EXPECTED_P2_TRUE))
def test_p2_partial_uses_cqh_clamp_max_body_lines(tid):
"""P2=true partials declare ``--max-body-lines`` + ``cqh`` + ``clamp(``
body line-height clamp. Satisfied by F23 today; F13/F14/F20/F8 land u4-u7."""
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
assert "--max-body-lines" in css, tid
assert "cqh" in css, tid
assert "clamp(" in css, tid
@pytest.mark.parametrize("tid", sorted(EXPECTED_P2_FALSE))
def test_p2_false_partial_has_no_max_body_lines(tid):
"""P2=false partials must not declare ``--max-body-lines``."""
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
assert "--max-body-lines" not in css, tid
@@ -0,0 +1,299 @@
"""IMP-36 (Gitea #65 u8) — Selenium self-fire for the P1/P2 generalization.
For each of the four P1+P2 partials (F13 ``three_parallel_requirements``,
F14 ``three_persona_benefits``, F20 ``dx_sw_necessity_three_perspectives``,
F8 ``info_management_what_how_when``), the partial's ``<style>`` block is
rendered with a minimal structural skeleton inside a fixed-size outer div
at two aspect ratios — wide (1200x675, aspect 1.78) and tall (600x600,
aspect 1.0) — and verified live in headless Chrome:
* P1 (container-query rotation): grid-template-columns goes from 3 tracks
(wide, aspect >= 1.5) to 1 track (tall, aspect < 1.5).
* P2 (cqh/clamp line-height): computed line-height on the body text element
differs between wide and tall because ``cqh`` scales with container height.
* P2 invariant (Stage 2 guardrail #6 / IMP-36 contract): the additive P2
rule body declares ``line-height: clamp(...)`` only — no ``font-size``
mutation. Enforced by static text scan of each partial.
OVERFLOW_CASCADE_ORDER must remain a 4-tuple — the Step 17 cascade contract
is not altered by IMP-36 (P1/P2 are CSS-only self-fire; no new Python stage
is introduced — "no new Python surface" per Stage 2 plan).
Chromedriver resolution mirrors the pipeline order (``PROJECT_ROOT/
chromedriver{,.exe}`` -> PATH -> Selenium Manager). When no driver resolves
the suite skips; under ``PHASE_Z_REQUIRE_SELENIUM=1`` the skip becomes a
strict xfail so CI cannot silently lose coverage.
"""
from __future__ import annotations
import os
import re
import shutil
from pathlib import Path
import pytest
from src.phase_z2_ai_fallback.step17 import OVERFLOW_CASCADE_ORDER
PROJECT_ROOT = Path(__file__).resolve().parents[2]
FAMILIES = PROJECT_ROOT / "templates" / "phase_z2" / "families"
# ─── chromedriver guard (mirrors test_phase_z2_step14_image_check) ───
def _selenium_manager_resolvable() -> bool:
try:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options as _Opts
except Exception:
return False
opts = _Opts()
for arg in ("--headless=new", "--no-sandbox", "--disable-dev-shm-usage"):
opts.add_argument(arg)
try:
drv = webdriver.Chrome(options=opts)
except Exception:
return False
try:
drv.quit()
except Exception:
pass
return True
def _chromedriver_resolvable() -> bool:
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
if candidate.is_file():
return True
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
return True
return _selenium_manager_resolvable()
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
_DRIVER_AVAILABLE = _chromedriver_resolvable()
if not _DRIVER_AVAILABLE:
if _REQUIRE_SELENIUM:
pytestmark = pytest.mark.xfail(
strict=True,
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
)
else:
pytestmark = pytest.mark.skip(
reason=(
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
),
)
# ─── frame harness table ─────────────────────────────────────────────
# stem = partial filename (no .html)
# root = top-level container-query class (target of container-type:size)
# cols = grid class that rotates 3->1 under aspect < 1.5
# col_inner = minimal markup for one column with one body text element.
# The inline --max-body-lines value is chosen so the P2 clamp
# does not saturate at both aspects (otherwise wide and tall
# would compute identical line-height). F13 uses 20cqh / N so
# N=8 splits the clamp band; F14/F20/F8 use 60cqh / N so N=20
# splits theirs.
# text_sel = CSS selector for the body text element to measure
# p2_re = regex for the IMP-36 P2 rule body (must contain line-height
# clamp and must NOT contain font-size)
#
# Font-size invariance is asserted uniformly for all four frames — IMP-36 P2
# mutates line-height / --max-body-lines only (Stage 2 guardrail #6).
FRAMES = [
{
"stem": "three_parallel_requirements",
"root": "f13b",
"cols": "f13b__cols",
"col_inner": (
'<div class="f13b__col"><div class="f13b__body">'
'<div class="f13b__section"><div class="f13b__desc" '
'style="--max-body-lines: 8;">'
'<div class="text-line">line a</div>'
'<div class="text-line">line b</div>'
"</div></div></div></div>"
),
"text_sel": ".f13b__desc",
"p2_re": r"\.f13b__desc\s*\{\s*line-height:\s*clamp\([^}]*\}",
},
{
"stem": "three_persona_benefits",
"root": "f14b",
"cols": "f14b__cols",
"col_inner": (
'<div class="f14b__col"><div class="f14b__body" '
'style="--max-body-lines: 20;">'
'<div class="text-line">line a</div>'
'<div class="text-line">line b</div>'
"</div></div>"
),
"text_sel": ".f14b__body .text-line",
"p2_re": r"\.f14b__body\s+\.text-line\s*\{\s*line-height:\s*clamp\([^}]*\}",
},
{
"stem": "dx_sw_necessity_three_perspectives",
"root": "f20b",
"cols": "f20b__cols",
"col_inner": (
'<div class="f20b__col"><div class="f20b__body" '
'style="--max-body-lines: 20;">'
'<div class="text-line">line a</div>'
'<div class="text-line">line b</div>'
"</div></div>"
),
"text_sel": ".f20b__body .text-line",
"p2_re": r"\.f20b__body\s+\.text-line\s*\{\s*line-height:\s*clamp\([^}]*\}",
},
{
"stem": "info_management_what_how_when",
"root": "f8b",
"cols": "f8b__cols",
"col_inner": (
'<div class="f8b__col"><div class="f8b__body" '
'style="--max-body-lines: 20;">'
'<div class="text-line">line a</div>'
'<div class="text-line">line b</div>'
"</div></div>"
),
"text_sel": ".f8b__body .text-line",
"p2_re": r"\.f8b__body\s+\.text-line\s*\{\s*line-height:\s*clamp\([^}]*\}",
},
]
def _read_style_block(partial: Path) -> str:
text = partial.read_text(encoding="utf-8")
m = re.search(r"<style>(.*?)</style>", text, flags=re.DOTALL)
assert m, f"<style> block missing in {partial}"
return m.group(1)
def _harness_html(frame: dict, outer_w: int, outer_h: int) -> str:
style = _read_style_block(FAMILIES / f"{frame['stem']}.html")
cols_html = (
f'<div class="{frame["cols"]}">' + (frame["col_inner"] * 3) + "</div>"
)
return (
"<!doctype html><html><head><meta charset='utf-8'><style>"
":root{"
" --font-body:10px; --font-sub-title:12px; --font-zone-title:13px;"
" --font-caption:10px;"
" --lh-body:1.4; --lh-sub-title:1.3; --lh-zone-title:1.3;"
"}"
"html,body{margin:0;padding:0;font-size:10px;}"
f".outer{{width:{outer_w}px;height:{outer_h}px;}}"
f".outer > .{frame['root']}{{width:100%;height:100%;}}"
f"{style}</style></head><body>"
f'<div class="outer"><div class="{frame["root"]}">'
f"{cols_html}"
"</div></div></body></html>"
)
def _new_driver():
from selenium import webdriver
from selenium.webdriver.chrome.options import Options as _Opts
opts = _Opts()
for arg in ("--headless=new", "--no-sandbox", "--disable-dev-shm-usage"):
opts.add_argument(arg)
drv_path = None
for cand in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
if cand.is_file():
drv_path = str(cand)
break
if drv_path is None:
which = shutil.which("chromedriver") or shutil.which("chromedriver.exe")
if which:
drv_path = which
if drv_path:
from selenium.webdriver.chrome.service import Service
return webdriver.Chrome(service=Service(executable_path=drv_path), options=opts)
return webdriver.Chrome(options=opts)
def _measure(drv, frame: dict, html_path: Path) -> dict:
drv.get(html_path.resolve().as_uri())
cols_tpl = drv.execute_script(
"return getComputedStyle(document.querySelector(arguments[0])).gridTemplateColumns;",
f".{frame['cols']}",
)
lh = drv.execute_script(
"var el = document.querySelector(arguments[0]); "
"return el ? getComputedStyle(el).lineHeight : null;",
frame["text_sel"],
)
fs = drv.execute_script(
"var el = document.querySelector(arguments[0]); "
"return el ? getComputedStyle(el).fontSize : null;",
frame["text_sel"],
)
tracks = [t for t in (cols_tpl or "").split() if t]
return {"cols": cols_tpl, "tracks": len(tracks), "lh": lh, "fs": fs}
# ─── live (Selenium) parametrized check ──────────────────────────────
@pytest.mark.parametrize("frame", FRAMES, ids=[f["stem"] for f in FRAMES])
def test_p1_rotation_and_p2_lineheight_self_fire(tmp_path: Path, frame: dict) -> None:
"""P1: 3-track grid rotates to 1-track when aspect < 1.5.
P2: line-height differs between aspects (cqh-driven clamp evaluates
differently as container height changes).
Font-size invariance is asserted uniformly for all four frames — IMP-36
P2 mutates line-height / --max-body-lines only (Stage 2 guardrail #6)."""
wide_path = tmp_path / f"{frame['stem']}_wide.html"
tall_path = tmp_path / f"{frame['stem']}_tall.html"
wide_path.write_text(_harness_html(frame, 1200, 600), encoding="utf-8")
tall_path.write_text(_harness_html(frame, 400, 400), encoding="utf-8")
drv = _new_driver()
try:
drv.set_window_size(1400, 900)
wide = _measure(drv, frame, wide_path)
tall = _measure(drv, frame, tall_path)
finally:
try:
drv.quit()
except Exception:
pass
assert wide["tracks"] == 3, (frame["stem"], wide)
assert tall["tracks"] == 1, (frame["stem"], tall)
assert wide["lh"] is not None and tall["lh"] is not None, (frame["stem"], wide, tall)
assert wide["lh"] != tall["lh"], (frame["stem"], wide, tall)
assert wide["fs"] == tall["fs"], (frame["stem"], wide, tall)
# ─── static (no-Selenium) guards ─────────────────────────────────────
@pytest.mark.parametrize("frame", FRAMES, ids=[f["stem"] for f in FRAMES])
def test_p2_rule_declares_line_height_only(frame: dict) -> None:
"""IMP-36 P2 invariant — the additive P2 rule body must contain
``line-height: clamp(`` and must NOT declare ``font-size``."""
body = (FAMILIES / f"{frame['stem']}.html").read_text(encoding="utf-8")
m = re.search(frame["p2_re"], body, flags=re.DOTALL)
assert m, f"{frame['stem']}: P2 clamp rule not located via /{frame['p2_re']}/"
rule_body = m.group(0)
assert "line-height:" in rule_body, f"{frame['stem']}: P2 rule missing line-height: {rule_body!r}"
assert "clamp(" in rule_body, f"{frame['stem']}: P2 rule missing clamp(: {rule_body!r}"
assert "font-size" not in rule_body, (
f"{frame['stem']}: P2 rule must not declare font-size — got {rule_body!r}"
)
def test_overflow_cascade_order_is_four_tuple() -> None:
"""IMP-36 must not alter the Step 17 cascade contract. P1/P2 are CSS-only
self-fire (no new Python stage); the 4-tuple stays intact."""
assert isinstance(OVERFLOW_CASCADE_ORDER, tuple)
assert len(OVERFLOW_CASCADE_ORDER) == 4
assert [stage.value for stage in OVERFLOW_CASCADE_ORDER] == [
"deterministic",
"popup",
"ai_repair",
"user_override",
]
@@ -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"
)
@@ -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
@@ -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"
@@ -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
@@ -0,0 +1,305 @@
"""IMP-35 (#64) u10 — MDX preservation guard tests.
Stage 2 binding contract (unit u10):
After Step 17 POPUP gate (u5) stamps the unit, composition (u6) binds
the strategy, pipeline (u7) wires the render context, and slide_base
(u8) renders the ``<details>/<summary>`` wrapper, the end-to-end
invariant the user lock requires is:
MDX 원문 무손실 보존 (오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6
line 110, CLAUDE.md 자세히보기 원칙):
- popup body == FULL ``raw_content`` (byte-for-byte verbatim)
- body preview == SUBSET of ``raw_content`` (deterministic
leading-substring CUT — never a rewrite, never a re-summary)
- the original is ALWAYS reachable via the popup; the preview
loses no information because the popup holds the full source
- no structural element is dropped: text_block / table / image
/ ``<details>`` counts in popup body match the original
u6 and u7 each lock pieces of this invariant on their own surface.
u10 locks the END-TO-END no-content-drop guarantee on the rendered
payload — the surface a downstream verifier (Selenium / vision gate)
would inspect — so a future refactor on either u6 or u7 cannot
silently degrade MDX preservation without this test failing first.
Key invariants this file locks:
1. popup_html (full source) preserves every structural element from
raw_content byte-for-byte: bullet lines, paragraph blocks, markdown
table rows, image markdown, and nested ``<details>`` blocks.
2. preview_text is a deterministic leading-substring CUT of
raw_content — ``raw_content.startswith(preview_text)`` holds when
truncation happened.
3. Combined invariant: popup_html holds the FULL original even when
preview_text is shorter, so no content is dropped — the full
source is always reachable via the popup.
4. has_popup=False path: popup_html / preview_text are both None.
There is no popup escalation, so by definition no escalation can
drop content; the frame's partial_html (rendered separately by
slide_base.html and not part of u7 popup wiring) holds the inline
body.
5. AI isolation contract — pure deterministic preservation check;
no anthropic import, no route_ai_fallback path.
Cross-references:
- u6 composition popup binding (popup_body_source = full raw_content):
tests/phase_z2/test_composition_popup_strategy.py
- u7 pipeline wiring (popup_html = popup_body_source verbatim;
preview_text is a deterministic line-budget cut):
tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py
- u8 slide_base.html render surface (autoescaped popup body):
tests/phase_z2/test_slide_base_popup_render.py
- u9 display_strategies.yaml catalog (preserves_original=True for the
popup-bearing strategy):
tests/phase_z2/test_display_strategies_popup.py
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
import pytest
from src.phase_z2_composition import compose_zone_popup_payload
# ─── Synthetic stubs ─────────────────────────────────────────────────
@dataclass
class _StubUnit:
"""Minimal duck-typed CompositionUnit for u10 preservation tests."""
raw_content: str = "MOCK_ORIGINAL_CONTENT"
has_popup: bool = False
popup_escalation_plan: Optional[dict] = None
def _stub_popup_plan() -> dict:
"""Mirror the plan_details_popup_escalation feasible-escalation shape
(u3). u10 only echoes the plan into the unit so the binder reaches
the popup branch; no field is consumed here."""
return {
"action": "details_popup_escalation",
"stub": True,
"feasible": True,
"category": "structural_major_overflow",
"needs_split_decision": True,
"rationale": "MOCK_RATIONALE",
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
}
# ─── Deterministic structural-element counters ──────────────────────
def _count_markdown_bullet_lines(text: str) -> int:
"""Count leading-``-`` markdown bullet lines (- / * / + at line start)."""
return sum(
1 for line in text.splitlines() if re.match(r"^\s*[-*+]\s+", line)
)
def _count_markdown_table_rows(text: str) -> int:
"""Count markdown table rows (lines with ``|`` somewhere)."""
return sum(1 for line in text.splitlines() if "|" in line)
def _count_markdown_images(text: str) -> int:
"""Count markdown image references ``![...](...)``."""
return len(re.findall(r"!\[[^\]]*\]\([^)]+\)", text))
def _count_details_blocks(text: str) -> int:
"""Count nested ``<details>`` blocks in raw_content (rare — used to
lock the invariant even when MDX already carries native popups)."""
return len(re.findall(r"<details\b", text, flags=re.IGNORECASE))
# ─── Sample MDX content (structural diversity for the count guard) ──
_FULL_MDX_SAMPLE = (
"## MOCK_SECTION_TITLE\n"
"\n"
"Paragraph one explaining the MOCK topic. Lorem ipsum dolor sit amet.\n"
"\n"
"- bullet one with **bold** marker\n"
"- bullet two with *italic* marker\n"
"- bullet three trailing\n"
"\n"
"| col_a | col_b |\n"
"| --- | --- |\n"
"| MOCK_A | MOCK_B |\n"
"| MOCK_C | MOCK_D |\n"
"\n"
"![MOCK_ALT](mock/path/to/image_a.png)\n"
"![MOCK_ALT_TWO](mock/path/to/image_b.png)\n"
"\n"
"<details><summary>MOCK_NESTED_TRIGGER</summary>"
"<p>MOCK_NESTED_BODY</p></details>\n"
"\n"
"Paragraph two — closing remarks for the MOCK topic.\n"
)
# ─── Popup body = full raw_content (byte-for-byte) ───────────────────
def test_popup_body_byte_for_byte_equal_to_raw_content():
"""u10 — the end-to-end invariant: popup_html on the rendered payload
is byte-for-byte equal to the unit's raw_content. u6 + u7 already
lock this on their own surface; u10 re-asserts on the payload a
downstream verifier (Selenium / vision gate) would inspect."""
unit = _StubUnit(
raw_content=_FULL_MDX_SAMPLE,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = compose_zone_popup_payload(unit, container_height_px=200)
assert payload["popup_html"] == _FULL_MDX_SAMPLE
assert len(payload["popup_html"]) == len(_FULL_MDX_SAMPLE)
def test_popup_body_preserves_bullet_line_count():
"""u10 — text_block count equality. Every bullet line present in
raw_content MUST also be present in popup_html. A future refactor
that accidentally trims popup body to a summary would drop bullets
and fail this guard."""
unit = _StubUnit(
raw_content=_FULL_MDX_SAMPLE,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = compose_zone_popup_payload(unit, container_height_px=200)
assert _count_markdown_bullet_lines(payload["popup_html"]) == (
_count_markdown_bullet_lines(_FULL_MDX_SAMPLE)
)
def test_popup_body_preserves_markdown_table_row_count():
"""u10 — table count equality. Markdown table rows (header / divider
/ data) MUST all survive the popup wiring."""
unit = _StubUnit(
raw_content=_FULL_MDX_SAMPLE,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = compose_zone_popup_payload(unit, container_height_px=200)
assert _count_markdown_table_rows(payload["popup_html"]) == (
_count_markdown_table_rows(_FULL_MDX_SAMPLE)
)
def test_popup_body_preserves_image_reference_count():
"""u10 — image count equality. Markdown ``![alt](src)`` references
MUST all survive (CLAUDE.md: 이미지는 원본 그대로 사용, 크기만 조절 —
popup escalation must not silently drop image refs)."""
unit = _StubUnit(
raw_content=_FULL_MDX_SAMPLE,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = compose_zone_popup_payload(unit, container_height_px=200)
assert _count_markdown_images(payload["popup_html"]) == (
_count_markdown_images(_FULL_MDX_SAMPLE)
)
def test_popup_body_preserves_nested_details_block_count():
"""u10 — nested ``<details>`` blocks. Even when MDX already carries
a native popup, the u10 popup escalation MUST NOT collapse or drop
nested ``<details>`` markers."""
unit = _StubUnit(
raw_content=_FULL_MDX_SAMPLE,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = compose_zone_popup_payload(unit, container_height_px=200)
assert _count_details_blocks(payload["popup_html"]) == (
_count_details_blocks(_FULL_MDX_SAMPLE)
)
# ─── Preview = deterministic leading-substring CUT ──────────────────
def test_preview_text_is_a_leading_substring_of_raw_content_when_truncated():
"""u10 — preview is a CUT, never a rewrite. When truncation happens,
raw_content MUST start with preview_text verbatim (line-boundary
cut semantics; popup body retains the FULL original)."""
unit = _StubUnit(
raw_content=_FULL_MDX_SAMPLE,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
# 2-line budget — far smaller than the multi-line sample.
payload = compose_zone_popup_payload(unit, container_height_px=36)
preview = payload["preview_text"]
assert preview, "preview_text must be non-empty when truncation fires"
assert _FULL_MDX_SAMPLE.startswith(preview), (
"preview_text must be a leading-substring of raw_content "
"(MDX 원문 무손실 보존 — preview is a CUT, never a rewrite)."
)
# The popup body still holds the FULL original — no information loss.
assert payload["popup_html"] == _FULL_MDX_SAMPLE
def test_no_content_drop_when_preview_is_shorter_than_popup_body():
"""u10 — combined no-drop invariant. preview_text may be a strict
prefix of popup_html (shorter), but the popup body always holds the
full original. The user can always reach every line of the source
via the popup, even when the inline preview shows only the head."""
unit = _StubUnit(
raw_content=_FULL_MDX_SAMPLE,
has_popup=True,
popup_escalation_plan=_stub_popup_plan(),
)
payload = compose_zone_popup_payload(unit, container_height_px=36)
preview = payload["preview_text"]
popup_body = payload["popup_html"]
# preview is strictly shorter when truncation fires.
assert len(preview) < len(popup_body)
# popup_body is the FULL original — every line of raw_content is
# present in popup_body regardless of the inline preview budget.
for line in _FULL_MDX_SAMPLE.splitlines():
assert line in popup_body, (
f"MDX preservation guard violated — line {line!r} not present "
f"in popup body."
)
# ─── has_popup=False path: no popup, no escalation, no drop ─────────
def test_no_popup_path_yields_no_popup_html_no_preview_text():
"""u10 — when the Step 17 POPUP gate did not fire, no popup
escalation happens. popup_html and preview_text are both None.
By construction this branch cannot drop content (no escalation),
and the frame's partial_html (rendered separately by slide_base
and not part of u7 popup wiring) holds the inline body."""
unit = _StubUnit(raw_content=_FULL_MDX_SAMPLE, has_popup=False)
payload = compose_zone_popup_payload(unit, container_height_px=200)
assert payload["has_popup"] is False
assert payload["popup_html"] is None
assert payload["preview_text"] is None
# ─── AI isolation contract (structural import lock) ─────────────────
def test_popup_mdx_preservation_module_has_no_ai_imports():
"""u10 — preservation guard MUST stay AI-free. Structural guard:
composition module (where compose_zone_popup_payload lives) is
allowed to consult the catalog and unit state, never the Anthropic
SDK / route_ai_fallback path. Mirrors u6 / u7 import-isolation
pattern (feedback_ai_isolation_contract)."""
import src.phase_z2_composition as composition_module
source = composition_module.__file__
assert source is not None
with open(source, encoding="utf-8") as f:
text = f.read()
assert "import anthropic" not in text
assert "from anthropic" not in text
assert "route_ai_fallback" not in text
@@ -0,0 +1,413 @@
"""IMP-35 (#64) u8 — slide_base.html details/summary popup render tests.
Stage 2 wiring contract (unit u8):
u7 (``compose_zone_popup_payload`` in ``src/phase_z2_pipeline.py``) wired
four uniform per-zone render-context keys into every ``zones_data``
entry::
has_popup : bool
popup_html : str | None (FULL ``raw_content`` verbatim when
has_popup=True)
preview_text : str | None
popup_binding : dict | None (u6 binding — includes
``display_strategy``,
``detail_trigger.{placement,label}``)
u8 is the slide_base.html consumer side: it renders a JS-free
``<details>/<summary>`` wrapper inside the zone div when
``zone.has_popup`` is True. The summary acts as the toggle, the body
holds the FULL ``popup_html``. The frame's existing ``partial_html``
remains the zone body (inline preview / FIT-version of content); the
popup body holds the original — never replaces the partial.
Key invariants this file locks:
1. has_popup=False → no ``<details>`` element emitted (byte-identical
contract for non-popup zones, no regression to pre-u8).
2. has_popup=True → exactly one ``<details class="zone__popup-details
zone__popup-details--<placement>">`` per zone with a ``<summary>``
trigger and a ``<div class="zone__popup-body">`` holding the full
popup_html.
3. Popup body content is HTML-escaped (Jinja2 autoescape is ON for
slide_base.html — popup_html is plain MDX text, never raw HTML).
A ``<script>`` literal in raw_content MUST appear escaped, never as
an executable tag.
4. Whitespace inside the popup body is preserved via the
``.zone__popup-body`` CSS contract (``white-space: pre-wrap``).
Locks MDX 원문 무손실 보존 — newline structure of raw_content is
visible verbatim (오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6
line 110).
5. Placement / label / strategy id are READ from
``zone.popup_binding.detail_trigger.{placement,label}`` and
``zone.popup_binding.display_strategy`` — no hardcoded literal
drift from the catalog
(``templates/phase_z2/regions/display_strategies.yaml``).
6. Defensive defaults: a popup zone whose ``popup_binding`` is ``None``
(the unrenderable empty-plan branch of the pipeline composer
stamps ``popup_binding=None``) still renders sane defaults
(``placement=top-right``, ``label=details``,
``display_strategy=inline_preview_with_details``) — no
KeyError/AttributeError on the Jinja2 path.
7. The zone div carries ``data-has-popup="1"`` exactly when
has_popup=True — downstream observability anchor.
Cross-references:
- u5 Step 17 POPUP gate (stamps the marker on the unit):
tests/phase_z2/test_phase_z2_step17_popup_gate.py
- u6 composition popup binding (produces the binding dict u8 reads):
tests/phase_z2/test_composition_popup_strategy.py
- u7 pipeline composer wiring (puts the four keys into zones_data):
tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py
- display strategy catalog (placement / label source of truth):
templates/phase_z2/regions/display_strategies.yaml
"""
from __future__ import annotations
import re
import pytest
from src.phase_z2_pipeline import render_slide
# ─── Test scaffolding ───────────────────────────────────────────────
def _layout_css() -> dict:
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
def _no_popup_zone(**overrides) -> dict:
"""Baseline non-popup zone (matches the four-key wiring from u7
when has_popup=False — popup_binding may be None for the empty plan
branch or the u6 ``inline_full`` echo for renderable no-popup units;
here we exercise the empty-plan branch where popup_binding=None)."""
base = {
"position": "primary",
"template_id": "__empty__",
"slot_payload": {},
"has_popup": False,
"popup_html": None,
"preview_text": None,
"popup_binding": None,
}
base.update(overrides)
return base
def _popup_binding(
*,
placement: str = "top-right",
label: str = "details",
strategy: str = "inline_preview_with_details",
) -> dict:
"""Matches the u6 binding shape (subset relevant to u8 render)."""
return {
"display_strategy": strategy,
"detail_trigger": {"placement": placement, "label": label},
"has_popup": True,
"popup_escalation_plan": {"action": "details_popup_escalation"},
}
def _popup_zone(
*,
popup_html: str = "MOCK_POPUP_BODY_FULL_ORIGINAL",
binding: dict | None = None,
**overrides,
) -> dict:
"""Baseline popup zone (has_popup=True) for u8 rendering tests."""
base = {
"position": "primary",
"template_id": "__empty__",
"slot_payload": {},
"has_popup": True,
"popup_html": popup_html,
"preview_text": "MOCK_PREVIEW",
"popup_binding": binding if binding is not None else _popup_binding(),
}
base.update(overrides)
return base
def _render(zones: list[dict]) -> str:
return render_slide(
slide_title="t",
slide_footer=None,
zones_data=zones,
layout_preset="single",
layout_css=_layout_css(),
gap_px=14,
)
# ─── Invariant 1 — no details on no-popup zone ───────────────────────
def _body_section(html: str) -> str:
"""Return the HTML between </style> and </body> so assertions can
target the rendered body content without false positives on the
in-template CSS block (which legitimately declares the popup CSS
classes regardless of whether any zone emits a popup)."""
end_of_style = html.index("</style>") + len("</style>")
return html[end_of_style:]
def test_zone_without_popup_does_not_render_details_element():
"""has_popup=False → no ``<details class="zone__popup-details">``
element emitted. The CSS class declarations stay in <style> (CSS
contract lives once in the template); what MUST NOT appear is the
element instance in the body."""
body = _body_section(_render([_no_popup_zone()]))
assert "<details" not in body
assert "zone__popup-details" not in body
assert "zone__popup-summary" not in body
assert "zone__popup-body" not in body
assert "data-has-popup" not in body
def test_zone_without_popup_keeps_existing_zone_attrs():
"""No regression on the zone div for non-popup zones — the
data-zone-position + data-template-id contract from pre-u8 stays
intact."""
html = _render([_no_popup_zone()])
assert 'data-zone-position="primary"' in html
assert 'data-template-id="__empty__"' in html
# ─── Invariant 2 — exactly one details on popup zone ────────────────
def test_zone_with_popup_renders_details_summary_body_triple():
"""has_popup=True → exactly one ``<details class="zone__popup-details
...">`` per zone with a ``<summary class="zone__popup-summary">``
trigger AND a ``<div class="zone__popup-body">`` body."""
html = _render([_popup_zone()])
details_matches = re.findall(
r'<details class="zone__popup-details[^"]*"', html
)
assert len(details_matches) == 1
assert 'class="zone__popup-summary"' in html
assert 'class="zone__popup-body"' in html
def test_zone_with_popup_marks_zone_div_with_data_has_popup_attr():
"""The zone div carries ``data-has-popup="1"`` exactly when
has_popup=True (downstream observability anchor)."""
html = _render([_popup_zone()])
assert 'data-has-popup="1"' in html
def test_zone_without_popup_does_not_carry_data_has_popup_attr():
"""has_popup=False zone div MUST NOT carry the data-has-popup
attribute (otherwise the observability anchor lies)."""
html = _render([_no_popup_zone()])
assert "data-has-popup" not in html
# ─── Invariant 3 — escaping (XSS safety + literal preservation) ──────
def test_popup_body_html_special_chars_are_escaped():
"""popup_html is plain MDX text. A literal ``<script>`` in
raw_content MUST appear escaped (Jinja2 autoescape ON), never as an
executable tag. Locks XSS guard + MDX-as-text contract."""
payload = "<script>alert(1)</script>"
html = _render([_popup_zone(popup_html=payload)])
# Raw <script> tag MUST NOT appear inside popup body.
assert "<script>alert(1)</script>" not in html
# Escaped form MUST appear (& -> &amp; lt -> &lt;).
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in html
def test_popup_body_ampersand_and_quotes_are_escaped():
"""Literal ``&`` ``<`` ``>`` ``"`` ``'`` in popup_html are
autoescaped — round-trip safe through the HTML body."""
payload = "A & B < C > D \" E ' F"
html = _render([_popup_zone(popup_html=payload)])
assert "&amp;" in html
assert "&lt;" in html
assert "&gt;" in html
# Raw form of the un-escaped ampersand sequence must not appear.
assert "A & B < C > D" not in html
# ─── Invariant 4 — whitespace preservation contract ──────────────────
def test_popup_body_preserves_newlines_in_content_verbatim():
"""popup_html with newlines is emitted verbatim into the body —
no collapse, no trim. Visual newline preservation is the CSS
contract (.zone__popup-body { white-space: pre-wrap }) but the
underlying text MUST carry the newlines through to the HTML."""
payload = "line one\nline two\nline three"
html = _render([_popup_zone(popup_html=payload)])
# The exact body text appears between the body div tags.
body_match = re.search(
r'<div class="zone__popup-body">(.*?)</div>',
html,
re.DOTALL,
)
assert body_match is not None
assert body_match.group(1) == payload
def test_popup_body_css_class_declares_whitespace_pre_wrap():
"""The CSS contract that makes the preserved newlines actually
visible is ``.zone__popup-body { white-space: pre-wrap }`` in
slide_base.html. Locks the styling axis — without this rule the
preserved newlines collapse in render."""
html = _render([_popup_zone()])
# Compress whitespace before regex match (CSS block formatting
# may vary across edits).
flat = re.sub(r"\s+", " ", html)
assert ".zone__popup-body" in flat
assert "white-space: pre-wrap" in flat
def test_popup_body_holds_full_raw_content_verbatim():
"""popup_html (FULL raw_content from u7 / u6) appears in the body
char-for-char (modulo HTML escape on special chars). No trim, no
summary substitution — MDX 원문 무손실 보존 (오답노트 #5)."""
payload = (
"## MOCK_SECTION_TITLE\n\n"
"- bullet 1\n"
"- bullet 2\n"
"- bullet 3 with **bold**\n"
)
html = _render([_popup_zone(popup_html=payload)])
# Extract the popup body content.
body_match = re.search(
r'<div class="zone__popup-body">(.*?)</div>',
html,
re.DOTALL,
)
assert body_match is not None
# ** stays as ** (autoescape only touches HTML special chars).
assert body_match.group(1) == payload
# ─── Invariant 5 — placement / label / strategy from binding ─────────
def test_popup_placement_class_modifier_reflects_binding_placement():
"""The placement (top-right / top-left / bottom-right / bottom-left)
is READ from zone.popup_binding.detail_trigger.placement and
surfaces as the BEM modifier on the details element."""
for placement in ("top-right", "top-left", "bottom-right", "bottom-left"):
zone = _popup_zone(binding=_popup_binding(placement=placement))
html = _render([zone])
assert f"zone__popup-details--{placement}" in html
assert f'data-popup-placement="{placement}"' in html
def test_popup_summary_label_reflects_binding_label():
"""The summary trigger text is READ from
zone.popup_binding.detail_trigger.label — no hardcoded literal in
the template (catalog drift guard)."""
zone = _popup_zone(binding=_popup_binding(label="자세히"))
html = _render([zone])
assert ">자세히</summary>" in html
def test_popup_data_display_strategy_attr_reflects_binding_strategy_id():
"""The details element carries data-display-strategy=<strategy_id>
from the binding so downstream observability (DOM scrape, test
introspection) can identify which catalog strategy fired."""
zone = _popup_zone(binding=_popup_binding(strategy="details_only"))
html = _render([zone])
assert 'data-display-strategy="details_only"' in html
# ─── Invariant 6 — defensive defaults (binding=None / missing keys) ──
def test_popup_zone_with_binding_none_uses_defensive_defaults():
"""The unrenderable empty-plan branch of the pipeline composer
stamps popup_binding=None (u7 wiring). u8 MUST render sane defaults
rather than KeyError/AttributeError on the Jinja2 path: placement =
top-right, label = 'details', strategy =
inline_preview_with_details."""
zone = _popup_zone(binding=None)
html = _render([zone])
assert "zone__popup-details--top-right" in html
assert ">details</summary>" in html
assert 'data-display-strategy="inline_preview_with_details"' in html
def test_popup_zone_with_partial_binding_falls_back_per_missing_key():
"""A binding dict missing detail_trigger (defensive — should not
happen in normal u6 output, but the template MUST be robust) falls
back to the same defaults as binding=None."""
partial_binding = {
"display_strategy": "inline_preview_with_details",
# detail_trigger intentionally omitted.
}
zone = _popup_zone(binding=partial_binding)
html = _render([zone])
assert "zone__popup-details--top-right" in html
assert ">details</summary>" in html
# ─── Invariant 7 — multi-zone rendering ─────────────────────────────
def test_only_popup_zones_emit_details_in_multi_zone_slide():
"""Mixed slide: zone A has_popup=False, zone B has_popup=True.
Exactly ONE <details> block in the rendered HTML, on zone B only."""
zone_a = _no_popup_zone(position="left")
zone_b = _popup_zone(position="right")
html = _render([
zone_a,
zone_b,
])
matches = re.findall(r'<details class="zone__popup-details', html)
assert len(matches) == 1
# zone B is the right grid-area — popup details should sit within
# the zone whose div carries data-zone-position="right".
right_zone_block = re.search(
r'<div class="zone" data-zone-position="right"[^>]*>(.*?)</div>\s*</div>',
html,
re.DOTALL,
)
# If the regex above doesn't anchor (template HTML evolves), fall
# back to checking the details element appears AFTER the right
# zone marker but BEFORE the next zone marker.
if right_zone_block is None:
right_idx = html.index('data-zone-position="right"')
assert html.find("zone__popup-details", right_idx) > right_idx
# And the left zone block should NOT contain the popup.
left_end = html.index('data-zone-position="right"')
assert "zone__popup-details" not in html[:left_end]
else:
assert "zone__popup-details" in right_zone_block.group(1)
# ─── Determinism + smoke check ──────────────────────────────────────
def test_popup_render_is_deterministic_across_calls():
"""Two calls with identical input produce byte-identical HTML —
no order-dependence on dict iteration, no time-based identifier."""
zone = _popup_zone(popup_html="MOCK\nMULTI\nLINE")
assert _render([zone]) == _render([zone])
def test_popup_emits_no_javascript_on_render_path():
"""CLAUDE.md 자세히보기 contract — HTML-native ``<details>`` ONLY,
no JavaScript hook on the popup render path (print auto-expand is a
separate OOS axis per IMP-35 scope-lock)."""
html = _render([_popup_zone()])
# The slide_base.html embedded-mode <script> is allowed (separate
# axis). What MUST NOT appear is any popup-specific JS handler.
# Search the popup details block for inline JS attributes.
details_block_match = re.search(
r'<details class="zone__popup-details.*?</details>',
html,
re.DOTALL,
)
assert details_block_match is not None
block = details_block_match.group(0)
for js_attr in ("onclick=", "onload=", "onopen=", "ontoggle="):
assert js_attr not in block
# And no <script> tag inside the details body.
assert "<script" not in block
@@ -0,0 +1,232 @@
"""IMP-86 u4 — integration: mdx03 reject override reaches Step 12 AI audit
without heights_px ValueError; default-path regression.
Locks the Stage 2 guardrails from the IMP-86 issue body. Pre-u1, the mapper
``FitError`` handler appended only to ``adapter_needed_units`` and skipped
``zones_data`` / ``debug_zones`` — leaving them at ``len=1`` while
``build_layout_css("horizontal-2", ...)`` still returned ``R=2``. The
``_compute_per_zone_geometry`` invariant then raised
``ValueError("heights_px length 1 != grid rows R=2")`` BEFORE Step 12
fired, so the AI router was never reached and ``step12_ai_repair.json``
was never written. Post u1+u2 :
* u1 placeholder zone keeps cardinality at R=2;
* u2 pre-build invariant guard surfaces drift before
``_compute_per_zone_geometry`` raises;
* Step 12 AI router is reached and the audit artifact is produced;
* ``debug.json`` carries the adapter_needed marker (existing channel —
u5 will add finer per-record telemetry).
AI router is patched to a deterministic ``None``-returning stub so the
integration coverage proves the wiring without any network / API / model
dependency (``feedback_ai_isolation_contract`` — AI = fallback only).
The pair ``(ai_called=False, skip_reason='router_short_circuit')`` is
the gather-side surface (``src/phase_z2_ai_fallback/step12.py:210-213``)
for ``route_ai_fallback → None`` and is the explicit "router reached and
returned None" signal — distinct from earlier-gate skips
(``not_provisional`` / ``route_not_ai_adaptation:<hint>``) which would
indicate the router was NOT reached.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import src.phase_z2_ai_fallback.step12 as step12_mod
import src.phase_z2_pipeline as pz2
_REPO_ROOT = Path(__file__).resolve().parents[2]
_SAMPLE_MDX_PATH = _REPO_ROOT / "samples" / "mdx_batch" / "03.mdx"
def _stub_router_short_circuit() -> MagicMock:
"""AI-router stand-in returning ``None``.
Gather records ``(ai_called=False, skip_reason='router_short_circuit')``
per ``src/phase_z2_ai_fallback/step12.py:210-213`` when the router
returns ``None``. Asserting that pair on the reject record proves the
router was actually invoked — vs. the earlier ``not_provisional`` or
``route_not_ai_adaptation:<hint>`` skips which would indicate the
router was NEVER reached (the IMP-86 pre-fix failure mode, which
manifested as a ``ValueError`` crash before Step 12 even fired).
"""
return MagicMock(return_value=None)
@pytest.mark.integration
def test_integration_reject_override_reaches_step12_without_value_error(
tmp_path, monkeypatch
):
"""mdx03 + ``--override-frame 03-2=bim_dx_comparison_table`` must reach
Step 12 AI router without raising ``heights_px`` ValueError, and the
Step 12 audit artifact must reflect router reach on the provisional
reject unit.
"""
if not _SAMPLE_MDX_PATH.is_file():
pytest.skip(f"sample MDX not present: {_SAMPLE_MDX_PATH}")
monkeypatch.setattr(pz2, "RUNS_DIR", tmp_path / "runs")
router = _stub_router_short_circuit()
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
run_id = "imp86_u4_reject_override_integration"
pz2.run_phase_z2_mvp1(
_SAMPLE_MDX_PATH,
run_id=run_id,
override_frames={"03-2": "bim_dx_comparison_table"},
)
run_dir = tmp_path / "runs" / run_id / "phase_z2"
# Guardrail 1 — step12_ai_repair.json present. Pre-IMP-86, the
# pipeline crashed at _compute_per_zone_geometry before Step 12 fired
# so this artifact was MISSING under reject override (issue body
# primary symptom: "AI 호출 0 / step12_ai_repair.json 미생성").
step12_path = run_dir / "steps" / "step12_ai_repair.json"
assert step12_path.is_file(), (
f"step12_ai_repair.json missing at {step12_path} — pre-IMP-86 "
"pipeline crashed at _compute_per_zone_geometry ValueError "
"before Step 12 fired."
)
step12_data = json.loads(step12_path.read_text(encoding="utf-8"))
per_unit = step12_data["data"]["per_unit"]
assert len(per_unit) >= 1, "step12 per_unit must contain at least one record"
# Guardrail 2 — provisional reject unit reached the AI router.
# _apply_frame_override_to_unit (src/phase_z2_pipeline.py:1199-1208)
# promotes the unit to provisional + label='reject' when the override
# target matches a V4 reject judgment; the IMP-47B u1 route map then
# sends reject → 'ai_adaptation_required'.
reject_records = [
r for r in per_unit if r.get("source_section_ids") == ["03-2"]
]
assert len(reject_records) == 1, (
f"expected exactly one per_unit record for 03-2; got {reject_records}"
)
reject = reject_records[0]
assert reject["provisional"] is True, (
"03-2 unit must be provisional after reject override per "
"_apply_frame_override_to_unit reject-judgment promotion"
)
assert reject["route_hint"] == "ai_adaptation_required", (
f"03-2 route_hint must be 'ai_adaptation_required' "
f"(IMP-47B u1 reject→AI map); got {reject['route_hint']}"
)
# The stub router returned None → gather records router_short_circuit.
# This pair is the explicit "router reached" surface; the alternative
# skips (not_provisional / route_not_ai_adaptation:*) would mean the
# router was NEVER called — the IMP-86 pre-fix failure mode.
assert reject["skip_reason"] == "router_short_circuit", (
f"03-2 must reach the (stubbed) AI router and record "
f"router_short_circuit; got skip_reason={reject['skip_reason']}, "
f"error={reject.get('error')}"
)
assert reject["ai_called"] is False
assert reject["error"] is None
router.assert_called()
# Guardrail 3 — pipeline reached Step 20 (no heights_px ValueError).
# The pre-IMP-86 crash happened BEFORE the Step 7 layout artifact, so
# the Step 20 final-status artifact is the strongest "pipeline did
# not crash mid-flight" signal.
step20_path = run_dir / "steps" / "step20_slide_status.json"
assert step20_path.is_file(), (
f"step20_slide_status.json missing at {step20_path} — pipeline "
"did not reach final step (likely crashed mid-pipeline)."
)
# Guardrail 4 — debug.json adapter_needed marker. u1 placeholder
# writes a parallel zones_data + debug_zone record, but
# adapter_needed_units stays the authoritative adapter signal for
# debug.json consumers. u5 adds finer per-placeholder telemetry
# (adapter_needed=True / mapper_fit_error) on the placeholder records
# themselves (asserted in Guardrail 5 below).
# write_debug_json (src/phase_z2_pipeline.py:3254-3292) nests the
# slide_status payload from compute_slide_status (src/phase_z2_pipeline.py:2939-3128)
# under the top-level "slide_status" key, so adapter_needed_count and
# adapter_needed_units are addressed via debug["slide_status"][...]
# — not at the top level.
debug_path = run_dir / "debug.json"
assert debug_path.is_file(), f"debug.json missing at {debug_path}"
debug = json.loads(debug_path.read_text(encoding="utf-8"))
slide_status = debug.get("slide_status") or {}
assert slide_status.get("adapter_needed_count", 0) >= 1, (
f"debug.json slide_status.adapter_needed_count must reflect the "
f"FitError on 03-2; got {slide_status.get('adapter_needed_count')}"
)
adapter_units = slide_status.get("adapter_needed_units") or []
assert any(
"03-2" in (a.get("source_section_ids") or []) for a in adapter_units
), (
f"slide_status.adapter_needed_units must include the 03-2 reject "
f"override unit; got {adapter_units}"
)
# Guardrail 5 (IMP-86 u5) — per-placeholder telemetry on debug.json
# zones[] entry. Source: src/phase_z2_pipeline.py FitError handler
# appends adapter_needed=True + mapper_fit_error=<str(e)> + provisional
# on the placeholder debug_zone record so consumers reading the zones
# array alone (without joining against slide_status) can identify the
# adapter contract surface. adapter_needed_units stays the
# authoritative per-slide list (preserved by Guardrail 4 above).
debug_zones = debug.get("zones") or []
reject_zones = [
z for z in debug_zones if list(z.get("source_section_ids") or []) == ["03-2"]
]
assert len(reject_zones) == 1, (
f"expected exactly one debug zones[] record for 03-2 (placeholder "
f"from FitError handler); got {reject_zones}"
)
reject_zone = reject_zones[0]
assert reject_zone.get("adapter_needed") is True, (
f"debug zones[] placeholder for 03-2 must set adapter_needed=True; "
f"got {reject_zone.get('adapter_needed')!r}"
)
fit_err_msg = reject_zone.get("mapper_fit_error")
assert isinstance(fit_err_msg, str) and fit_err_msg, (
f"debug zones[] placeholder for 03-2 must carry a non-empty "
f"mapper_fit_error string (FitError raised by map_mdx_to_slots); "
f"got {fit_err_msg!r}"
)
assert reject_zone.get("provisional") is True, (
f"debug zones[] placeholder for 03-2 must mirror the unit "
f"provisional state (reject override → provisional=True); got "
f"{reject_zone.get('provisional')!r}"
)
@pytest.mark.integration
def test_integration_default_path_no_override_no_regression(
tmp_path, monkeypatch
):
"""mdx03 default path (no override) must still run end-to-end. The u1
placeholder + u2 invariant guard must be no-ops when the mapper
succeeds for every unit (default mdx03 path; the FitError branch is
not entered).
"""
if not _SAMPLE_MDX_PATH.is_file():
pytest.skip(f"sample MDX not present: {_SAMPLE_MDX_PATH}")
monkeypatch.setattr(pz2, "RUNS_DIR", tmp_path / "runs")
monkeypatch.setattr(
step12_mod, "route_ai_fallback", _stub_router_short_circuit()
)
run_id = "imp86_u4_default_path_regression"
pz2.run_phase_z2_mvp1(_SAMPLE_MDX_PATH, run_id=run_id)
run_dir = tmp_path / "runs" / run_id / "phase_z2"
# Pre-Step 12 crash would surface as both artifacts missing; default
# path must produce both.
assert (run_dir / "steps" / "step12_ai_repair.json").is_file(), (
"step12_ai_repair.json missing on default mdx03 path — u1/u2 "
"must be no-ops when mapper succeeds for every unit."
)
assert (run_dir / "steps" / "step20_slide_status.json").is_file(), (
"step20_slide_status.json missing on default mdx03 path — "
"pipeline did not complete."
)
+87 -6
View File
@@ -86,7 +86,7 @@ def test_router_returns_none_when_route_not_ai_adaptation(monkeypatch):
def test_router_returns_cached_when_cache_hit(monkeypatch):
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
cached = _make_proposal()
monkeypatch.setattr(router_mod, "read_proposal", lambda key: cached)
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: cached)
client = MagicMock(spec=AiFallbackClient)
result = route_ai_fallback(**_call_kwargs(), client=client)
assert result is cached
@@ -99,16 +99,97 @@ def test_router_validates_cached_proposal(monkeypatch):
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
payload={"unknown_key": "x"},
)
monkeypatch.setattr(router_mod, "read_proposal", lambda key: bad_cached)
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: bad_cached)
client = MagicMock(spec=AiFallbackClient)
with pytest.raises(AiFallbackValidationError):
route_ai_fallback(**_call_kwargs(), client=client)
client.request_proposal.assert_not_called()
def test_router_forwards_fingerprints_and_misses_on_mismatch(monkeypatch):
"""Scope: router-level (IMP-46 #62 Axis R u2).
When caller supplies ``fingerprints`` and the cache layer returns
``None`` (simulating a strict-equality mismatch against the stored
contract/partial/catalog SHA), the router proceeds to call the
client. The exact ``fingerprints`` dict supplied by the caller must
appear in the kwargs passed to ``read_proposal``.
"""
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
captured: dict = {}
def _spy_read_proposal(key, *, fingerprints=None):
captured["key"] = key
captured["fingerprints"] = fingerprints
return None
monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal)
proposal = _make_proposal()
client = MagicMock(spec=AiFallbackClient)
client.request_proposal.return_value = proposal
supplied = {"contract_sha": "aaa", "partial_sha": "bbb", "catalog_sha": "ccc"}
result = route_ai_fallback(
**_call_kwargs(), client=client, fingerprints=supplied
)
assert result is proposal
assert captured["fingerprints"] == supplied
client.request_proposal.assert_called_once()
def test_router_forwards_fingerprints_and_hits_on_match(monkeypatch):
"""Scope: router-level (IMP-46 #62 Axis R u2).
When caller supplies ``fingerprints`` and the cache layer returns a
cached proposal (simulating strict-equality match against stored
SHA bundle), the router short-circuits without calling the client.
The forwarded kwarg must equal the caller-supplied dict exactly.
"""
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
cached = _make_proposal()
captured: dict = {}
def _spy_read_proposal(key, *, fingerprints=None):
captured["fingerprints"] = fingerprints
return cached
monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal)
client = MagicMock(spec=AiFallbackClient)
supplied = {"contract_sha": "xxx", "partial_sha": "yyy", "catalog_sha": "zzz"}
result = route_ai_fallback(
**_call_kwargs(), client=client, fingerprints=supplied
)
assert result is cached
assert captured["fingerprints"] == supplied
client.request_proposal.assert_not_called()
def test_router_forwards_fingerprints_none_for_legacy_callers(monkeypatch):
"""Scope: router-level (IMP-46 #62 Axis R u2).
Legacy callers that omit the ``fingerprints`` kwarg must result in
``read_proposal`` being invoked with ``fingerprints=None`` (cache
layer skips fingerprint comparison — legacy no-invalidation
behaviour preserved).
"""
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
cached = _make_proposal()
captured: dict = {}
def _spy_read_proposal(key, *, fingerprints=None):
captured["fingerprints"] = fingerprints
return cached
monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal)
client = MagicMock(spec=AiFallbackClient)
result = route_ai_fallback(**_call_kwargs(), client=client)
assert result is cached
assert captured["fingerprints"] is None
client.request_proposal.assert_not_called()
def test_router_calls_client_and_returns_validated_proposal(monkeypatch):
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
proposal = _make_proposal()
client = MagicMock(spec=AiFallbackClient)
client.request_proposal.return_value = proposal
@@ -121,7 +202,7 @@ def test_router_calls_client_and_returns_validated_proposal(monkeypatch):
def test_router_propagates_validation_error(monkeypatch):
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
bad = AiFallbackProposal(
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
payload={"unknown_key": "x"},
@@ -134,7 +215,7 @@ def test_router_propagates_validation_error(monkeypatch):
def test_router_propagates_budget_exceeded(monkeypatch):
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
client = MagicMock(spec=AiFallbackClient)
client.request_proposal.side_effect = AiFallbackBudgetExceeded("over")
with pytest.raises(AiFallbackBudgetExceeded):
@@ -143,7 +224,7 @@ def test_router_propagates_budget_exceeded(monkeypatch):
def test_router_propagates_circuit_open(monkeypatch):
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
client = MagicMock(spec=AiFallbackClient)
client.request_proposal.side_effect = AiFallbackCircuitOpen("tripped")
with pytest.raises(AiFallbackCircuitOpen):
+102
View File
@@ -500,3 +500,105 @@ def test_production_non_provisional_reject_skipped_before_route_gate(monkeypatch
assert records[0]["skip_reason"] == "not_provisional"
assert records[0]["ai_called"] is False
router.assert_not_called()
# ---------------------------------------------------------------------------
# IMP-46 u4 — Step 12 ↔ router fingerprints forwarding (integration scope)
# ---------------------------------------------------------------------------
# Locks the producer→consumer wiring added in u3: Step 12 builds the
# fingerprints dict at step12.py:179-185, stamps it onto record["fingerprints"]
# at step12.py:185, and now (post-u3) forwards the SAME object into
# route_ai_fallback via the fingerprints= kwarg at step12.py:203. These tests
# assert end-to-end forwarding (router receives exactly record["fingerprints"]
# for AI-eligible units; router untouched and record["fingerprints"] is None
# for skipped / non-AI records).
def test_router_receives_exactly_record_fingerprints_for_ai_eligible(monkeypatch):
"""Integration scope: route_ai_fallback fingerprints kwarg == record['fingerprints']."""
router = MagicMock(return_value=None)
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
contract = {"frame_id": "fid_123", "payload": {"k": "v"}, "sub_zones": []}
partial = {"deeper": [9, 8, 7], "shallow": "x"}
catalog_value = "c0ffee00" * 8
recs = _call(
[_ai_unit()],
get_contract_fn=lambda _t: contract,
figma_partial_loader=lambda _t: partial,
catalog_sha_loader=lambda: catalog_value,
)
record_fingerprints = recs[0]["fingerprints"]
router.assert_called_once()
forwarded = router.call_args.kwargs["fingerprints"]
# Strict equality: same keys, same SHA values — Step 12 producer is wired
# to the router consumer with no transformation between them.
assert forwarded == record_fingerprints
assert forwarded == {
"contract_sha": hashlib.sha256(
json.dumps(contract, sort_keys=True, ensure_ascii=False).encode("utf-8")
).hexdigest(),
"partial_sha": hashlib.sha256(
json.dumps(partial, sort_keys=True, ensure_ascii=False).encode("utf-8")
).hexdigest(),
"catalog_sha": catalog_value,
}
def test_router_fingerprints_kwarg_is_present_even_with_default_catalog(monkeypatch):
"""Integration scope: fingerprints kwarg is supplied (not omitted) even when catalog_sha defaults to ''."""
router = MagicMock(return_value=None)
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
_call([_ai_unit()])
router.assert_called_once()
# The fingerprints kwarg must be present in the call (not relying on the
# router's default None) — proves step12.py:203 forwards explicitly.
assert "fingerprints" in router.call_args.kwargs
forwarded = router.call_args.kwargs["fingerprints"]
assert isinstance(forwarded, dict)
assert set(forwarded.keys()) == {"contract_sha", "partial_sha", "catalog_sha"}
assert forwarded["catalog_sha"] == "" # default sentinel, still forwarded
def test_router_not_called_and_fingerprints_none_for_non_provisional(monkeypatch):
"""Integration scope: non-provisional unit → router untouched, record['fingerprints'] is None."""
router = MagicMock(return_value=None)
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
recs = _call([FakeUnit(label="restructure", provisional=False)])
router.assert_not_called()
assert recs[0]["ai_called"] is False
assert recs[0]["skip_reason"] == "not_provisional"
assert recs[0]["fingerprints"] is None
assert recs[0]["cache_key"] is None
def test_router_not_called_and_fingerprints_none_for_non_ai_route(monkeypatch):
"""Integration scope: light_edit (non-AI route) → router untouched, record['fingerprints'] is None."""
router = MagicMock(return_value=None)
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
recs = _call([FakeUnit(label="light_edit", provisional=True)])
router.assert_not_called()
assert recs[0]["ai_called"] is False
assert recs[0]["skip_reason"] == (
"route_not_ai_adaptation:deterministic_minor_adjustment"
)
assert recs[0]["fingerprints"] is None
assert recs[0]["cache_key"] is None
def test_mixed_units_router_receives_fingerprints_only_for_ai_eligible(monkeypatch):
"""Integration scope: in a mixed batch, only the AI-eligible unit forwards fingerprints to router."""
router = MagicMock(return_value=None)
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
units = [
FakeUnit(label="restructure", provisional=False), # not_provisional
FakeUnit(label="light_edit", provisional=True), # non-AI route
_ai_unit(), # AI-eligible
]
recs = _call(units)
# Exactly one router invocation — the AI-eligible unit.
router.assert_called_once()
forwarded = router.call_args.kwargs["fingerprints"]
assert forwarded == recs[2]["fingerprints"]
# Skipped records carry None.
assert recs[0]["fingerprints"] is None
assert recs[1]["fingerprints"] is None
+156
View File
@@ -21,8 +21,10 @@ from src.phase_z2_ai_fallback import step17 as step17_mod
from src.phase_z2_ai_fallback.step17 import (
OVERFLOW_CASCADE_ORDER,
STEP17_AI_REPAIR_BLOCKED_REASON,
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON,
OverflowCascadeStage,
gather_step17_ai_repair_proposals,
gather_step17_popup_split_decisions,
)
@@ -163,6 +165,160 @@ def test_gather_with_empty_units_returns_empty_list():
assert records == []
# ─── IMP-35 u4: POPUP cascade AI split-decision contract (API gated) ─────
def test_popup_split_decision_api_gated_reason_constant_value():
"""u4 binding contract — API-gated skip_reason is a stable, machine-readable
constant that downstream consumers can distinguish from the AI_REPAIR
block reason. Never collide with STEP17_AI_REPAIR_BLOCKED_REASON."""
assert (
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON
== "step17_popup_split_decision_api_gated"
)
assert (
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON
!= STEP17_AI_REPAIR_BLOCKED_REASON
)
def test_popup_split_decision_returns_one_record_per_unit():
units = [
FakeUnit(label="restructure", provisional=True),
FakeUnit(label="reject", provisional=False),
FakeUnit(label="use_as_is", provisional=True),
]
records = gather_step17_popup_split_decisions(
units, route_for_label=_route_for_label
)
assert len(records) == 3
def test_popup_split_decision_cascade_stage_is_popup():
"""u4 — cascade_stage must mark these records as the POPUP stage, NOT
AI_REPAIR. This lets consumers multiplex POPUP and AI_REPAIR records on
the same retry trace without ambiguity."""
units = [FakeUnit(label="restructure", provisional=True)]
record = gather_step17_popup_split_decisions(
units, route_for_label=_route_for_label
)[0]
assert record["cascade_stage"] == OverflowCascadeStage.POPUP.value
assert record["cascade_stage"] != OverflowCascadeStage.AI_REPAIR.value
def test_popup_split_decision_api_gated_flag_true():
"""u4 — api_gated=True everywhere. The flag is the primary state signal
consumers read to decide whether the AI hook is active."""
units = [FakeUnit(label="restructure", provisional=True)]
record = gather_step17_popup_split_decisions(
units, route_for_label=_route_for_label
)[0]
assert record["api_gated"] is True
def test_popup_split_decision_ai_called_is_false_and_no_proposal():
"""u4 — ai_called=False, split_decision=None, error=None. The hook is the
contract surface only; the Anthropic API is NOT invoked at u4."""
units = [FakeUnit(label="restructure", provisional=True)]
record = gather_step17_popup_split_decisions(
units, route_for_label=_route_for_label
)[0]
assert record["ai_called"] is False
assert record["split_decision"] is None
assert record["error"] is None
def test_popup_split_decision_skip_reason_is_api_gated():
"""u4 — every record carries the API-gated skip_reason regardless of
label / provisional / route_hint."""
units = [
FakeUnit(label="restructure", provisional=True),
FakeUnit(label="reject", provisional=False),
FakeUnit(label="use_as_is", provisional=True),
FakeUnit(label=None, provisional=False),
]
records = gather_step17_popup_split_decisions(
units, route_for_label=_route_for_label
)
for record in records:
assert (
record["skip_reason"]
== STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON
)
def test_popup_split_decision_honors_route_for_label():
"""u4 — route_for_label callable is applied per unit. Verifies the hook
surface accepts the same labelroute 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 ─
+249
View File
@@ -0,0 +1,249 @@
"""IMP-#85 u3a — Audit CLI invariants I1-I3.
Scope (Stage 2 lock):
I1 partial existence `templates/phase_z2/families/{template_id}.html`
must exist for live (non-VP) contracts.
I2 builder declared live contracts must declare non-empty
`payload.builder`.
I3 builder registered declared builders must be in PAYLOAD_BUILDERS.
`visual_pending: true` skipped for all of I1-I3 (data-driven from catalog,
no hard-coded frame allow-list; matches u2 invariant scope).
Out of scope ( axis):
- I4 slot_payload references (u3b).
- V4 runtime VP filter (u4).
- Implementing the 17 missing VP builders.
"""
from __future__ import annotations
import subprocess
import sys
import textwrap
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_PATH = REPO_ROOT / "scripts" / "audit_frame_invariants.py"
def _write_yaml(path: Path, payload: dict) -> Path:
path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8")
return path
def _run_cli(catalog: Path, partials: Path) -> subprocess.CompletedProcess:
return subprocess.run(
[
sys.executable,
str(SCRIPT_PATH),
"--catalog",
str(catalog),
"--partials-dir",
str(partials),
],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
)
def test_prod_catalog_audit_passes(tmp_path):
"""Prod catalog + prod partials dir → I1-I3 PASS (live contracts clean)."""
from scripts.audit_frame_invariants import (
DEFAULT_CATALOG_PATH,
DEFAULT_PARTIALS_DIR,
run_audit,
)
violations = run_audit(DEFAULT_CATALOG_PATH, DEFAULT_PARTIALS_DIR)
assert violations == [], (
"Prod live contracts (non-VP) must satisfy I1-I3 invariants. "
f"Got: {violations}"
)
def test_i1_partial_missing_for_live_contract(tmp_path):
"""Live contract without families/{template_id}.html → I1 violation."""
from src.phase_z2_mapper import PAYLOAD_BUILDERS
from scripts.audit_frame_invariants import check_i1_partial_existence
sample_builder = next(iter(PAYLOAD_BUILDERS.keys()))
catalog = {
"missing_partial_frame": {
"template_id": "missing_partial_frame",
"payload": {"builder": sample_builder},
},
}
partials_dir = tmp_path / "families"
partials_dir.mkdir()
violations = check_i1_partial_existence(catalog, partials_dir)
assert len(violations) == 1
assert "I1 partial-missing" in violations[0]
assert "missing_partial_frame" in violations[0]
def test_i1_partial_present_no_violation(tmp_path):
"""Live contract with partial on disk → no I1 violation."""
from scripts.audit_frame_invariants import check_i1_partial_existence
catalog = {
"ok_frame": {
"template_id": "ok_frame",
"payload": {"builder": "items_with_role"},
},
}
partials_dir = tmp_path / "families"
partials_dir.mkdir()
(partials_dir / "ok_frame.html").write_text("<div/>", encoding="utf-8")
assert check_i1_partial_existence(catalog, partials_dir) == []
def test_i1_skips_visual_pending(tmp_path):
"""visual_pending: true with no partial → I1 skip (no violation)."""
from scripts.audit_frame_invariants import check_i1_partial_existence
catalog = {
"vp_frame": {
"template_id": "vp_frame",
"visual_pending": True,
"payload": {"builder": "definitely_not_registered"},
},
}
partials_dir = tmp_path / "families"
partials_dir.mkdir()
assert check_i1_partial_existence(catalog, partials_dir) == []
def test_i2_missing_builder_field():
"""Live contract without payload.builder → I2 violation."""
from scripts.audit_frame_invariants import check_i2_builder_declared
catalog = {
"no_builder_frame": {
"template_id": "no_builder_frame",
"payload": {},
},
}
violations = check_i2_builder_declared(catalog)
assert len(violations) == 1
assert "I2 builder-undeclared" in violations[0]
assert "no_builder_frame" in violations[0]
def test_i2_skips_visual_pending():
"""visual_pending: true without builder → I2 skip."""
from scripts.audit_frame_invariants import check_i2_builder_declared
catalog = {
"vp_frame": {
"template_id": "vp_frame",
"visual_pending": True,
"payload": {},
},
}
assert check_i2_builder_declared(catalog) == []
def test_i3_unregistered_builder():
"""Live contract with unknown builder → I3 violation."""
from scripts.audit_frame_invariants import check_i3_builder_registered
catalog = {
"ghost_frame": {
"template_id": "ghost_frame",
"payload": {"builder": "ghost_builder_xyz"},
},
}
violations = check_i3_builder_registered(
catalog, registered_builders={"items_with_role"}
)
assert len(violations) == 1
assert "I3 builder-unregistered" in violations[0]
assert "ghost_frame" in violations[0]
assert "ghost_builder_xyz" in violations[0]
def test_i3_registered_builder_passes():
"""Live contract with registered builder → no I3 violation."""
from scripts.audit_frame_invariants import check_i3_builder_registered
catalog = {
"ok_frame": {
"template_id": "ok_frame",
"payload": {"builder": "items_with_role"},
},
}
assert check_i3_builder_registered(
catalog, registered_builders={"items_with_role"}
) == []
def test_i3_skips_visual_pending():
"""visual_pending: true with unregistered builder → I3 skip."""
from scripts.audit_frame_invariants import check_i3_builder_registered
catalog = {
"vp_frame": {
"template_id": "vp_frame",
"visual_pending": True,
"payload": {"builder": "vp_only_builder"},
},
}
assert check_i3_builder_registered(
catalog, registered_builders={"items_with_role"}
) == []
def test_cli_exit_zero_on_clean_catalog(tmp_path):
"""CLI exit code 0 + PASS line on clean (live) catalog."""
catalog_path = tmp_path / "catalog.yaml"
partials_dir = tmp_path / "families"
partials_dir.mkdir()
(partials_dir / "ok_frame.html").write_text("<div/>", encoding="utf-8")
_write_yaml(
catalog_path,
{
"ok_frame": {
"template_id": "ok_frame",
"payload": {"builder": "items_with_role"},
},
"vp_frame": {
"template_id": "vp_frame",
"visual_pending": True,
"payload": {"builder": "unregistered_xyz"},
},
},
)
result = _run_cli(catalog_path, partials_dir)
assert result.returncode == 0, result.stdout + result.stderr
assert "PASS" in result.stdout
def test_cli_exit_one_on_violations(tmp_path):
"""CLI exit code 1 + aggregated violations listed on drift."""
catalog_path = tmp_path / "catalog.yaml"
partials_dir = tmp_path / "families"
partials_dir.mkdir()
_write_yaml(
catalog_path,
{
"frame_a": {
"template_id": "frame_a",
"payload": {"builder": "ghost_builder"},
},
"frame_b": {
"template_id": "frame_b",
"payload": {},
},
},
)
result = _run_cli(catalog_path, partials_dir)
assert result.returncode == 1, result.stdout + result.stderr
assert "FAIL" in result.stdout
assert "frame_a" in result.stdout
assert "frame_b" in result.stdout
assert "I1" in result.stdout
assert "I2" in result.stdout or "I3" in result.stdout
+444
View File
@@ -0,0 +1,444 @@
"""IMP-#85 u3b — Audit CLI invariant I4 (slot_payload ↔ builder generated keys).
Scope (Stage 2 lock):
I4 slot_payload refs every key generated by the contract's builder must
appear as a `slot_payload.<key>` reference in the
partial. Direction A only (dead generated key).
Skipped when the partial uses dynamic bracket
access (`slot_payload[...]`).
`visual_pending: true` skipped (data-driven from catalog, matches u2/u3a
invariant scope; no hard-coded frame allow-list).
Out of scope ( axis):
- V4 runtime VP filter (u4).
- Catalog regression coverage suite (u5).
- Implementing the 17 missing VP builders.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_PATH = REPO_ROOT / "scripts" / "audit_frame_invariants.py"
def _write_yaml(path: Path, payload: dict) -> Path:
path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8")
return path
def _run_cli(catalog: Path, partials: Path) -> subprocess.CompletedProcess:
return subprocess.run(
[
sys.executable,
str(SCRIPT_PATH),
"--catalog",
str(catalog),
"--partials-dir",
str(partials),
],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
)
def test_prod_catalog_audit_passes_i4():
"""Prod catalog + prod partials dir → no I4 violations on live contracts."""
from scripts.audit_frame_invariants import (
DEFAULT_CATALOG_PATH,
DEFAULT_PARTIALS_DIR,
check_i4_slot_payload_refs,
)
from src.phase_z2_mapper import PAYLOAD_BUILDERS
catalog = yaml.safe_load(
DEFAULT_CATALOG_PATH.read_text(encoding="utf-8")
) or {}
registered = set(PAYLOAD_BUILDERS.keys())
violations = check_i4_slot_payload_refs(
catalog, DEFAULT_PARTIALS_DIR, registered
)
assert violations == [], (
"Prod live contracts must satisfy I4 (every generated key is "
"referenced by the partial, or partial uses dynamic access). "
f"Got: {violations}"
)
def test_extract_static_slot_refs_finds_dot_access():
from scripts.audit_frame_invariants import extract_static_slot_refs
partial = (
"{{ slot_payload.title }}\n"
"{% if slot_payload.foo %}<b>{{ slot_payload.foo }}</b>{% endif %}\n"
"{% for x in slot_payload.bar %}{{ x }}{% endfor %}\n"
)
refs = extract_static_slot_refs(partial)
assert refs == {"title", "foo", "bar"}
def test_extract_static_slot_refs_ignores_dynamic_bracket():
from scripts.audit_frame_invariants import extract_static_slot_refs
partial = "{{ slot_payload['pill_' ~ n ~ '_label'] }}"
# Dynamic access does NOT contribute dot-access refs.
assert extract_static_slot_refs(partial) == set()
def test_partial_uses_dynamic_slot_access_detects_bracket():
from scripts.audit_frame_invariants import partial_uses_dynamic_slot_access
dynamic = "{{ slot_payload['pill_' ~ n ~ '_label'] }}"
static = "{{ slot_payload.title }} and {{ slot_payload.body }}"
assert partial_uses_dynamic_slot_access(dynamic) is True
assert partial_uses_dynamic_slot_access(static) is False
def test_expected_keys_quadrant_flat_slots_default_pattern():
from scripts.audit_frame_invariants import expected_payload_keys
contract = {
"payload": {
"title": {"source": "section.title"},
"builder": "quadrant_flat_slots",
"builder_options": {
"item_parser": "quadrant_item",
"pad_to": 4,
"label_key_pattern": "quadrant_{n}_label",
"body_key_pattern": "quadrant_{n}_body",
},
}
}
keys = expected_payload_keys(contract)
assert "title" in keys
for n in range(1, 5):
assert f"quadrant_{n}_label" in keys
assert f"quadrant_{n}_body" in keys
def test_expected_keys_quadrant_flat_slots_custom_pattern():
from scripts.audit_frame_invariants import expected_payload_keys
contract = {
"payload": {
"title": {"source": "section.title"},
"builder": "quadrant_flat_slots",
"builder_options": {
"item_parser": "quadrant_item",
"pad_to": 3,
"label_key_pattern": "category_{n}_label",
"body_key_pattern": "category_{n}_body",
},
}
}
keys = expected_payload_keys(contract)
assert keys == {
"title",
"category_1_label", "category_2_label", "category_3_label",
"category_1_body", "category_2_body", "category_3_body",
}
def test_expected_keys_cycle_intersect_3():
from scripts.audit_frame_invariants import expected_payload_keys
contract = {
"payload": {
"title": {"source": "section.title"},
"builder": "cycle_intersect_3",
"builder_options": {
"item_parser": "quadrant_item",
"pad_to": 3,
"label_key_pattern": "circle_{n}_label",
},
}
}
keys = expected_payload_keys(contract)
assert keys == {
"title", "circle_1_label", "circle_2_label", "circle_3_label",
"intersection",
}
def test_expected_keys_compare_table_2col():
from scripts.audit_frame_invariants import expected_payload_keys
contract = {
"payload": {
"title": {"source": "section.title"},
"builder": "compare_table_2col",
"builder_options": {"item_parser": "compare_row_2col_item"},
}
}
keys = expected_payload_keys(contract)
assert keys == {"title", "col_a_label", "col_b_label", "rows"}
def test_expected_keys_paired_rows_4x2_slots():
from scripts.audit_frame_invariants import expected_payload_keys
contract = {
"payload": {
"title": {"source": "section.title"},
"builder": "paired_rows_4x2_slots",
"builder_options": {
"item_parser": "quadrant_item",
"label_key_pattern": "row_{r}_{side}_label",
"body_key_pattern": "row_{r}_{side}_body",
"rows": 4,
"sides": ["left", "right"],
},
}
}
keys = expected_payload_keys(contract)
assert "title" in keys
for r in range(1, 5):
for side in ("left", "right"):
assert f"row_{r}_{side}_label" in keys
assert f"row_{r}_{side}_body" in keys
def test_expected_keys_process_product_pair():
from scripts.audit_frame_invariants import expected_payload_keys
contract = {
"payload": {
"title": {"source": "section.title"},
"builder": "process_product_pair",
"builder_options": {
"pad_sections_to": 3,
"columns": [
{"title_to": "banner_left", "body_to": "process",
"body_parser": "column_with_transform"},
{"title_to": "banner_right", "body_to": "product",
"body_parser": "column_with_transform"},
],
},
}
}
keys = expected_payload_keys(contract)
assert keys == {"title", "banner_left", "process", "banner_right", "product"}
def test_expected_keys_items_with_role():
from scripts.audit_frame_invariants import expected_payload_keys
contract = {
"payload": {
"title": {"source": "section.title"},
"builder": "items_with_role",
"builder_options": {
"item_parser": "pillar_item",
"array_root": "pillars",
},
}
}
keys = expected_payload_keys(contract)
assert keys == {"title", "pillars"}
def test_i4_dead_generated_key_flagged(tmp_path):
"""Builder produces key X, partial doesn't reference it → I4 violation."""
from scripts.audit_frame_invariants import check_i4_slot_payload_refs
partials_dir = tmp_path / "families"
partials_dir.mkdir()
# Partial only references `title` — missing category_2_label / _body etc.
(partials_dir / "drift_frame.html").write_text(
"<div>{{ slot_payload.title }}</div>"
"<div>{{ slot_payload.category_1_label }}</div>"
"<div>{{ slot_payload.category_1_body }}</div>",
encoding="utf-8",
)
catalog = {
"drift_frame": {
"template_id": "drift_frame",
"payload": {
"title": {"source": "section.title"},
"builder": "quadrant_flat_slots",
"builder_options": {
"item_parser": "quadrant_item",
"pad_to": 2,
"label_key_pattern": "category_{n}_label",
"body_key_pattern": "category_{n}_body",
},
},
},
}
violations = check_i4_slot_payload_refs(
catalog, partials_dir, registered_builders={"quadrant_flat_slots"}
)
msgs = "\n".join(violations)
assert "I4 generated-key-orphan" in msgs
assert "drift_frame" in msgs
assert "category_2_label" in msgs
assert "category_2_body" in msgs
# category_1 keys ARE referenced — must NOT be flagged.
assert "slot_payload.category_1_label." not in msgs
assert "slot_payload.category_1_body." not in msgs
def test_i4_skips_partial_with_dynamic_bracket_access(tmp_path):
"""Dynamic bracket access in partial → I4 skipped (cannot resolve statically)."""
from scripts.audit_frame_invariants import check_i4_slot_payload_refs
partials_dir = tmp_path / "families"
partials_dir.mkdir()
(partials_dir / "dynamic_frame.html").write_text(
"{{ slot_payload.title }}\n"
"{% for n in range(1, 6) %}"
"{{ slot_payload['pill_' ~ n ~ '_label'] }}"
"{{ slot_payload['pill_' ~ n ~ '_body'] }}"
"{% endfor %}",
encoding="utf-8",
)
catalog = {
"dynamic_frame": {
"template_id": "dynamic_frame",
"payload": {
"title": {"source": "section.title"},
"builder": "quadrant_flat_slots",
"builder_options": {
"item_parser": "quadrant_item",
"pad_to": 5,
"label_key_pattern": "pill_{n}_label",
"body_key_pattern": "pill_{n}_body",
},
},
},
}
violations = check_i4_slot_payload_refs(
catalog, partials_dir, registered_builders={"quadrant_flat_slots"}
)
assert violations == [], (
"Dynamic bracket access must suppress I4 (cannot resolve statically); "
f"got: {violations}"
)
def test_i4_skips_visual_pending(tmp_path):
"""VP contract with drift → I4 skip (no violation)."""
from scripts.audit_frame_invariants import check_i4_slot_payload_refs
partials_dir = tmp_path / "families"
partials_dir.mkdir()
(partials_dir / "vp_frame.html").write_text(
"<div>nothing</div>", encoding="utf-8"
)
catalog = {
"vp_frame": {
"template_id": "vp_frame",
"visual_pending": True,
"payload": {
"title": {"source": "section.title"},
"builder": "quadrant_flat_slots",
"builder_options": {
"item_parser": "quadrant_item", "pad_to": 4,
},
},
},
}
violations = check_i4_slot_payload_refs(
catalog, partials_dir, registered_builders={"quadrant_flat_slots"}
)
assert violations == []
def test_i4_skips_unregistered_builder(tmp_path):
"""Unregistered builder (already an I3 hit) → I4 silent on same contract."""
from scripts.audit_frame_invariants import check_i4_slot_payload_refs
partials_dir = tmp_path / "families"
partials_dir.mkdir()
(partials_dir / "ghost_frame.html").write_text(
"{{ slot_payload.title }}", encoding="utf-8"
)
catalog = {
"ghost_frame": {
"template_id": "ghost_frame",
"payload": {
"title": {"source": "section.title"},
"builder": "ghost_builder_not_in_registry",
},
},
}
violations = check_i4_slot_payload_refs(
catalog, partials_dir, registered_builders={"quadrant_flat_slots"}
)
assert violations == [], (
"Unregistered builder is already flagged by I3 — I4 must stay silent "
f"on the same contract; got: {violations}"
)
def test_i4_skips_missing_partial(tmp_path):
"""Missing partial (already I1 hit) → I4 silent on same contract."""
from scripts.audit_frame_invariants import check_i4_slot_payload_refs
partials_dir = tmp_path / "families"
partials_dir.mkdir()
# No partial file written.
catalog = {
"missing_partial_frame": {
"template_id": "missing_partial_frame",
"payload": {
"title": {"source": "section.title"},
"builder": "quadrant_flat_slots",
"builder_options": {
"item_parser": "quadrant_item", "pad_to": 4,
},
},
},
}
violations = check_i4_slot_payload_refs(
catalog, partials_dir, registered_builders={"quadrant_flat_slots"}
)
assert violations == []
def test_cli_pass_on_prod_paths(tmp_path):
"""End-to-end CLI on prod paths reports PASS with I1-I4 wording."""
result = subprocess.run(
[sys.executable, str(SCRIPT_PATH)],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "PASS (I1-I4 clean" in result.stdout
def test_cli_fail_on_synthetic_i4_drift(tmp_path):
"""CLI exits 1 + emits I4 violation when a non-VP contract has dead keys."""
partials_dir = tmp_path / "families"
partials_dir.mkdir()
(partials_dir / "drift_frame.html").write_text(
"{{ slot_payload.title }}", encoding="utf-8"
)
catalog_path = _write_yaml(
tmp_path / "frame_contracts.yaml",
{
"drift_frame": {
"template_id": "drift_frame",
"payload": {
"title": {"source": "section.title"},
"builder": "quadrant_flat_slots",
"builder_options": {
"item_parser": "quadrant_item", "pad_to": 2,
"label_key_pattern": "category_{n}_label",
"body_key_pattern": "category_{n}_body",
},
},
},
},
)
result = _run_cli(catalog_path, partials_dir)
assert result.returncode == 1, result.stdout + result.stderr
assert "I4 generated-key-orphan" in result.stdout
assert "category_1_label" in result.stdout
+146
View File
@@ -79,3 +79,149 @@ def test_catalog_entry_count_matches_frame_count():
f"catalog shape inconsistent: entries={entry_count} "
f"templates={template_count} frames={frame_count}"
)
# ──────────────────────── IMP-#85 u5 regression coverage ────────────────────────
#
# Scope (Stage 2 lock):
# - Prod catalog passes the audit CLI (run_audit) end-to-end.
# - Non-VP fixture catalogs reproduce the boot invariant (u2) + audit (u3a/u3b)
# negative paths: missing payload.builder, missing partial, undeclared
# slot_payload reference (I4 generated-key-orphan).
# - Same fixtures with `visual_pending: true` MUST be silently skipped — the
# data-driven VP scope guard from u2/u3a/u3b must not regress.
#
# Out of scope:
# - Implementing the 17 missing VP builders (별 P0 / IMP-04b backlog).
# - Visual rendering of fixture frames.
#
# Path-convention note (tests/CLAUDE.md §F-5):
# Stage 2 plan named `tests/fixtures/catalog/` but the project convention
# reserves the root `tests/fixtures/` for non-Phase-Z fixtures (creation
# requires a separate issue). Phase-Z YAML fixtures live under
# `tests/phase_z2/fixtures/`. The u5 fixtures therefore live at
# `tests/phase_z2/fixtures/catalog/`.
import yaml
from scripts.audit_frame_invariants import (
DEFAULT_CATALOG_PATH,
DEFAULT_PARTIALS_DIR,
run_audit,
)
from src import phase_z2_mapper
from src.phase_z2_mapper import (
CatalogInvariantError,
PAYLOAD_BUILDERS,
_check_catalog_builder_invariant,
)
_IMP85_FIXTURES_DIR = Path(__file__).parent / "phase_z2" / "fixtures" / "catalog"
_MISSING_BUILDER_FIXTURE = _IMP85_FIXTURES_DIR / "missing_builder_non_vp.yaml"
_UNDECLARED_SLOT_FIXTURE = _IMP85_FIXTURES_DIR / "undeclared_slot_ref_non_vp.yaml"
def _load_fixture_catalog(path: Path) -> dict:
with path.open(encoding="utf-8") as f:
return yaml.safe_load(f)
@pytest.fixture
def _reset_catalog_cache_for_imp85():
"""Some tests below load fixture YAMLs into the boot invariant; ensure the
prod cache is untouched on entry/exit so other tests stay deterministic."""
phase_z2_mapper._CATALOG_CACHE = None
yield
phase_z2_mapper._CATALOG_CACHE = None
def test_prod_catalog_audit_clean():
"""IMP-#85 u5 — prod catalog + prod partials dir pass audit (I1-I4 clean)."""
violations = run_audit(DEFAULT_CATALOG_PATH, DEFAULT_PARTIALS_DIR)
assert violations == [], (
f"Prod catalog audit reported {len(violations)} violation(s):\n - "
+ "\n - ".join(violations)
)
def test_missing_builder_fixture_raises_catalog_invariant(
_reset_catalog_cache_for_imp85,
):
"""Fixture: non-VP contract with unregistered builder → u2 invariant raise."""
catalog = _load_fixture_catalog(_MISSING_BUILDER_FIXTURE)
with pytest.raises(CatalogInvariantError) as exc:
_check_catalog_builder_invariant(catalog)
msg = str(exc.value)
assert "imp85_u5_missing_builder_frame" in msg
assert "definitely_not_a_registered_builder_imp85_u5" in msg
def test_missing_builder_fixture_audit_reports_i3(tmp_path):
"""Fixture: non-VP contract with unregistered builder → audit I3 + I1.
The fixture frame's template_id has no partial on disk (tmp_path is empty),
so I1 fires as well. I3 is the primary assertion target; I1 surfacing is
expected and asserted to lock both audit paths together.
"""
violations = run_audit(_MISSING_BUILDER_FIXTURE, tmp_path)
joined = "\n".join(violations)
assert any(
v.startswith("I3 builder-unregistered:")
and "imp85_u5_missing_builder_frame" in v
for v in violations
), f"expected I3 builder-unregistered violation, got:\n{joined}"
assert any(
v.startswith("I1 partial-missing:")
and "imp85_u5_missing_builder_frame" in v
for v in violations
), f"expected I1 partial-missing violation, got:\n{joined}"
def test_undeclared_slot_fixture_audit_reports_i4(tmp_path):
"""Fixture: non-VP contract with valid builder but orphan generated key.
`items_with_role` + `array_root: orphan_array_root_imp85_u5` produces
`slot_payload.orphan_array_root_imp85_u5`. The temp partial below contains
`slot_payload.title` only (no bracket access), so I4 must fire on the
orphan array_root key.
"""
partials_dir = tmp_path / "families"
partials_dir.mkdir()
partial = partials_dir / "imp85_u5_undeclared_slot_frame.html"
partial.write_text(
"<div>{{ slot_payload.title }}</div>",
encoding="utf-8",
)
violations = run_audit(_UNDECLARED_SLOT_FIXTURE, partials_dir)
joined = "\n".join(violations)
assert any(
v.startswith("I4 generated-key-orphan:")
and "imp85_u5_undeclared_slot_frame" in v
and "orphan_array_root_imp85_u5" in v
for v in violations
), f"expected I4 generated-key-orphan violation, got:\n{joined}"
def test_fixtures_with_visual_pending_true_are_skipped(
tmp_path, _reset_catalog_cache_for_imp85,
):
"""VP scope guard — flipping `visual_pending: true` on fixture frames must
silence both the boot invariant (u2) and the audit CLI (I1-I4)."""
missing = _load_fixture_catalog(_MISSING_BUILDER_FIXTURE)
undeclared = _load_fixture_catalog(_UNDECLARED_SLOT_FIXTURE)
for entry in (*missing.values(), *undeclared.values()):
entry["visual_pending"] = True
_check_catalog_builder_invariant(missing)
_check_catalog_builder_invariant(undeclared)
vp_yaml = tmp_path / "vp_only.yaml"
vp_yaml.write_text(yaml.safe_dump({**missing, **undeclared}), encoding="utf-8")
partials_dir = tmp_path / "families"
partials_dir.mkdir()
violations = run_audit(vp_yaml, partials_dir)
assert violations == [], (
f"VP frames must be silently skipped, got:\n - "
+ "\n - ".join(violations)
)
+400
View File
@@ -0,0 +1,400 @@
"""IMP-51 (#79) u4 — tests for ``src.image_id_stamper``.
Covers the stamping contract called out in the Stage 2 plan :
1. ``USER_CONTENT_IMAGE_SELECTOR`` constant matches the canonical string
shared with the frontend (u3 typed client, u8 SlideCanvas).
2. ``stable_image_id`` is deterministic across calls and across runs
(same ``src`` same id), and the ordinal suffix only appears for
occurrences > 0.
3. ``stamp_user_content_images`` is a pure no-op when ``sources`` is
empty / all-non-string (forward-compat invariant current Phase Z
final.html has zero user-content imgs).
4. Allowlisted srcs are stamped; non-allowlisted (decorative) srcs are
left byte-for-byte unchanged.
5. Idempotent under re-stamping (the role-attr probe short-circuits).
6. Duplicate srcs in DOM order get an ordinal suffix.
7. Single-quoted ``src`` is recognized.
8. Self-closing XHTML ``<img />`` is preserved.
9. ``<img>`` tags without a ``src`` attribute are skipped (no crash).
All tests are pure-Python no filesystem, no Selenium, no fixtures.
"""
from __future__ import annotations
import pytest
from src.image_id_stamper import (
IMAGE_ID_ATTR,
IMAGE_ROLE_ATTR,
IMAGE_ROLE_VALUE,
USER_CONTENT_IMAGE_SELECTOR,
build_image_overrides_style,
inject_image_overrides_style,
stable_image_id,
stamp_user_content_images,
)
# -- selector contract ------------------------------------------------------
def test_selector_matches_canonical_string():
# MUST stay verbatim in sync with the frontend mirror in
# Front/client/src/services/userOverridesApi.ts and the SlideCanvas
# query target in u8. Drift here breaks the persisted-override loop.
assert USER_CONTENT_IMAGE_SELECTOR == '.slide img[data-image-role="user-content"]'
def test_attribute_constants_match_selector_components():
assert IMAGE_ROLE_ATTR == "data-image-role"
assert IMAGE_ROLE_VALUE == "user-content"
assert IMAGE_ID_ATTR == "data-image-id"
assert IMAGE_ROLE_ATTR in USER_CONTENT_IMAGE_SELECTOR
assert f'"{IMAGE_ROLE_VALUE}"' in USER_CONTENT_IMAGE_SELECTOR
# -- stable_image_id --------------------------------------------------------
def test_stable_image_id_deterministic_same_src():
a = stable_image_id("/uploads/photo.png")
b = stable_image_id("/uploads/photo.png")
assert a == b
assert a.startswith("img-")
# sha1[:10] + "img-" prefix → fixed length.
assert len(a) == len("img-") + 10
def test_stable_image_id_differs_for_different_src():
assert stable_image_id("/a.png") != stable_image_id("/b.png")
def test_stable_image_id_ordinal_zero_has_no_suffix():
assert "-" not in stable_image_id("/x.png", ordinal=0)[4:]
@pytest.mark.parametrize("ordinal", [1, 2, 7])
def test_stable_image_id_ordinal_suffix(ordinal):
base = stable_image_id("/x.png", ordinal=0)
suffixed = stable_image_id("/x.png", ordinal=ordinal)
assert suffixed == f"{base}-{ordinal}"
def test_stable_image_id_rejects_non_string_src():
with pytest.raises(TypeError):
stable_image_id(None) # type: ignore[arg-type]
def test_stable_image_id_rejects_negative_ordinal():
with pytest.raises(ValueError):
stable_image_id("/x.png", ordinal=-1)
# -- stamp_user_content_images : forward-compat no-op -----------------------
def test_stamp_no_sources_is_pure_noop():
html = '<div class="slide"><img src="/decorative.png"></div>'
out, ids = stamp_user_content_images(html, sources=())
assert out == html
assert ids == []
def test_stamp_all_non_string_sources_is_noop():
html = '<img src="/x.png">'
out, ids = stamp_user_content_images(html, sources=[None, 123, ""]) # type: ignore[list-item]
assert out == html
assert ids == []
def test_stamp_empty_html_is_safe():
out, ids = stamp_user_content_images("", sources=["/x.png"])
assert out == ""
assert ids == []
# -- stamp_user_content_images : allowlist semantics ------------------------
def test_stamp_user_content_src_stamps_role_and_id():
html = '<div class="slide"><img src="/u/p.png" alt="photo"></div>'
out, ids = stamp_user_content_images(html, sources=["/u/p.png"])
expected_id = stable_image_id("/u/p.png")
assert ids == [expected_id]
assert IMAGE_ROLE_ATTR in out
assert f'{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"' in out
assert f'{IMAGE_ID_ATTR}="{expected_id}"' in out
# Original attribute (alt) preserved.
assert 'alt="photo"' in out
def test_stamp_decorative_src_left_unchanged():
html = (
'<div class="slide">'
'<img src="/figma/bg.png">'
'<img src="/u/photo.png">'
'</div>'
)
out, ids = stamp_user_content_images(html, sources=["/u/photo.png"])
# decorative img untouched (no data-image-role injected on it)
assert '<img src="/figma/bg.png">' in out
# user-content img stamped
assert ids == [stable_image_id("/u/photo.png")]
# decorative bg.png must not appear with the role attr
assert '/figma/bg.png' in out
decorative_segment = out.split('<img', 1)[1].split('>', 1)[0]
assert IMAGE_ROLE_ATTR not in decorative_segment
def test_stamp_is_idempotent_on_second_invocation():
html = '<div class="slide"><img src="/u/p.png"></div>'
once, ids1 = stamp_user_content_images(html, sources=["/u/p.png"])
twice, ids2 = stamp_user_content_images(once, sources=["/u/p.png"])
assert twice == once
assert ids1 == [stable_image_id("/u/p.png")]
# second invocation finds the role attr already present → no new id
assert ids2 == []
def test_stamp_duplicate_src_gets_ordinal_suffix_in_dom_order():
html = (
'<div class="slide">'
'<img src="/u/dup.png">'
'<img src="/u/dup.png">'
'<img src="/u/dup.png">'
'</div>'
)
_, ids = stamp_user_content_images(html, sources=["/u/dup.png"])
base = stable_image_id("/u/dup.png", ordinal=0)
assert ids == [base, f"{base}-1", f"{base}-2"]
def test_stamp_recognizes_single_quoted_src():
html = "<div class=\"slide\"><img src='/u/p.png'></div>"
out, ids = stamp_user_content_images(html, sources=["/u/p.png"])
assert ids == [stable_image_id("/u/p.png")]
assert IMAGE_ROLE_ATTR in out
def test_stamp_preserves_self_closing_xhtml_form():
html = '<div class="slide"><img src="/u/p.png" /></div>'
out, ids = stamp_user_content_images(html, sources=["/u/p.png"])
assert ids == [stable_image_id("/u/p.png")]
# self-close slash retained
assert "/>" in out
# role injected before existing attrs
assert f'<img {IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"' in out
def test_stamp_img_without_src_is_left_unchanged():
html = '<div class="slide"><img alt="no src"></div>'
out, ids = stamp_user_content_images(html, sources=["/u/p.png"])
assert out == html
assert ids == []
def test_stamp_returned_ids_persist_across_renders():
# Same allowlist + same DOM order → same id sequence on a fresh
# render. This is the invariant that lets user_overrides.json keys
# re-apply on the next pipeline run without re-clicking.
html = '<div class="slide"><img src="/u/a.png"><img src="/u/b.png"></div>'
_, ids_first = stamp_user_content_images(html, sources=["/u/a.png", "/u/b.png"])
_, ids_second = stamp_user_content_images(html, sources=["/u/a.png", "/u/b.png"])
assert ids_first == ids_second
assert ids_first == [stable_image_id("/u/a.png"), stable_image_id("/u/b.png")]
# -- build_image_overrides_style : CSS builder (u7) -----------------------
def test_build_style_empty_overrides_returns_empty_string():
# Forward-compat invariant — None / {} produces "" so the caller
# can short-circuit the <style> injection without DOM mutation.
assert build_image_overrides_style({}, []) == ""
assert build_image_overrides_style({}, ["img-abc"]) == ""
def test_build_style_no_stamped_ids_returns_empty_string():
# Override present but no stamped imgs in the DOM (Q1 = A current
# Phase Z state) — no rules emitted.
out = build_image_overrides_style({"img-abc": {"x": 1, "y": 2, "w": 3, "h": 4}}, [])
assert out == ""
def test_build_style_emits_rule_for_stamped_id_present_in_overrides():
iid = stable_image_id("/u/p.png")
out = build_image_overrides_style(
{iid: {"x": 10, "y": 20, "w": 30.5, "h": 25}},
[iid],
)
assert f'[{IMAGE_ID_ATTR}="{iid}"]' in out
assert f'[{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"]' in out
assert "position: absolute" in out
assert "left: 10" in out and "top: 20" in out
assert "width: 30.5" in out and "height: 25" in out
def test_build_style_drops_overrides_for_unstamped_ids():
# Override exists for an id that was not stamped on this render
# → silently dropped (the SlideCanvas pathway cannot produce
# such keys; persisted-but-stale entries must NOT inject CSS).
iid_stamped = stable_image_id("/u/here.png")
iid_stale = stable_image_id("/u/removed.png")
out = build_image_overrides_style(
{
iid_stamped: {"x": 10, "y": 10, "w": 20, "h": 20},
iid_stale: {"x": 90, "y": 90, "w": 5, "h": 5},
},
[iid_stamped],
)
assert iid_stamped in out
assert iid_stale not in out
def test_build_style_emits_rules_in_stamped_id_order():
# Deterministic CSS output across renders — rules sorted by DOM
# order (stamped_ids), not by dict insertion order.
iid_a = stable_image_id("/u/a.png")
iid_b = stable_image_id("/u/b.png")
out = build_image_overrides_style(
# dict insertion order: b then a
{
iid_b: {"x": 0, "y": 0, "w": 50, "h": 50},
iid_a: {"x": 50, "y": 50, "w": 50, "h": 50},
},
# stamped order: a then b
[iid_a, iid_b],
)
assert out.index(iid_a) < out.index(iid_b)
def test_build_style_drops_malformed_geometry_entries():
iid_valid = stable_image_id("/u/ok.png")
iid_missing_axis = stable_image_id("/u/missing.png")
iid_non_numeric = stable_image_id("/u/bad.png")
iid_non_dict = stable_image_id("/u/list.png")
out = build_image_overrides_style(
{
iid_valid: {"x": 1, "y": 2, "w": 3, "h": 4},
iid_missing_axis: {"x": 1, "y": 2, "w": 3}, # no h
iid_non_numeric: {"x": "abc", "y": 2, "w": 3, "h": 4},
iid_non_dict: [1, 2, 3, 4],
},
[iid_valid, iid_missing_axis, iid_non_numeric, iid_non_dict],
)
assert iid_valid in out
assert iid_missing_axis not in out
assert iid_non_numeric not in out
assert iid_non_dict not in out
def test_build_style_coerces_int_geometry_to_float_rules():
# JSON-loaded int values round-trip through float(...) so the
# emitted CSS uses numeric values acceptable to the browser parser.
iid = stable_image_id("/u/p.png")
out = build_image_overrides_style({iid: {"x": 1, "y": 2, "w": 3, "h": 4}}, [iid])
assert "left: 1.0%" in out
assert "top: 2.0%" in out
assert "width: 3.0%" in out
assert "height: 4.0%" in out
# -- inject_image_overrides_style : <style> block injector (u7) -----------
def test_inject_style_empty_css_returns_html_unchanged():
html = "<html><head></head><body>x</body></html>"
assert inject_image_overrides_style(html, "") == html
def test_inject_style_inserts_before_head_close():
html = "<html><head><title>t</title></head><body>x</body></html>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert "<style>" in out
# injected block sits before </head>, NOT after <body>
head_idx = out.lower().index("</head>")
body_idx = out.lower().index("<body")
style_idx = out.index("<style>")
assert style_idx < head_idx < body_idx
def test_inject_style_case_insensitive_head_close():
html = "<HTML><HEAD></HEAD><BODY>x</BODY></HTML>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert "<style>" in out
# injection before </HEAD>
assert out.index("<style>") < out.upper().index("</HEAD>")
def test_inject_style_falls_back_to_body_open_when_no_head():
html = "<body>x</body>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert "<style>" in out
# injected right after the <body> open tag
body_open_end = out.index(">", out.index("<body")) + 1
assert out[body_open_end:].lstrip().startswith("<!-- IMP-51")
def test_inject_style_falls_back_to_document_start_when_no_head_or_body():
html = "<div>fragment</div>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert out.startswith("<!-- IMP-51 image_overrides start -->")
assert "<style>" in out
assert out.rstrip().endswith("</div>")
def test_inject_style_is_idempotent_on_second_call():
html = "<html><head></head><body>x</body></html>"
css = ".x { color: red; }"
once = inject_image_overrides_style(html, css)
twice = inject_image_overrides_style(once, css)
assert twice == once
# Marker block appears exactly once after both invocations.
assert once.count("<!-- IMP-51 image_overrides start -->") == 1
assert twice.count("<!-- IMP-51 image_overrides start -->") == 1
def test_inject_style_replaces_existing_block_with_new_css():
# Re-injection with different CSS replaces the previous block in
# place — the marker pair is found and its body is swapped out.
html = "<html><head></head><body>x</body></html>"
first = inject_image_overrides_style(html, ".old { color: red; }")
second = inject_image_overrides_style(first, ".new { color: blue; }")
assert ".old" not in second
assert ".new" in second
assert second.count("<!-- IMP-51 image_overrides start -->") == 1
def test_inject_style_wraps_block_with_marker_comments():
html = "<html><head></head><body>x</body></html>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert "<!-- IMP-51 image_overrides start -->" in out
assert "<!-- IMP-51 image_overrides end -->" in out
# Open marker precedes close marker, with the <style> tag between.
s = out.index("<!-- IMP-51 image_overrides start -->")
e = out.index("<!-- IMP-51 image_overrides end -->")
assert s < out.index("<style>") < out.index("</style>") < e
# -- end-to-end stamp → build → inject (u4 + u7 chained) ------------------
def test_stamp_then_build_then_inject_round_trip():
html = (
"<html><head><title>t</title></head>"
'<body><div class="slide"><img src="/u/p.png"></div></body>'
"</html>"
)
stamped_html, ids = stamp_user_content_images(html, sources=["/u/p.png"])
assert ids == [stable_image_id("/u/p.png")]
css = build_image_overrides_style(
{ids[0]: {"x": 12.5, "y": 25, "w": 40, "h": 30}}, ids,
)
out = inject_image_overrides_style(stamped_html, css)
# The stamped attribute is still present on the <img> AND the
# injected rule targets that same id.
assert f'{IMAGE_ID_ATTR}="{ids[0]}"' in out
assert f'[{IMAGE_ID_ATTR}="{ids[0]}"]' in out
assert "left: 12.5%" in out
assert "<style>" in out
@@ -0,0 +1,64 @@
"""IMP-49 #78 — partial Figma provenance regression test.
For `templates/phase_z2/families/dx_sw_necessity_three_perspectives.html`,
extract color literals (hex + rgb/rgba, whitespace-preserving) from the
<style> block, then assert each non-whitelisted literal exists byte-identically
in `figma_to_html_agent/blocks/1171281198/index.html`.
Whitelist composition (per Stage 2 EXIT REPORT, IMP-49 #78):
- Neutrals: #fff, #1a1a1a (page-default text / surface)
- Shared zone-title token: #000, #883700, rgba(50,44,30,0.4)
(F13/F14/F12/F11/F18 zone-title family not sourced from frame 20)
Guardrails:
- rgb()/rgba() literals matched as substrings, so whitespace (single space
after comma) is preserved byte-for-byte against upstream.
- Failure message surfaces the offending literal and the whitelist.
"""
from __future__ import annotations
import re
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent
PARTIAL_PATH = (
PROJECT_ROOT / "templates" / "phase_z2" / "families"
/ "dx_sw_necessity_three_perspectives.html"
)
UPSTREAM_PATH = (
PROJECT_ROOT / "figma_to_html_agent" / "blocks" / "1171281198" / "index.html"
)
COLOR_WHITELIST = frozenset({
"#fff",
"#1a1a1a",
"#000",
"#883700",
"rgba(50,44,30,0.4)",
})
HEX_RE = re.compile(r"#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b")
RGB_RE = re.compile(r"rgba?\([^)]*\)")
STYLE_RE = re.compile(r"<style>(.*?)</style>", re.DOTALL)
def _extract_style_block(html_text: str) -> str:
match = STYLE_RE.search(html_text)
assert match, f"partial {PARTIAL_PATH} must contain a <style> block"
return match.group(1)
def test_partial_color_literals_byte_identical_to_upstream() -> None:
partial_text = PARTIAL_PATH.read_text(encoding="utf-8")
upstream_text = UPSTREAM_PATH.read_text(encoding="utf-8")
css_text = _extract_style_block(partial_text)
literals = HEX_RE.findall(css_text) + RGB_RE.findall(css_text)
assert literals, "partial <style> must contain at least one color literal"
missing = sorted(
{lit for lit in literals if lit not in COLOR_WHITELIST and lit not in upstream_text}
)
assert not missing, (
"Non-whitelisted color literals in partial must be byte-identical to "
f"upstream {UPSTREAM_PATH.relative_to(PROJECT_ROOT)}. "
f"Missing: {missing}. Whitelist: {sorted(COLOR_WHITELIST)}."
)
@@ -0,0 +1,259 @@
"""IMP-#85 u4 — lookup_v4_candidates visual_pending filter regression tests.
Scope (Stage 2 lock):
- ``visual_pending: true`` frames are excluded from the live candidate set
returned by ``lookup_v4_candidates`` (mdx04 hard-crash path closure).
- Filter is data-driven from catalog ``visual_pending`` field (no hard-coded
frame allow-list, per Stage 2 guardrail + ``feedback_no_hardcoding``).
- ``lookup_v4_all_judgments`` raw telemetry MUST remain untouched full 32
judgments (reject + VP inclusive) preserved for frontend Step 7-A axis.
- Existing ``label == "reject"`` filter and ``max_n`` cap behavior unchanged.
Out of scope (other IMP-#85 units / future axes):
- Implementing the 17 missing VP builders ( P0 backlog, IMP-04b / #42).
- VP semantics redefinition / VP frame removal from V4 evidence.
- Adapter pipeline redesign.
Synthetic naming convention (per ``test_phase_z2_v4_fallback.py`` E1 lock):
``MOCK_`` prefix mandatory. ``_a`` / ``_b`` suffixes = enumeration, not
ordering / priority. Rank expressed by ``v4_full_rank``, never by suffix.
"""
from __future__ import annotations
import pytest
from src import phase_z2_pipeline
from src.phase_z2_pipeline import (
_is_visual_pending,
lookup_v4_all_judgments,
lookup_v4_candidates,
)
# ─── Synthetic catalog stub ──────────────────────────────────────
# Maps template_id → contract dict (None means catalog-unregistered).
_MOCK_CATALOG: dict[str, object] = {
"MOCK_template_live_a": {"visual_pending": False},
"MOCK_template_live_b": {"visual_pending": False},
"MOCK_template_live_no_vp": {}, # no visual_pending key at all → treated as live
"MOCK_template_vp_a": {"visual_pending": True},
"MOCK_template_vp_b": {"visual_pending": True},
# MOCK_template_missing_contract intentionally absent (get_contract → None)
}
def _mock_get_contract(template_id: str):
return _MOCK_CATALOG.get(template_id)
@pytest.fixture
def patch_catalog(monkeypatch):
"""Monkeypatch module-level ``get_contract`` so ``_is_visual_pending``
reads from ``_MOCK_CATALOG`` without touching prod ``frame_contracts.yaml``.
"""
monkeypatch.setattr(
"src.phase_z2_pipeline.get_contract", _mock_get_contract
)
def _make_v4(judgments: list[dict], section_id: str = "S1") -> dict:
return {"mdx_sections": {section_id: {"judgments_full32": judgments}}}
def _j(rank: int, template_id: str, frame_id: str, label: str = "use_as_is",
confidence: float = 0.9) -> dict:
return {
"frame_id": frame_id,
"frame_number": rank,
"template_id": template_id,
"confidence": confidence,
"label": label,
"v4_full_rank": rank,
}
# ─── _is_visual_pending helper ──────────────────────────────────
def test_is_visual_pending_true_for_vp_contract(patch_catalog):
"""VP-flagged contract → True."""
assert _is_visual_pending("MOCK_template_vp_a") is True
def test_is_visual_pending_false_for_live_contract(patch_catalog):
"""Live (explicit visual_pending=False) contract → False."""
assert _is_visual_pending("MOCK_template_live_a") is False
def test_is_visual_pending_false_when_key_absent(patch_catalog):
"""Contract without ``visual_pending`` field → False (default = live)."""
assert _is_visual_pending("MOCK_template_live_no_vp") is False
def test_is_visual_pending_false_for_unregistered_contract(patch_catalog):
"""``get_contract`` → None → False (no spurious gating on unknown ids).
Catalog drift (unregistered template_id) is caught by catalog invariant
(u2 boot + u3 audit), not by this runtime helper.
"""
assert _is_visual_pending("MOCK_template_missing_contract") is False
# ─── lookup_v4_candidates VP filter ─────────────────────────────
def test_vp_rank_1_excluded_live_rank_2_promoted(patch_catalog):
"""mdx04 crash-path shape — rank-1 VP frame is skipped, live rank-2 wins.
Mirrors the production 04-2.x case where ``sw_dependency_four_problems``
(VP, builder = ``cards_4_grid`` absent from registry) appeared at high
rank and crashed the mapper. With the u4 filter, the VP candidate is
skipped and a live candidate is returned instead.
"""
v4 = _make_v4([
_j(1, "MOCK_template_vp_a", "MOCK_frame_001", "restructure"),
_j(2, "MOCK_template_live_a", "MOCK_frame_002", "use_as_is"),
])
candidates = lookup_v4_candidates(v4, "S1", max_n=6)
assert [c.template_id for c in candidates] == ["MOCK_template_live_a"]
def test_all_vp_yields_empty_candidates(patch_catalog):
"""All candidates VP → empty list (Step 9 fallback signal).
0-length output remains the documented ``no_non_reject_v4_candidate``
signal for the Step 9 fallback path; VP exclusion preserves this contract.
"""
v4 = _make_v4([
_j(1, "MOCK_template_vp_a", "MOCK_frame_001", "use_as_is"),
_j(2, "MOCK_template_vp_b", "MOCK_frame_002", "light_edit"),
])
candidates = lookup_v4_candidates(v4, "S1", max_n=6)
assert candidates == []
def test_vp_and_reject_both_filtered(patch_catalog):
"""VP and reject co-occur — both filtered; only live non-reject survive."""
v4 = _make_v4([
_j(1, "MOCK_template_vp_a", "MOCK_frame_001", "use_as_is"),
_j(2, "MOCK_template_live_a", "MOCK_frame_002", "reject"),
_j(3, "MOCK_template_live_b", "MOCK_frame_003", "use_as_is"),
])
candidates = lookup_v4_candidates(v4, "S1", max_n=6)
assert [c.template_id for c in candidates] == ["MOCK_template_live_b"]
def test_unregistered_contract_not_filtered_by_vp(patch_catalog):
"""Unregistered template_id (get_contract → None) is NOT VP-filtered.
VP gating only applies when catalog declares ``visual_pending: true``.
Catalog drift (template_id absent from catalog entirely) is a separate
failure mode covered by catalog invariant (u2) and audit (u3a) runtime
VP filter stays silent on that axis.
"""
v4 = _make_v4([
_j(1, "MOCK_template_missing_contract", "MOCK_frame_001", "use_as_is"),
])
candidates = lookup_v4_candidates(v4, "S1", max_n=6)
assert [c.template_id for c in candidates] == ["MOCK_template_missing_contract"]
def test_max_n_applies_after_vp_filter(patch_catalog):
"""``max_n`` caps the live-eligible list after VP and reject filtering.
Three live candidates + ``max_n=2`` first two live frames are returned.
"""
v4 = _make_v4([
_j(1, "MOCK_template_vp_a", "MOCK_frame_001", "use_as_is"),
_j(2, "MOCK_template_live_a", "MOCK_frame_002", "use_as_is"),
_j(3, "MOCK_template_live_b", "MOCK_frame_003", "use_as_is"),
_j(4, "MOCK_template_live_no_vp", "MOCK_frame_004", "use_as_is"),
])
candidates = lookup_v4_candidates(v4, "S1", max_n=2)
assert [c.template_id for c in candidates] == [
"MOCK_template_live_a",
"MOCK_template_live_b",
]
def test_only_live_candidates_pass_unchanged(patch_catalog):
"""No VP / no reject → behavior identical to pre-u4 (regression guard)."""
v4 = _make_v4([
_j(1, "MOCK_template_live_a", "MOCK_frame_001", "use_as_is"),
_j(2, "MOCK_template_live_b", "MOCK_frame_002", "light_edit"),
])
candidates = lookup_v4_candidates(v4, "S1", max_n=6)
assert [c.template_id for c in candidates] == [
"MOCK_template_live_a",
"MOCK_template_live_b",
]
# ─── lookup_v4_all_judgments untouched (Step 7-A axis preservation) ───
def test_all_judgments_includes_vp_frames(patch_catalog):
"""Raw 32-judgment telemetry MUST include VP frames (not gated).
Stage 2 explicit guardrail frontend Step 7-A axis needs full 32-frame
PNG evidence including VP scaffolding. The u4 filter applies ONLY to the
live candidate path, not the raw judgments path.
"""
v4 = _make_v4([
_j(1, "MOCK_template_vp_a", "MOCK_frame_001", "restructure"),
_j(2, "MOCK_template_live_a", "MOCK_frame_002", "use_as_is"),
_j(3, "MOCK_template_vp_b", "MOCK_frame_003", "light_edit"),
])
all_judgments = lookup_v4_all_judgments(v4, "S1")
assert [j.template_id for j in all_judgments] == [
"MOCK_template_vp_a",
"MOCK_template_live_a",
"MOCK_template_vp_b",
]
def test_all_judgments_includes_reject_and_vp(patch_catalog):
"""Raw judgments preserves BOTH reject AND VP — confirms u4 narrowed scope."""
v4 = _make_v4([
_j(1, "MOCK_template_vp_a", "MOCK_frame_001", "restructure"),
_j(2, "MOCK_template_live_a", "MOCK_frame_002", "reject"),
_j(3, "MOCK_template_live_b", "MOCK_frame_003", "use_as_is"),
])
all_judgments = lookup_v4_all_judgments(v4, "S1")
candidates = lookup_v4_candidates(v4, "S1", max_n=6)
# raw telemetry: 3 (all preserved)
assert len(all_judgments) == 3
# live candidates: 1 (vp + reject filtered)
assert [c.template_id for c in candidates] == ["MOCK_template_live_b"]
# ─── Empty section / missing v4 ─────────────────────────────────
def test_empty_judgments_returns_empty(patch_catalog):
"""No judgments → empty list (unchanged from pre-u4)."""
v4 = _make_v4([])
assert lookup_v4_candidates(v4, "S1", max_n=6) == []
def test_unknown_section_returns_empty(patch_catalog):
"""Section_id not in V4 → empty list (unchanged from pre-u4)."""
v4 = _make_v4([_j(1, "MOCK_template_live_a", "MOCK_frame_001")])
assert lookup_v4_candidates(v4, "SECTION_NOT_PRESENT", max_n=6) == []
+129
View File
@@ -0,0 +1,129 @@
"""IMP-#85 u6 — mdx04 VP routing regression against the real V4 evidence.
Scope (Stage 2 lock):
- Use the production ``tests/matching/v4_full32_result.yaml`` + the production
``templates/phase_z2/catalog/frame_contracts.yaml`` (no fixtures, no mocks).
- Prove that ``sw_dependency_four_problems`` (VP rank-1 on ``04-2.1``, VP
rank-2 on ``04-2.2``) is excluded from ``lookup_v4_candidates`` after u4,
while ``lookup_v4_all_judgments`` retains it as Step 7-A raw telemetry.
- Guard mdx03 dynamically the actual rank-1 winners on ``03-1`` / ``03-2``
must be non-VP per catalog AND must survive into live candidates.
- VP gating is asserted data-driven (catalog ``visual_pending: true`` flag),
never hard-coded matches Stage 1/2 ``feedback_no_hardcoding`` guardrail.
Out of scope:
- Implementing the 17 missing VP builders ( P0 backlog, IMP-04b / #42).
- VP semantics redefinition or VP frame removal from V4 evidence.
- Adapter pipeline redesign.
"""
from __future__ import annotations
from src.phase_z2_mapper import get_contract
from src.phase_z2_pipeline import (
load_v4_result,
lookup_v4_all_judgments,
lookup_v4_candidates,
)
CRASH_TEMPLATE_ID = "sw_dependency_four_problems"
def _rank1_template_id(v4: dict, section_id: str) -> str:
judgments = v4["mdx_sections"][section_id]["judgments_full32"]
return judgments[0]["template_id"]
# ─── Dynamic catalog proof — VP flag is data-driven ─────────────
def test_crash_template_is_visual_pending_in_catalog():
"""Catalog declares ``sw_dependency_four_problems.visual_pending: true``.
Locks the data-driven contract the entire u4 / u6 chain rests on this
YAML flag, not a hard-coded frame allow-list. If the catalog ever drops
the flag without registering the ``cards_4_grid`` builder, this assertion
surfaces the regression before mdx04 crashes the mapper.
"""
contract = get_contract(CRASH_TEMPLATE_ID)
assert isinstance(contract, dict), CRASH_TEMPLATE_ID
assert contract.get("visual_pending") is True
# ─── mdx04-2.1 — VP frame at rank 1 ─────────────────────────────
def test_mdx04_2_1_excludes_vp_rank_1_from_live_candidates():
"""``04-2.1`` rank-1 is the VP crash frame — must NOT appear in live set.
Every surviving live candidate (if any) must itself be non-VP per catalog;
the section may legitimately produce an empty list (all remaining entries
are reject), which is the documented ``no_non_reject_v4_candidate`` signal
routed to the Step 9 fallback path.
"""
v4 = load_v4_result()
assert _rank1_template_id(v4, "04-2.1") == CRASH_TEMPLATE_ID
candidates = lookup_v4_candidates(v4, "04-2.1", max_n=6)
tids = [c.template_id for c in candidates]
assert CRASH_TEMPLATE_ID not in tids
for tid in tids:
contract = get_contract(tid) or {}
assert contract.get("visual_pending") is not True, (
f"04-2.1: surviving live candidate {tid} is VP"
)
def test_mdx04_2_1_retains_vp_frame_in_raw_judgments():
"""Step 7-A axis preservation — raw 32-entry telemetry still carries VP."""
v4 = load_v4_result()
all_tids = [j.template_id for j in lookup_v4_all_judgments(v4, "04-2.1")]
assert CRASH_TEMPLATE_ID in all_tids
# ─── mdx04-2.2 — VP frame at rank 2 ─────────────────────────────
def test_mdx04_2_2_excludes_vp_rank_2_from_live_candidates():
"""``04-2.2`` rank-2 is the VP crash frame — rank-1 live frame must win."""
v4 = load_v4_result()
rank_1 = _rank1_template_id(v4, "04-2.2")
rank_1_contract = get_contract(rank_1) or {}
# Pre-condition for this regression: rank-1 on 04-2.2 is non-VP.
assert rank_1_contract.get("visual_pending") is not True
candidates = lookup_v4_candidates(v4, "04-2.2", max_n=6)
tids = [c.template_id for c in candidates]
assert CRASH_TEMPLATE_ID not in tids
assert tids[0] == rank_1
def test_mdx04_2_2_retains_vp_frame_in_raw_judgments():
"""Raw judgments path preserves VP frame regardless of its rank."""
v4 = load_v4_result()
all_tids = [j.template_id for j in lookup_v4_all_judgments(v4, "04-2.2")]
assert CRASH_TEMPLATE_ID in all_tids
# ─── mdx03 dynamic guard — non-VP rank-1 survives ───────────────
def test_mdx03_rank_1_non_vp_survives_live_candidates():
"""Non-VP rank-1 winners on mdx03 sections must still win after u4.
Dynamic check pulls rank-1 from the V4 yaml + catalog VP flag at runtime.
No hard-coded template_id list; only the regression contract is asserted.
"""
v4 = load_v4_result()
for section_id in ("03-1", "03-2"):
rank_1 = _rank1_template_id(v4, section_id)
contract = get_contract(rank_1) or {}
assert contract.get("visual_pending") is not True, (
f"{section_id} rank-1 ({rank_1}) unexpectedly VP — guard precondition broken"
)
candidates = lookup_v4_candidates(v4, section_id, max_n=6)
tids = [c.template_id for c in candidates]
assert tids and tids[0] == rank_1, (
f"{section_id}: expected rank-1 ({rank_1}) live, got {tids}"
)
+348
View File
@@ -0,0 +1,348 @@
"""IMP-51 (#79) u5 — focused tests for the ``--override-image`` CLI surface.
Stage 2 u5 scope (per the Exit Report):
- Successful parse: single flag + multiple flags accumulate.
- Forwarding: parsed mapping reaches ``run_phase_z2_mvp1`` as
``override_image_overrides={image_id: {"x", "y", "w", "h"}}``.
- Empty payload: omitting ``--override-image`` forwards ``None``
(CLI ``or None`` collapse, sibling pattern to other axes).
- Hard-error cases (each must ``sys.exit(2)`` with a stderr message):
* missing ``=``
* empty ``IMAGE_ID``
* duplicate ``IMAGE_ID``
* wrong float count (not 4)
* non-numeric float component
The harness mirrors ``tests/test_user_overrides_pipeline_fallback.py``
the ``if __name__ == "__main__"`` block of ``src.phase_z2_pipeline`` is
exec'd inside the module's namespace after monkeypatching
``run_phase_z2_mvp1`` with a recording stub. This exercises the actual
production parser without invoking the real pipeline.
The persistence fallback is silenced by redirecting
``src.user_overrides_io.DEFAULT_OVERRIDES_ROOT`` to a clean tmp directory
so persisted state from prior runs cannot bleed into the parser-only
assertions here.
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
from typing import Any
import pytest
import src.phase_z2_pipeline as _pz2
import src.user_overrides_io as _io
# -- harness ---------------------------------------------------------------
def _exec_main_block(
captured: dict[str, Any], argv: list[str], monkeypatch
) -> None:
"""Run the ``__main__`` body of phase_z2_pipeline.py with a fake
``run_phase_z2_mvp1`` so its kwargs are observable."""
def _fake_run(
mdx_path,
run_id,
*,
override_layout=None,
override_frames=None,
override_zone_geometries=None,
override_section_assignments=None,
override_image_overrides=None,
):
captured["mdx_path"] = mdx_path
captured["run_id"] = run_id
captured["override_layout"] = override_layout
captured["override_frames"] = override_frames
captured["override_zone_geometries"] = override_zone_geometries
captured["override_section_assignments"] = override_section_assignments
captured["override_image_overrides"] = override_image_overrides
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
monkeypatch.setattr(sys, "argv", argv)
src_path = Path(_pz2.__file__)
source = src_path.read_text(encoding="utf-8")
tree = ast.parse(source)
for node in tree.body:
if (
isinstance(node, ast.If)
and isinstance(node.test, ast.Compare)
and isinstance(node.test.left, ast.Name)
and node.test.left.id == "__name__"
):
block = ast.Module(body=node.body, type_ignores=[])
exec(compile(block, str(src_path), "exec"), _pz2.__dict__)
return
raise AssertionError("no `if __name__ == '__main__'` block found")
def _redirect_overrides_root(tmp_path: Path, monkeypatch) -> None:
"""Isolate the persistence fallback so file state never leaks in."""
monkeypatch.setattr(_io, "DEFAULT_OVERRIDES_ROOT", tmp_path)
# -- success paths --------------------------------------------------------
def test_no_image_override_forwards_none(tmp_path, monkeypatch):
"""When ``--override-image`` is omitted, the kwarg must be ``None``
(the parser's accumulator stays empty → ``overrides_images or None``)."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
assert captured["override_image_overrides"] is None
def test_single_image_override_parses_and_forwards(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30.5,25",
],
monkeypatch,
)
assert captured["override_image_overrides"] == {
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.5, "h": 25.0},
}
def test_multiple_image_overrides_accumulate(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30,25",
"--override-image",
"img-def=50,15,40,40",
],
monkeypatch,
)
assert captured["override_image_overrides"] == {
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
"img-def": {"x": 50.0, "y": 15.0, "w": 40.0, "h": 40.0},
}
def test_image_override_strips_whitespace_in_image_id(tmp_path, monkeypatch):
"""``iid.strip()`` is intentional — match sibling --override-frame and
--override-zone-geometry leniency on surrounding whitespace."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
" img-pad =5,5,10,10",
],
monkeypatch,
)
assert captured["override_image_overrides"] == {
"img-pad": {"x": 5.0, "y": 5.0, "w": 10.0, "h": 10.0},
}
# -- hard-error paths -----------------------------------------------------
def test_image_override_missing_equals_exits(tmp_path, monkeypatch, capsys):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc10,15,30,25",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "--override-image must be IMAGE_ID=X,Y,W,H" in err
def test_image_override_empty_image_id_exits(tmp_path, monkeypatch, capsys):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"=10,15,30,25",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "IMAGE_ID must be non-empty" in err
def test_image_override_whitespace_only_image_id_exits(
tmp_path, monkeypatch, capsys
):
"""``iid.strip()`` must collapse whitespace-only IDs into the empty-ID
error path (otherwise a spurious key would land in the mapping)."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
" =10,15,30,25",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "IMAGE_ID must be non-empty" in err
def test_image_override_duplicate_image_id_exits(
tmp_path, monkeypatch, capsys
):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30,25",
"--override-image",
"img-abc=20,25,30,35",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "duplicate IMAGE_ID 'img-abc'" in err
def test_image_override_wrong_float_count_exits(
tmp_path, monkeypatch, capsys
):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "expects 4 floats X,Y,W,H" in err
def test_image_override_too_many_floats_exits(
tmp_path, monkeypatch, capsys
):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30,25,99",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "expects 4 floats X,Y,W,H" in err
def test_image_override_non_numeric_value_exits(
tmp_path, monkeypatch, capsys
):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,abc,30,25",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "floats parse fail" in err
# -- isolation guard ------------------------------------------------------
def test_image_override_does_not_leak_into_sibling_axes(tmp_path, monkeypatch):
"""A populated image override must not perturb the other four axes."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30,25",
],
monkeypatch,
)
assert captured["override_image_overrides"] == {
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
}
assert captured["override_layout"] is None
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None
+587
View File
@@ -0,0 +1,587 @@
"""IMP-48 (#77) u6 — Unit tests for ``resplit_all_reject_merges`` helper.
Scope (this slice Stage 2 plan u6):
The helper ``resplit_all_reject_merges`` in
``src/phase_z2_composition.py`` is a deterministic Step 6 post-pass that
decomposes a merged ``parent_merged`` / ``parent_merged_inferred`` unit
carrying ``label="reject"`` into per-section singles. This file exercises
the helper directly with synthetic stub V4 matches + stub sections; it
does NOT touch the pipeline hook (that is u7/u8/u9's regression scope).
u6 cases covered (Stage 2 plan):
1. **Detection** merged-reject is detected when
``merge_type {"parent_merged", "parent_merged_inferred"}``,
``label == "reject"``, and ``len(source_section_ids) >= 2``.
Singles / non-reject merges / one-child merges are ignored.
2. **Beneficial split** at least one rebuilt single with
``label != "reject"`` ``applied=True``, merged replaced by
per-section singles tagged ``selection_path="resplit_from_merge"``.
3. **Non-beneficial keep-merged** all rebuilt singles are reject
``applied=False``, merged kept, ``skipped_units[0].reason ==
"no_beneficial_split"``.
4. **Layout-cap keep-merged** projected post-split count > 4
EVERY would-be split aborts with ``reason="layout_cap_exceeded"``
(Stage 2 Q2 default no partial split; v0 ``select_layout_preset``
supports 1~4 units only).
5. **Override skip** ``section_assignment_override=True`` short-
circuits before detection with ``skipped_reason=
"section_assignment_override"`` (IMP-06 #6 zoneSections stays
ground truth).
6. **Coverage invariant** missing section / missing V4 match
records ``skipped_units[*].reason == "incomplete_rebuild"`` with
the missing section ids surfaced. Merged unit is preserved.
7. **Idempotent re-entry** calling the helper again on its own
output is a no-op (singles are excluded by ``merge_type=="single"``).
8. **Audit shape invariants** Stage 1 schema (``applied``,
``split_units``, ``skipped_units``, ``post_split_unit_count``,
``post_split_layout_preset``) is always present.
AI=0 throughout PZ-1 deterministic code path only.
No-hardcoding (RULE_7) stubs use MOCK_ prefixed identifiers; no
real catalog template_id / frame_id / MDX sample identifier leaks.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from src.phase_z2_composition import (
CompositionUnit,
resplit_all_reject_merges,
)
# ─── Synthetic stubs (MOCK_ prefix mandatory — IMP-30 u3 convention) ───
@dataclass
class _StubV4Match:
template_id: str
frame_id: str
frame_number: int
confidence: float
label: str
v4_rank: Optional[int] = None
selection_path: str = "rank_1"
fallback_reason: Optional[str] = None
provisional: bool = False
@dataclass
class _StubSection:
section_id: str
title: str = ""
raw_content: str = ""
_LABEL_TO_STATUS = {
"use_as_is": "matched_zone",
"light_edit": "adapt_matched_zone",
"restructure": "extract_matched_zone",
"reject": "fallback_candidate",
}
_ALLOWED_STATUSES = {"matched_zone", "adapt_matched_zone"}
def _make_lookup(matches: dict[str, _StubV4Match]):
"""Build a (section_id) -> V4Match | None lookup over the given map."""
def _fn(section_id: str) -> Optional[_StubV4Match]:
return matches.get(section_id)
return _fn
def _make_merged_unit(
*,
merge_type: str,
source_section_ids: list[str],
label: str = "reject",
template_id: str = "MOCK_TMPL_PARENT",
) -> CompositionUnit:
"""Construct a merged CompositionUnit shaped like collect_candidates output."""
return CompositionUnit(
source_section_ids=list(source_section_ids),
merge_type=merge_type,
frame_template_id=template_id,
frame_id="MOCK_FRM_PARENT",
frame_number=99,
confidence=0.10,
label=label,
phase_z_status=_LABEL_TO_STATUS.get(label, "unknown"),
raw_content="MERGED RAW CONTENT (joined string from children)",
title="MOCK_PARENT",
)
def _make_single_unit(
section_id: str,
*,
label: str = "use_as_is",
template_id: Optional[str] = None,
) -> CompositionUnit:
"""Construct a single CompositionUnit shaped like collect_candidates output."""
return CompositionUnit(
source_section_ids=[section_id],
merge_type="single",
frame_template_id=template_id or f"MOCK_TMPL_{section_id}",
frame_id=f"MOCK_FRM_{section_id}",
frame_number=hash(section_id) % 32,
confidence=0.80,
label=label,
phase_z_status=_LABEL_TO_STATUS.get(label, "unknown"),
raw_content=f"section {section_id} content",
title=section_id,
)
# ─── Case 1 : Detection — what counts as a merged-reject ─────────────
def test_detection_ignores_single_units():
"""``merge_type="single"`` units never enter detection (idempotency anchor)."""
units = [_make_single_unit("MOCK_S1", label="reject")]
sections = [_StubSection("MOCK_S1", raw_content="single reject")]
lookup = _make_lookup({
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.1, "reject"),
})
out_units, audit = resplit_all_reject_merges(
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
)
assert out_units == units
assert audit["applied"] is False
assert audit["detected_units"] == []
assert audit["skipped_reason"] == "no_detection"
def test_detection_ignores_non_reject_merge():
"""A merged unit with ``label != "reject"`` is not in scope."""
units = [_make_merged_unit(
merge_type="parent_merged",
source_section_ids=["MOCK_S1", "MOCK_S2"],
label="light_edit",
)]
sections = [
_StubSection("MOCK_S1", raw_content="c1"),
_StubSection("MOCK_S2", raw_content="c2"),
]
lookup = _make_lookup({
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.9, "use_as_is"),
})
out_units, audit = resplit_all_reject_merges(
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
)
assert out_units == units
assert audit["applied"] is False
assert audit["detected_units"] == []
assert audit["skipped_reason"] == "no_detection"
def test_detection_ignores_one_child_merge():
"""``len(source_section_ids) < 2`` excludes from detection."""
units = [_make_merged_unit(
merge_type="parent_merged",
source_section_ids=["MOCK_S1"],
label="reject",
)]
sections = [_StubSection("MOCK_S1", raw_content="c1")]
lookup = _make_lookup({
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.1, "reject"),
})
out_units, audit = resplit_all_reject_merges(
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
)
assert out_units == units
assert audit["applied"] is False
assert audit["detected_units"] == []
def test_detection_picks_parent_merged_reject():
"""``parent_merged`` + reject + ≥2 sids → detected."""
units = [_make_merged_unit(
merge_type="parent_merged",
source_section_ids=["MOCK_S1", "MOCK_S2"],
label="reject",
)]
sections = [
_StubSection("MOCK_S1", raw_content="c1"),
_StubSection("MOCK_S2", raw_content="c2"),
]
# All children also reject → detection only; gating skipped via no_beneficial.
lookup = _make_lookup({
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.1, "reject"),
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.1, "reject"),
})
_, audit = resplit_all_reject_merges(
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
)
assert len(audit["detected_units"]) == 1
assert audit["detected_units"][0]["merge_type"] == "parent_merged"
assert audit["detected_units"][0]["label"] == "reject"
assert audit["detected_units"][0]["source_section_ids"] == ["MOCK_S1", "MOCK_S2"]
def test_detection_picks_parent_merged_inferred_reject():
"""``parent_merged_inferred`` + reject + ≥2 sids → detected."""
units = [_make_merged_unit(
merge_type="parent_merged_inferred",
source_section_ids=["MOCK_S1", "MOCK_S2"],
label="reject",
)]
sections = [
_StubSection("MOCK_S1", raw_content="c1"),
_StubSection("MOCK_S2", raw_content="c2"),
]
lookup = _make_lookup({
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.1, "reject"),
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.1, "reject"),
})
_, audit = resplit_all_reject_merges(
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
)
assert len(audit["detected_units"]) == 1
assert audit["detected_units"][0]["merge_type"] == "parent_merged_inferred"
# ─── Case 2 : Beneficial split — applied path ────────────────────────
def test_beneficial_split_applied_when_one_child_non_reject():
"""≥1 rebuilt single with ``label != "reject"`` → apply the split."""
merged = _make_merged_unit(
merge_type="parent_merged",
source_section_ids=["MOCK_S1", "MOCK_S2"],
label="reject",
template_id="MOCK_TMPL_PARENT_DISCARDED",
)
units = [merged]
sections = [
_StubSection("MOCK_S1", title="S1", raw_content="MDX raw of S1"),
_StubSection("MOCK_S2", title="S2", raw_content="MDX raw of S2"),
]
lookup = _make_lookup({
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.1, "reject"),
})
out_units, audit = resplit_all_reject_merges(
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
)
assert audit["applied"] is True
# Merged removed, two singles inserted in order.
assert len(out_units) == 2
assert [u.merge_type for u in out_units] == ["single", "single"]
assert [u.source_section_ids for u in out_units] == [["MOCK_S1"], ["MOCK_S2"]]
# ★ feedback_ai_isolation_contract — singles use their OWN rank-1 V4 evidence,
# not the discarded merged parent's template_id.
assert out_units[0].frame_template_id == "MOCK_TMPL_S1"
assert out_units[1].frame_template_id == "MOCK_TMPL_S2"
assert merged.frame_template_id not in {out_units[0].frame_template_id,
out_units[1].frame_template_id}
# ★ MDX_raw_content_invariant — singles use per-section raw_content (not the joined merged string).
assert out_units[0].raw_content == "MDX raw of S1"
assert out_units[1].raw_content == "MDX raw of S2"
# ★ Stage 1 Q3 YES — selection_path tag applied only to split-produced singles.
assert out_units[0].selection_path == "resplit_from_merge"
assert out_units[1].selection_path == "resplit_from_merge"
# Audit shape.
assert len(audit["split_units"]) == 1
split = audit["split_units"][0]
assert split["merged_source_section_ids"] == ["MOCK_S1", "MOCK_S2"]
assert split["non_reject_count"] == 1
assert {s["section_id"] for s in split["split_singles"]} == {"MOCK_S1", "MOCK_S2"}
assert audit["skipped_units"] == []
assert audit["post_split_unit_count"] == 2
assert audit["post_split_layout_preset"] == "horizontal-2"
# ``skipped_reason`` removed when applied=True.
assert "skipped_reason" not in audit
def test_beneficial_split_preserves_full_coverage():
"""Coverage invariant — split increases unit count, never reduces section coverage."""
merged = _make_merged_unit(
merge_type="parent_merged_inferred",
source_section_ids=["MOCK_S1", "MOCK_S2", "MOCK_S3"],
label="reject",
)
units = [merged]
sections = [
_StubSection("MOCK_S1", raw_content="c1"),
_StubSection("MOCK_S2", raw_content="c2"),
_StubSection("MOCK_S3", raw_content="c3"),
]
lookup = _make_lookup({
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.8, "light_edit"),
"MOCK_S3": _StubV4Match("MOCK_TMPL_S3", "MOCK_FRM_S3", 3, 0.1, "reject"),
})
out_units, audit = resplit_all_reject_merges(
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
)
assert audit["applied"] is True
covered = {sid for u in out_units for sid in u.source_section_ids}
assert covered == set(merged.source_section_ids) # ★ dropped_zero_invariant
# ─── Case 3 : Non-beneficial keep-merged ─────────────────────────────
def test_non_beneficial_split_keeps_merged_when_all_children_reject():
"""All rebuilt singles are reject → split is not beneficial; merged kept."""
merged = _make_merged_unit(
merge_type="parent_merged",
source_section_ids=["MOCK_S1", "MOCK_S2"],
label="reject",
)
units = [merged]
sections = [
_StubSection("MOCK_S1", raw_content="c1"),
_StubSection("MOCK_S2", raw_content="c2"),
]
lookup = _make_lookup({
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.1, "reject"),
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.1, "reject"),
})
out_units, audit = resplit_all_reject_merges(
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
)
assert audit["applied"] is False
# Merged preserved by identity (IMP-47B #76 handles it directly).
assert out_units == [merged]
assert audit["split_units"] == []
assert len(audit["skipped_units"]) == 1
skip = audit["skipped_units"][0]
assert skip["reason"] == "no_beneficial_split"
assert skip["merged_source_section_ids"] == ["MOCK_S1", "MOCK_S2"]
assert audit["post_split_unit_count"] == 1
assert audit["post_split_layout_preset"] is None
# ─── Case 4 : Layout-cap keep-merged ─────────────────────────────────
def test_layout_cap_aborts_split_when_projected_count_exceeds_four():
"""Projected post-split count > 4 → ALL would-be splits aborted.
Setup: 1 single (non-target) + 1 merged-reject of 4 sections.
Initial unit count = 2. If split applied, post-split = 5 (> 4 cap).
Stage 2 Q2 default keep merged, no partial split.
"""
other_single = _make_single_unit("MOCK_OTHER", label="use_as_is")
merged = _make_merged_unit(
merge_type="parent_merged",
source_section_ids=["MOCK_S1", "MOCK_S2", "MOCK_S3", "MOCK_S4"],
label="reject",
)
units = [other_single, merged]
sections = [
_StubSection("MOCK_OTHER", raw_content="other"),
_StubSection("MOCK_S1", raw_content="c1"),
_StubSection("MOCK_S2", raw_content="c2"),
_StubSection("MOCK_S3", raw_content="c3"),
_StubSection("MOCK_S4", raw_content="c4"),
]
lookup = _make_lookup({
"MOCK_OTHER": _StubV4Match("MOCK_TMPL_O", "MOCK_FRM_O", 0, 0.9, "use_as_is"),
# Beneficial in principle (some non-reject), but cap aborts.
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.8, "light_edit"),
"MOCK_S3": _StubV4Match("MOCK_TMPL_S3", "MOCK_FRM_S3", 3, 0.1, "reject"),
"MOCK_S4": _StubV4Match("MOCK_TMPL_S4", "MOCK_FRM_S4", 4, 0.1, "reject"),
})
out_units, audit = resplit_all_reject_merges(
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
)
assert audit["applied"] is False
assert out_units == units # byte-identical fallback for IMP-47B handoff
assert audit["split_units"] == []
assert len(audit["skipped_units"]) == 1
skip = audit["skipped_units"][0]
assert skip["reason"] == "layout_cap_exceeded"
assert skip["projected_post_split_count"] == 5
assert audit["post_split_unit_count"] == 2
assert audit["post_split_layout_preset"] is None
# ─── Case 5 : Override skip ──────────────────────────────────────────
def test_override_skip_short_circuits_before_detection():
"""``section_assignment_override=True`` (IMP-06 #6) makes the helper a no-op."""
merged = _make_merged_unit(
merge_type="parent_merged",
source_section_ids=["MOCK_S1", "MOCK_S2"],
label="reject",
)
units = [merged]
sections = [
_StubSection("MOCK_S1", raw_content="c1"),
_StubSection("MOCK_S2", raw_content="c2"),
]
lookup = _make_lookup({
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.8, "light_edit"),
})
out_units, audit = resplit_all_reject_merges(
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
section_assignment_override=True,
)
assert out_units == units # byte-identical
assert audit["applied"] is False
assert audit["skipped_reason"] == "section_assignment_override"
# Override is upstream of detection — never enumerates.
assert audit["detected_units"] == []
assert audit["split_units"] == []
assert audit["skipped_units"] == []
# ─── Case 6 : Coverage invariant — incomplete rebuild ────────────────
def test_incomplete_rebuild_keeps_merged_when_section_missing():
"""A merged unit referencing a section absent from ``sections`` →
``incomplete_rebuild`` skip with the missing id surfaced.
"""
merged = _make_merged_unit(
merge_type="parent_merged",
source_section_ids=["MOCK_S1", "MOCK_MISSING"],
label="reject",
)
units = [merged]
sections = [_StubSection("MOCK_S1", raw_content="c1")] # MOCK_MISSING absent
lookup = _make_lookup({
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
})
out_units, audit = resplit_all_reject_merges(
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
)
assert audit["applied"] is False
assert out_units == [merged]
assert audit["split_units"] == []
assert len(audit["skipped_units"]) == 1
skip = audit["skipped_units"][0]
assert skip["reason"] == "incomplete_rebuild"
assert skip["missing_section_ids"] == ["MOCK_MISSING"]
def test_incomplete_rebuild_keeps_merged_when_v4_match_missing():
"""A merged unit referencing a section without V4 evidence →
``incomplete_rebuild`` skip.
"""
merged = _make_merged_unit(
merge_type="parent_merged_inferred",
source_section_ids=["MOCK_S1", "MOCK_S2"],
label="reject",
)
units = [merged]
sections = [
_StubSection("MOCK_S1", raw_content="c1"),
_StubSection("MOCK_S2", raw_content="c2"),
]
lookup = _make_lookup({
# MOCK_S2 deliberately omitted to simulate missing V4 evidence.
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
})
out_units, audit = resplit_all_reject_merges(
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
)
assert audit["applied"] is False
assert out_units == [merged]
skip = audit["skipped_units"][0]
assert skip["reason"] == "incomplete_rebuild"
assert skip["missing_section_ids"] == ["MOCK_S2"]
# ─── Case 7 : Idempotent re-entry ────────────────────────────────────
def test_idempotent_re_entry_is_noop_after_split():
"""Running the helper a second time on its own output detects nothing
(singles are excluded by construction). max_retry=1 (Stage 2 lock).
"""
merged = _make_merged_unit(
merge_type="parent_merged",
source_section_ids=["MOCK_S1", "MOCK_S2"],
label="reject",
)
units = [merged]
sections = [
_StubSection("MOCK_S1", raw_content="c1"),
_StubSection("MOCK_S2", raw_content="c2"),
]
lookup = _make_lookup({
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.8, "light_edit"),
})
first_out, first_audit = resplit_all_reject_merges(
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
)
assert first_audit["applied"] is True
# Second pass over the post-resplit list — should be a no-op.
second_out, second_audit = resplit_all_reject_merges(
first_out, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
)
assert second_audit["applied"] is False
assert second_audit["detected_units"] == []
assert second_audit["skipped_reason"] == "no_detection"
assert second_out == first_out # output byte-identical
# ─── Case 8 : Audit shape invariants ─────────────────────────────────
def test_audit_payload_always_has_stage_1_keys():
"""Every return path must include the Stage 1 schema keys (additive only)."""
required = {
"applied",
"split_units",
"skipped_units",
"post_split_unit_count",
"post_split_layout_preset",
}
# 1) override skip
_, audit_override = resplit_all_reject_merges(
[], [], _make_lookup({}), _LABEL_TO_STATUS, _ALLOWED_STATUSES,
section_assignment_override=True,
)
assert required.issubset(audit_override)
# 2) no detection (empty units)
_, audit_empty = resplit_all_reject_merges(
[], [], _make_lookup({}), _LABEL_TO_STATUS, _ALLOWED_STATUSES,
)
assert required.issubset(audit_empty)
assert audit_empty["post_split_unit_count"] == 0
assert audit_empty["post_split_layout_preset"] is None
assert audit_empty["skipped_reason"] == "no_detection"
# 3) applied path — see test_beneficial_split_applied_when_one_child_non_reject
# already asserts the full applied shape.
+16 -9
View File
@@ -1012,11 +1012,14 @@ def test_u6_empty_shell_unit_listed_with_empty_identifiers():
assert entry["selection_path"] == "empty_shell"
assert entry["fallback_reason"] == "no_v4_rank_1_for_any_section"
assert entry["v4_rank"] is None
# full_mdx_coverage holds because shell.source_section_ids covers every
# aligned section id — u4 deliberately sets this so coverage stays True
# under the terminal first-render invariant.
assert status["full_mdx_coverage"] is True
assert status["overall"] == "PASS"
# IMP-87 u4 inversion (Stage 1 anchor c53722ad): empty-shell-only runs
# MUST NOT report PASS / full_mdx_coverage. u1 redefines coverage over
# content units (frame_template_id == "__empty__" excluded) and u2 routes
# empty_shell_dominant slides into the EMPTY_SHELL_NO_CONTENT branch
# before the legacy 4-way ladder. Aligned section ids covered only by
# the shell unit are surfaced in filtered_section_ids instead.
assert status["full_mdx_coverage"] is False
assert status["overall"] == "EMPTY_SHELL_NO_CONTENT"
# ─── u6 case 4 : mixed selection — provisional + normal units coexist ──
@@ -1357,10 +1360,14 @@ def test_u7_e2e_zero_v4_empty_shell_status_surface(u7_patch_selector_deps):
assert shell_entry["phase_z_status"] == "empty_shell"
assert shell_entry["frame_template_id"] == "__empty__"
assert shell_entry["source_section_ids"] == ["S1", "S2"]
# Coverage check — both sections counted as covered by the shell unit
# (rendered=True path; PASS enum unchanged by provisional qualifier).
assert status["full_mdx_coverage"] is True
assert status["overall"] == "PASS"
# IMP-87 u4 inversion (Stage 1 anchor c53722ad): even at the e2e level
# an empty-shell-only run must surface as EMPTY_SHELL_NO_CONTENT, not
# PASS. u1 excludes __empty__ frames from content coverage so the two
# aligned sections end up in filtered_section_ids; u2's
# empty_shell_dominant branch then short-circuits the overall ladder
# ahead of any visual_check verdict.
assert status["full_mdx_coverage"] is False
assert status["overall"] == "EMPTY_SHELL_NO_CONTENT"
# ─── u7 case 3 : e2e normal path unchanged when opt-in flags both on ─────
@@ -0,0 +1,185 @@
"""IMP-#85 u2 — load_frame_contracts catalog builder invariant.
Scope (Stage 2 lock):
- Prod `frame_contracts.yaml` (32 frames) passes invariant on load.
- `visual_pending: true` contracts are skipped backlog axis (IMP-04b / #42).
- Non-VP contracts with missing or unknown `payload.builder` raise
`CatalogInvariantError` (boot-time fail-fast).
- Failed invariant must NOT populate `_CATALOG_CACHE` (retry-able).
Out of scope:
- Implementing the 17 missing VP builders ( P0 / IMP-04b backlog).
- Audit CLI invariants I1I4 (u3a / u3b).
- Lookup-side VP filter (u4).
- Catalog regression fixtures via tests/fixtures/catalog/ (u5).
"""
from __future__ import annotations
import pytest
from src import phase_z2_mapper
from src.phase_z2_mapper import (
CatalogInvariantError,
PAYLOAD_BUILDERS,
_check_catalog_builder_invariant,
load_frame_contracts,
)
@pytest.fixture(autouse=True)
def _reset_catalog_cache():
phase_z2_mapper._CATALOG_CACHE = None
yield
phase_z2_mapper._CATALOG_CACHE = None
def test_prod_catalog_passes_invariant():
"""Prod frame_contracts.yaml load 시 invariant violation 없음 (32 frames)."""
catalog = load_frame_contracts()
assert isinstance(catalog, dict)
assert len(catalog) >= 30
def test_invariant_skips_visual_pending_contract_with_unknown_builder():
"""visual_pending: true 인 contract 는 builder 가 unknown 이어도 skip."""
catalog = {
"vp_frame": {
"template_id": "vp_frame",
"visual_pending": True,
"payload": {"builder": "definitely_not_a_registered_builder"},
},
}
_check_catalog_builder_invariant(catalog)
def test_invariant_skips_vp_contract_missing_builder_field():
"""visual_pending: true contract 의 payload 가 builder field 자체를 안 가져도 skip."""
catalog = {
"vp_frame": {
"template_id": "vp_frame",
"visual_pending": True,
"payload": {},
},
}
_check_catalog_builder_invariant(catalog)
def test_invariant_raises_on_non_vp_missing_builder_field():
"""visual_pending 이 없거나 false 인 contract 의 payload.builder 누락 → raise."""
catalog = {
"live_frame": {
"template_id": "live_frame",
"payload": {},
},
}
with pytest.raises(CatalogInvariantError) as exc:
_check_catalog_builder_invariant(catalog)
msg = str(exc.value)
assert "live_frame" in msg
assert "missing payload.builder" in msg
def test_invariant_raises_on_non_vp_unknown_builder():
"""non-VP contract 의 payload.builder 가 PAYLOAD_BUILDERS 에 없으면 raise."""
catalog = {
"live_frame": {
"template_id": "live_frame",
"payload": {"builder": "definitely_not_a_registered_builder"},
},
}
with pytest.raises(CatalogInvariantError) as exc:
_check_catalog_builder_invariant(catalog)
msg = str(exc.value)
assert "live_frame" in msg
assert "definitely_not_a_registered_builder" in msg
def test_invariant_passes_on_non_vp_registered_builder():
"""non-VP contract 가 registered builder 를 가리키면 통과."""
sample_builder = next(iter(PAYLOAD_BUILDERS.keys()))
catalog = {
"live_frame": {
"template_id": "live_frame",
"payload": {"builder": sample_builder},
},
}
_check_catalog_builder_invariant(catalog)
def test_invariant_aggregates_multiple_violations_excluding_vp():
"""여러 non-VP 위반이 있으면 모두 message 에 포함. VP frame 은 제외."""
catalog = {
"frame_a": {
"template_id": "frame_a",
"payload": {"builder": "missing_x"},
},
"frame_b": {
"template_id": "frame_b",
"payload": {},
},
"vp_frame": {
"template_id": "vp_frame",
"visual_pending": True,
"payload": {"builder": "missing_y"},
},
}
with pytest.raises(CatalogInvariantError) as exc:
_check_catalog_builder_invariant(catalog)
msg = str(exc.value)
assert "frame_a" in msg
assert "frame_b" in msg
assert "vp_frame" not in msg
assert "missing_x" in msg
assert "missing_y" not in msg
def test_invariant_treats_visual_pending_false_as_live():
"""visual_pending: false (explicit) 는 live 와 동일하게 검증."""
catalog = {
"live_frame": {
"template_id": "live_frame",
"visual_pending": False,
"payload": {"builder": "missing_x"},
},
}
with pytest.raises(CatalogInvariantError):
_check_catalog_builder_invariant(catalog)
def test_load_frame_contracts_failure_does_not_populate_cache(
monkeypatch, tmp_path
):
"""invariant 실패 시 _CATALOG_CACHE 가 populate 되지 않음 (retry 가능)."""
bad_yaml = tmp_path / "bad.yaml"
bad_yaml.write_text(
"live_frame:\n"
" template_id: live_frame\n"
" payload:\n"
" builder: nonexistent_builder_xyz\n",
encoding="utf-8",
)
monkeypatch.setattr(phase_z2_mapper, "CATALOG_PATH", bad_yaml)
monkeypatch.setattr(phase_z2_mapper, "_CATALOG_CACHE", None)
with pytest.raises(CatalogInvariantError):
load_frame_contracts()
assert phase_z2_mapper._CATALOG_CACHE is None
def test_load_frame_contracts_success_populates_cache(monkeypatch, tmp_path):
"""invariant 통과 시 _CATALOG_CACHE 가 populate 되어 두 번째 호출이 동일 dict."""
sample_builder = next(iter(PAYLOAD_BUILDERS.keys()))
good_yaml = tmp_path / "good.yaml"
good_yaml.write_text(
"live_frame:\n"
" template_id: live_frame\n"
f" payload:\n builder: {sample_builder}\n",
encoding="utf-8",
)
monkeypatch.setattr(phase_z2_mapper, "CATALOG_PATH", good_yaml)
monkeypatch.setattr(phase_z2_mapper, "_CATALOG_CACHE", None)
first = load_frame_contracts()
second = load_frame_contracts()
assert first is second
assert "live_frame" in first
@@ -0,0 +1,85 @@
"""IMP-#85 u1 — mapper missing-builder dispatch raises BuilderMissingError.
Scope (Stage 2 lock):
- `BuilderMissingError` exists and is a subclass of `FitError`.
- `map_with_contract` raises `BuilderMissingError` when
`contract.payload.builder` references an unknown registry entry, OR
when `payload.builder` is empty/missing.
- Because it subclasses `FitError`, the existing pipeline
`except FitError` route in `src/phase_z2_pipeline.py` continues to
catch the failure and emit an `adapter_needed` record instead of a
hard crash (mdx04 `sw_dependency_four_problems` / `cards_4_grid`
regression evidence).
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from src.phase_z2_mapper import (
BuilderMissingError,
FitError,
PAYLOAD_BUILDERS,
map_with_contract,
)
def _make_section(raw_content: str = "- a\n- b\n- c"):
return SimpleNamespace(
section_id="test-sec",
raw_content=raw_content,
title="t",
order=1,
)
def test_builder_missing_error_is_fit_error_subclass():
assert issubclass(BuilderMissingError, FitError)
def test_unknown_builder_raises_builder_missing_error():
unknown = "definitely_not_a_registered_builder"
assert unknown not in PAYLOAD_BUILDERS
contract = {
"template_id": "fake_contract_unknown_builder",
"source_shape": "top_bullets",
"cardinality": {},
"payload": {"builder": unknown},
}
with pytest.raises(BuilderMissingError) as exc:
map_with_contract(_make_section(), contract)
assert unknown in str(exc.value)
assert "fake_contract_unknown_builder" in str(exc.value)
def test_missing_builder_field_raises_builder_missing_error():
contract = {
"template_id": "fake_contract_missing_builder_field",
"source_shape": "top_bullets",
"cardinality": {},
"payload": {},
}
with pytest.raises(BuilderMissingError) as exc:
map_with_contract(_make_section(), contract)
assert "missing payload.builder" in str(exc.value)
def test_builder_missing_error_caught_by_fit_error_handler():
"""Pipeline 의 `except FitError` 경로가 그대로 잡아주는지 검증.
실제 pipeline import 없이 동일 패턴을 재현하여 subclass 의도된
routing 효과(adapter_needed) 깨지지 않는지 확인.
"""
contract = {
"template_id": "fake_contract_routing_check",
"source_shape": "top_bullets",
"cardinality": {},
"payload": {"builder": "no_such_builder"},
}
caught = False
try:
map_with_contract(_make_section(), contract)
except FitError:
caught = True
assert caught, "BuilderMissingError must propagate through `except FitError`"
File diff suppressed because it is too large Load Diff
+127
View File
@@ -0,0 +1,127 @@
"""IMP-#85 u7 — subprocess smoke for mdx03 / mdx04 / mdx05 pipeline runs.
These smokes exercise the IMP-#85 catalog ↔ contract ↔ builder
invariant + runtime VP gate end-to-end against real MDX inputs:
* mdx03 non-VP rank-1 path stays clean (exit 0).
* mdx04 the original IMP-#85 hard-crash signature
(``BuilderMissingError ... PAYLOAD_BUILDERS has no such entry``)
is GONE. u1 converted the uncaught ``ValueError`` into a
``BuilderMissingError(FitError)`` subclass; the pipeline's
existing ``except FitError`` at ``src/phase_z2_pipeline.py:4436``
catches it and the zone is routed to
``adapter_needed (skip render)``. Anything that crashes
*downstream* of that routing (e.g. layout_css zone aggregation
when all live zones are adapter_needed) is a separate axis and
out of scope for this issue (see follow_up_issue_candidates).
* mdx05 non-VP rank-1 path stays clean (exit 0).
Each subprocess gets a unique run_id so the runs do not collide on
disk when pytest is invoked concurrently or with -x retry.
"""
from __future__ import annotations
import subprocess
import sys
import uuid
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
SAMPLES_DIR = REPO_ROOT / "samples" / "mdx_batch"
# Original IMP-#85 crash signature (issue body verbatim). u1 converted
# the uncaught ``ValueError`` raised from the mapper's missing-builder
# branch into a ``BuilderMissingError(FitError)`` subclass that the
# pipeline catches. The string below was the marker of the uncaught
# propagation; it must no longer appear in stdout/stderr of a mdx04
# subprocess run.
IMP85_OLD_CRASH_MARKER = "PAYLOAD_BUILDERS has no such entry"
def _run_pipeline(mdx_name: str, run_id: str, timeout: int = 240) -> subprocess.CompletedProcess:
"""Spawn ``python -m src.phase_z2_pipeline <mdx> <run_id>`` and capture I/O."""
return subprocess.run(
[
sys.executable,
"-m",
"src.phase_z2_pipeline",
str(SAMPLES_DIR / mdx_name),
run_id,
],
capture_output=True,
text=True,
timeout=timeout,
cwd=str(REPO_ROOT),
)
def _unique_run_id(prefix: str) -> str:
return f"{prefix}_imp85_smoke_{uuid.uuid4().hex[:8]}"
@pytest.mark.parametrize(
"mdx_name,prefix",
[
("03.mdx", "mdx03"),
("05.mdx", "mdx05"),
],
)
def test_non_vp_smoke_runs_clean(mdx_name: str, prefix: str) -> None:
"""mdx03 / mdx05 hit non-VP rank-1 frames; the pipeline runs to exit 0.
Non-VP rank-1 selection is the normal Phase Z path and the
primary regression guard that u1-u6 do not perturb mapper /
pipeline behaviour for non-VP routes.
"""
cp = _run_pipeline(mdx_name, _unique_run_id(prefix))
assert cp.returncode == 0, (
f"{mdx_name} pipeline returncode={cp.returncode}\n"
f"--- stderr tail ---\n{cp.stderr[-1500:]}\n"
f"--- stdout tail ---\n{cp.stdout[-1500:]}"
)
def test_mdx04_no_longer_emits_imp85_crash_signature() -> None:
"""mdx04 must no longer surface the IMP-#85 uncaught crash marker.
Before u1: missing-builder ``ValueError``
(``'PAYLOAD_BUILDERS has no such entry'``) propagated uncaught and
killed the pipeline at the mapper call site
(``src/phase_z2_pipeline.py:4411-4413``, ``except FitError``
only). After u1: the mapper raises
``BuilderMissingError(FitError)``, the pipeline catches it at the
same ``except FitError`` block, and the zone is recorded under
``adapter_needed (skip render)``.
This smoke asserts only that the original IMP-#85 marker is gone
from both stdout and stderr downstream crashes (e.g.
``build_layout_css`` zone aggregation when all live zones are
adapter_needed) belong to a separate axis and are tracked as a
follow-up issue candidate.
"""
cp = _run_pipeline("04.mdx", _unique_run_id("mdx04"))
combined = cp.stdout + cp.stderr
assert IMP85_OLD_CRASH_MARKER not in combined, (
"IMP-#85 original crash signature still present in pipeline output:\n"
f"--- stderr tail ---\n{cp.stderr[-1500:]}\n"
f"--- stdout tail ---\n{cp.stdout[-1500:]}"
)
def test_conftest_env_isolation_active_for_ai_fallback_defaults() -> None:
"""Direct assertion that ``tests/conftest.py`` isolated the AI
fallback env vars BEFORE ``src.config`` was first imported.
With ``AI_FALLBACK_ENABLED=true`` in the live ``.env``, the
Settings default-OFF contract would otherwise be violated whenever
a developer runs ``pytest -q tests`` against a checkout that has a
live operator ``.env``. This test pins the contract to the source
of truth (``src/config.py`` defaults).
"""
from src.config import Settings
s = Settings()
assert s.ai_fallback_enabled is False
assert s.ai_fallback_auto_cache is False
+262
View File
@@ -0,0 +1,262 @@
"""IMP-52 (#80) u8 — backend tests for ``src.user_overrides_io``.
Covers the persisted axes called out in the Stage 2 plan
(IMP-51 #79 u1 extended this to 5 axes by adding ``image_overrides``):
1. Round-trip ``save`` ``load`` (5 KNOWN_AXES + foreign top-level keys).
2. Unknown-key passthrough (foreign axes preserved across partial merges).
3. Missing / corrupt / non-object behavior (graceful ``{}`` + stderr warning).
4. Invalid keys (``InvalidOverrideKey`` raised on traversal / separators /
leading dot / empty).
All tests inject ``root=tmp_path`` so they never touch the real
``data/user_overrides/`` directory.
"""
from __future__ import annotations
import json
import pytest
from src.user_overrides_io import (
DEFAULT_OVERRIDES_ROOT,
InvalidOverrideKey,
KNOWN_AXES,
load,
override_path,
save,
validate_key,
)
# -- key validation ---------------------------------------------------------
def test_validate_key_accepts_typical_mdx_stems():
for key in ("01", "03", "03__DX_master", "sample.v2", "a-b_c.1"):
assert validate_key(key) == key
@pytest.mark.parametrize(
"bad_key",
[
"",
"..",
"../escape",
"sub/dir",
"sub\\dir",
".hidden",
"-leading-dash",
".",
"name with space",
"name?",
],
)
def test_validate_key_rejects_unsafe(bad_key):
with pytest.raises(InvalidOverrideKey):
validate_key(bad_key)
def test_validate_key_rejects_non_string():
for bad in (None, 123, b"bytes", ["list"], {"d": 1}):
with pytest.raises(InvalidOverrideKey):
validate_key(bad) # type: ignore[arg-type]
# -- override_path ----------------------------------------------------------
def test_override_path_uses_default_root_when_unspecified():
p = override_path("sample")
assert p.parent == DEFAULT_OVERRIDES_ROOT
assert p.name == "sample.json"
def test_override_path_honors_explicit_root(tmp_path):
p = override_path("sample", root=tmp_path)
assert p == tmp_path / "sample.json"
# -- load: missing / corrupt / non-object -----------------------------------
def test_load_missing_file_returns_empty_dict(tmp_path):
assert load("nope", root=tmp_path) == {}
def test_load_corrupt_json_warns_and_returns_empty(tmp_path, capsys):
path = override_path("corrupt", root=tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{ this is not valid json", encoding="utf-8")
result = load("corrupt", root=tmp_path)
assert result == {}
captured = capsys.readouterr()
assert "failed to read" in captured.err
assert str(path) in captured.err
def test_load_non_object_json_warns_and_returns_empty(tmp_path, capsys):
path = override_path("list_root", root=tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("[1, 2, 3]", encoding="utf-8")
result = load("list_root", root=tmp_path)
assert result == {}
captured = capsys.readouterr()
assert "not a JSON object" in captured.err
# -- save: round-trip + partial-merge + foreign-key preserve ----------------
def _full_payload() -> dict:
return {
"layout": "sidebar-right",
"zone_geometries": {
"zone-top": {"x": 40.0, "y": 50.0, "w": 1200.0, "h": 120.0},
},
"zone_sections": {"zone-top": ["03-1", "03-2"]},
"frames": {"03-1+03-2": "frame_two_way_compare"},
"image_overrides": {
"img-1": {"x": 10.0, "y": 20.0, "w": 30.0, "h": 25.0},
},
}
def test_known_axes_includes_image_overrides():
"""IMP-51 #79 u1 — ``image_overrides`` is a known axis (5 total)."""
assert "image_overrides" in KNOWN_AXES
assert len(KNOWN_AXES) == 5
def test_save_then_load_round_trip(tmp_path):
key = "03"
payload = _full_payload()
written = save(key, payload, root=tmp_path)
assert written.exists()
assert written == tmp_path / "03.json"
loaded = load(key, root=tmp_path)
for axis in KNOWN_AXES:
assert loaded[axis] == payload[axis], f"axis {axis!r} did not round-trip"
def test_save_partial_payload_preserves_other_axes(tmp_path):
key = "03"
save(key, _full_payload(), root=tmp_path)
save(key, {"layout": "two-column"}, root=tmp_path)
loaded = load(key, root=tmp_path)
assert loaded["layout"] == "two-column"
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
assert loaded["frames"] == _full_payload()["frames"]
assert loaded["image_overrides"] == _full_payload()["image_overrides"]
def test_save_partial_image_overrides_preserves_other_axes(tmp_path):
"""IMP-51 #79 u1 — partial ``image_overrides`` write preserves siblings."""
key = "03"
save(key, _full_payload(), root=tmp_path)
save(
key,
{"image_overrides": {"img-9": {"x": 5.0, "y": 5.0, "w": 50.0, "h": 50.0}}},
root=tmp_path,
)
loaded = load(key, root=tmp_path)
assert loaded["image_overrides"] == {
"img-9": {"x": 5.0, "y": 5.0, "w": 50.0, "h": 50.0}
}
assert loaded["layout"] == _full_payload()["layout"]
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
assert loaded["frames"] == _full_payload()["frames"]
def test_save_axis_replaces_not_deep_merges(tmp_path):
key = "03"
save(key, {"frames": {"03-1": "frame_a", "03-2": "frame_b"}}, root=tmp_path)
save(key, {"frames": {"03-3": "frame_c"}}, root=tmp_path)
loaded = load(key, root=tmp_path)
assert loaded["frames"] == {"03-3": "frame_c"}
def test_save_none_clears_axis(tmp_path):
key = "03"
save(key, _full_payload(), root=tmp_path)
save(key, {"layout": None}, root=tmp_path)
loaded = load(key, root=tmp_path)
assert "layout" not in loaded
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
assert loaded["frames"] == _full_payload()["frames"]
def test_save_preserves_foreign_top_level_keys(tmp_path):
"""Forward-compat: axes outside KNOWN_AXES (zone_sizes, schema_version,
...) must survive a partial merge on a known axis. (IMP-51 #79 u1
promoted ``image_overrides`` to a known axis, so it is no longer
exercised here as a foreign key.)"""
key = "03"
path = override_path(key, root=tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
pre_seed = {
"layout": "single-column",
"zone_sizes": {"zone-top": "tall"},
"schema_version": "experimental-1",
}
path.write_text(json.dumps(pre_seed), encoding="utf-8")
save(key, {"layout": "sidebar-right"}, root=tmp_path)
loaded = load(key, root=tmp_path)
assert loaded["layout"] == "sidebar-right"
assert loaded["zone_sizes"] == pre_seed["zone_sizes"]
assert loaded["schema_version"] == pre_seed["schema_version"]
def test_save_creates_parent_directory(tmp_path):
nested = tmp_path / "deep" / "nest"
assert not nested.exists()
save("03", {"layout": "two-column"}, root=nested)
assert (nested / "03.json").exists()
def test_save_writes_pretty_sorted_json_for_diffability(tmp_path):
key = "03"
save(key, _full_payload(), root=tmp_path)
raw = (tmp_path / "03.json").read_text(encoding="utf-8")
# sort_keys=True → KNOWN_AXES come out alphabetically
pos_frames = raw.index('"frames"')
pos_image_overrides = raw.index('"image_overrides"')
pos_layout = raw.index('"layout"')
pos_zg = raw.index('"zone_geometries"')
pos_zs = raw.index('"zone_sections"')
assert pos_frames < pos_image_overrides < pos_layout < pos_zg < pos_zs
def test_save_leaves_no_tmp_file_on_success(tmp_path):
save("03", _full_payload(), root=tmp_path)
leftovers = [p for p in tmp_path.iterdir() if p.name != "03.json"]
assert leftovers == [], f"tmp files leaked: {leftovers!r}"
def test_save_rejects_non_dict_partial(tmp_path):
with pytest.raises(TypeError):
save("03", ["not", "a", "dict"], root=tmp_path) # type: ignore[arg-type]
# -- save / load propagate key validation -----------------------------------
@pytest.mark.parametrize("bad_key", ["", "..", "sub/dir", ".hidden"])
def test_save_rejects_invalid_key(tmp_path, bad_key):
with pytest.raises(InvalidOverrideKey):
save(bad_key, {"layout": "two-column"}, root=tmp_path)
@pytest.mark.parametrize("bad_key", ["", "..", "sub/dir", ".hidden"])
def test_load_rejects_invalid_key(tmp_path, bad_key):
with pytest.raises(InvalidOverrideKey):
load(bad_key, root=tmp_path)
@@ -0,0 +1,410 @@
"""IMP-52 (#80) u9 — backend tests for the argparse persistence fallback.
Stage 2 u9 scope (per the Exit Report):
1. Per-axis fill file value flows through when CLI omits the axis.
2. CLI-wins CLI value beats file value on the same axis.
3. No-file noop missing file ``run_phase_z2_mvp1`` gets all-None.
4. Corrupt-file warn invalid JSON / non-object stderr warning + skip.
5. Invalid stem warn ``Path(args.mdx_path).stem`` rejected by validator
warning + fallback skipped wholesale.
We exec the ``if __name__ == "__main__"`` block of
``src.phase_z2_pipeline`` directly inside the module's namespace, after
(a) monkeypatching ``src.user_overrides_io.DEFAULT_OVERRIDES_ROOT`` to a
tmp directory and (b) replacing ``run_phase_z2_mvp1`` with a recording
stub. This exercises the production fallback verbatim without the cost
of a real pipeline invocation.
"""
from __future__ import annotations
import ast
import json
import sys
from pathlib import Path
from typing import Any
import pytest
import src.phase_z2_pipeline as _pz2
import src.user_overrides_io as _io
# -- harness ---------------------------------------------------------------
def _exec_main_block(
captured: dict[str, Any], argv: list[str], monkeypatch
) -> None:
"""Run the ``__main__`` body of phase_z2_pipeline.py with a fake
``run_phase_z2_mvp1`` so its kwargs are observable."""
def _fake_run(
mdx_path,
run_id,
*,
override_layout=None,
override_frames=None,
override_zone_geometries=None,
override_section_assignments=None,
override_image_overrides=None,
):
captured["mdx_path"] = mdx_path
captured["run_id"] = run_id
captured["override_layout"] = override_layout
captured["override_frames"] = override_frames
captured["override_zone_geometries"] = override_zone_geometries
captured["override_section_assignments"] = override_section_assignments
captured["override_image_overrides"] = override_image_overrides
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
monkeypatch.setattr(sys, "argv", argv)
src_path = Path(_pz2.__file__)
source = src_path.read_text(encoding="utf-8")
tree = ast.parse(source)
for node in tree.body:
if (
isinstance(node, ast.If)
and isinstance(node.test, ast.Compare)
and isinstance(node.test.left, ast.Name)
and node.test.left.id == "__name__"
):
block = ast.Module(body=node.body, type_ignores=[])
exec(compile(block, str(src_path), "exec"), _pz2.__dict__)
return
raise AssertionError("no `if __name__ == '__main__'` block found")
def _redirect_overrides_root(tmp_path: Path, monkeypatch) -> None:
"""Redirect the on-disk persistence root so tests never touch
``data/user_overrides/``."""
monkeypatch.setattr(_io, "DEFAULT_OVERRIDES_ROOT", tmp_path)
def _write_full_payload(tmp_path: Path, stem: str = "03") -> Path:
path = tmp_path / f"{stem}.json"
path.write_text(
json.dumps(
{
"layout": "sidebar-right",
"frames": {"03-1": "frame_file_a", "03-1+03-2": "frame_file_b"},
"zone_geometries": {
"top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.3},
"bottom": {"x": 0.0, "y": 0.3, "w": 1.0, "h": 0.7},
},
"zone_sections": {
"top": ["03-1"],
"bottom": ["03-2", "03-3"],
},
"image_overrides": {
"img-file-a": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
"img-file-b": {"x": 50.0, "y": 50.0, "w": 40.0, "h": 40.0},
},
}
),
encoding="utf-8",
)
return path
# -- 1. no-file noop -------------------------------------------------------
def test_no_overrides_file_passes_none_overrides(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
assert captured["override_layout"] is None
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
# MDX path / run_id propagate untouched.
assert captured["mdx_path"] == Path("03.mdx")
assert captured["run_id"] is None
# -- 2. file fills every axis when CLI is empty ----------------------------
def test_file_only_fills_all_five_axes_when_cli_empty(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
_write_full_payload(tmp_path, "03")
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
assert captured["override_layout"] == "sidebar-right"
assert captured["override_frames"] == {
"03-1": "frame_file_a",
"03-1+03-2": "frame_file_b",
}
assert captured["override_zone_geometries"] == {
"top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.3},
"bottom": {"x": 0.0, "y": 0.3, "w": 1.0, "h": 0.7},
}
assert captured["override_section_assignments"] == {
"top": ["03-1"],
"bottom": ["03-2", "03-3"],
}
assert captured["override_image_overrides"] == {
"img-file-a": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
"img-file-b": {"x": 50.0, "y": 50.0, "w": 40.0, "h": 40.0},
}
# -- 3. CLI beats file on the same axis -----------------------------------
def test_cli_layout_overrides_file_layout(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
_write_full_payload(tmp_path, "03")
captured: dict[str, Any] = {}
_exec_main_block(
captured,
["src.phase_z2_pipeline", "03.mdx", "--override-layout", "two-column"],
monkeypatch,
)
# layout from CLI; remaining axes still filled from file.
assert captured["override_layout"] == "two-column"
assert captured["override_frames"] == {
"03-1": "frame_file_a",
"03-1+03-2": "frame_file_b",
}
assert captured["override_zone_geometries"] is not None
assert captured["override_section_assignments"] is not None
def test_cli_frames_overrides_file_frames(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
_write_full_payload(tmp_path, "03")
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-frame",
"03-1=cli_frame_x",
],
monkeypatch,
)
# CLI ``frames`` payload wholly replaces file ``frames`` (per-axis win).
assert captured["override_frames"] == {"03-1": "cli_frame_x"}
# Other axes still come from the file.
assert captured["override_layout"] == "sidebar-right"
assert captured["override_zone_geometries"] is not None
assert captured["override_section_assignments"] is not None
assert captured["override_image_overrides"] is not None
# -- 3b. CLI image override beats file image override (IMP-51 #79 u6) -----
def test_cli_image_override_overrides_file_image_overrides(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
_write_full_payload(tmp_path, "03")
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-cli=70,80,20,15",
],
monkeypatch,
)
# CLI ``image_overrides`` payload wholly replaces file payload (per-axis).
assert captured["override_image_overrides"] == {
"img-cli": {"x": 70.0, "y": 80.0, "w": 20.0, "h": 15.0},
}
# Other axes still come from the file.
assert captured["override_layout"] == "sidebar-right"
assert captured["override_frames"] is not None
assert captured["override_zone_geometries"] is not None
assert captured["override_section_assignments"] is not None
# -- 4. corrupt / non-object file warns and skips fallback ----------------
def test_corrupt_json_warns_and_skips_fallback(tmp_path, monkeypatch, capsys):
_redirect_overrides_root(tmp_path, monkeypatch)
(tmp_path / "03.json").write_text("{ not valid json", encoding="utf-8")
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
err = capsys.readouterr().err
assert "failed to read" in err
# ``or None`` collapses empty dicts back to None on the call site.
assert captured["override_layout"] is None
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
def test_non_object_top_level_warns_and_skips_fallback(
tmp_path, monkeypatch, capsys
):
_redirect_overrides_root(tmp_path, monkeypatch)
(tmp_path / "03.json").write_text("[1, 2, 3]", encoding="utf-8")
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
err = capsys.readouterr().err
assert "not a JSON object" in err
assert captured["override_layout"] is None
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
# -- 5. invalid MDX stem warns and skips fallback wholesale ---------------
def test_invalid_mdx_stem_warns_and_skips_fallback(
tmp_path, monkeypatch, capsys
):
_redirect_overrides_root(tmp_path, monkeypatch)
# Seed a file the loader would otherwise consume; the invalid stem must
# short-circuit before any read happens.
_write_full_payload(tmp_path, "03")
captured: dict[str, Any] = {}
# ``Path(".hidden.mdx").stem`` == ".hidden" → leading dot → InvalidOverrideKey.
_exec_main_block(
captured, ["src.phase_z2_pipeline", ".hidden.mdx"], monkeypatch
)
err = capsys.readouterr().err
assert "cannot derive persistence key" in err
assert captured["override_layout"] is None
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
# -- 6. per-axis partial fill (file fills only what CLI omits) ------------
def test_per_axis_partial_fill_mixes_cli_and_file(tmp_path, monkeypatch):
"""File carries frames + zone_geometries; CLI supplies layout only.
Expected: ``override_layout`` = CLI value, ``override_frames`` and
``override_zone_geometries`` = file values, ``override_section_assignments``
= None (neither side provided it).
"""
_redirect_overrides_root(tmp_path, monkeypatch)
(tmp_path / "03.json").write_text(
json.dumps(
{
"frames": {"03-1": "frame_only_file"},
"zone_geometries": {
"top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.5},
},
}
),
encoding="utf-8",
)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-layout",
"sidebar-right",
],
monkeypatch,
)
assert captured["override_layout"] == "sidebar-right"
assert captured["override_frames"] == {"03-1": "frame_only_file"}
assert captured["override_zone_geometries"] == {
"top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.5},
}
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
# -- 7. image_overrides fallback edge cases (IMP-51 #79 u6) ---------------
def test_image_overrides_fallback_drops_malformed_entries(tmp_path, monkeypatch):
"""File carries a mix of valid + malformed image_overrides entries.
Expected: valid entry survives; malformed entries (non-string id,
empty id, non-dict value, missing key, non-numeric value) are silently
dropped no exception propagates.
"""
_redirect_overrides_root(tmp_path, monkeypatch)
(tmp_path / "03.json").write_text(
json.dumps(
{
"image_overrides": {
"img-valid": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
"": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
"img-not-dict": "oops",
"img-missing-h": {"x": 1.0, "y": 2.0, "w": 3.0},
"img-bad-value": {"x": "abc", "y": 2.0, "w": 3.0, "h": 4.0},
}
}
),
encoding="utf-8",
)
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
assert captured["override_image_overrides"] == {
"img-valid": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
}
def test_image_overrides_fallback_non_dict_axis_is_ignored(tmp_path, monkeypatch):
"""File ``image_overrides`` is a non-dict (list); fallback silently skips."""
_redirect_overrides_root(tmp_path, monkeypatch)
(tmp_path / "03.json").write_text(
json.dumps({"image_overrides": ["not", "a", "dict"]}),
encoding="utf-8",
)
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
# ``overrides_images`` stays empty; ``or None`` collapses on call site.
assert captured["override_image_overrides"] is None
def test_image_overrides_fallback_coerces_int_values_to_float(tmp_path, monkeypatch):
"""JSON-loaded ints (e.g. ``10`` not ``10.0``) must coerce to float."""
_redirect_overrides_root(tmp_path, monkeypatch)
(tmp_path / "03.json").write_text(
json.dumps(
{"image_overrides": {"img-int": {"x": 10, "y": 20, "w": 30, "h": 40}}}
),
encoding="utf-8",
)
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
coerced = captured["override_image_overrides"]
assert coerced == {"img-int": {"x": 10.0, "y": 20.0, "w": 30.0, "h": 40.0}}
for axis_value in coerced["img-int"].values():
assert isinstance(axis_value, float)