Compare commits

Author SHA1 Message Date
Kyeongmin 2afedfc780 fix(catalog): track promoted family partials required by 13-family baseline
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
app_sw_package_vs_solution.html + pre_construction_model_info_stacked.html
were staged as new files but missing from prior commits. catalog
frame_contracts.yaml already references both (family=table / family=list);
this commit reconciles the on-disk partials with the registry so the
13-family baseline matches `git ls-tree` after a clean checkout.

No marker work (data-region-id / data-content-unit-id) — that axis stays
with the marker-injection issue. Disjoint from family/variant
architecture refactor (별 tracking issue).
2026-05-27 12:14:57 +09:00
KyeongminandClaude Opus 4.7 5484077a53 feat(#94): IMP-94 u1~u6 Layer A region/content marker injection (stamper + render_slide chain + 4 zones_data.append placement_markers + 35 parity tests)
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 21s
u1 (src/region_marker_stamper.py): deterministic root-div stamper injecting data-region-id + data-content-unit-id onto each family-partial root div anchored by data-template-id. Idempotent (re-stamp = no-op), AI=0, additive only, empty/None markers no-op, F9/F29 frame-slot axis preserved.

u2 (src/phase_z2_pipeline.py render_slide chain): _stamp_region_markers chained after IMP-56 u9 _stamp_zone_html. Marker source = zone.get("placement_markers") or [] — Codex #16 P4b crash risk closed via the or-[] call-site fallback.

u3 (_derive_placement_markers helper): projects PlacementPlan.slot_assignments[] → list[dict] carrying region_id + content_unit_id + frame_slot_id (frame_slot_id reserved for #96 89-d). Live B4 path emits at primary zones_data.append.

u4 (3 non-live zones_data.append defaults): placement_markers: [] at IMP-30 u4 empty-shell, IMP-86 u1 adapter_needed, post-loop unrenderable plan-record paths — uniform zone shape, stamper no-op surface.

u5/u6 (tests/test_phase_z2_imp94_marker_parity.py): 33 hard tests + 2 cross-axis skip-if-anchor-absent (Emergency P4/P4b future axis). Coverage: 13 family-partial root anchors, F29 + F9 frame-slot preservation, idempotence, live render_slide stamping, P4b empty-marker no-crash, MDX 01 strip-attr parity, trace-to-DOM parity.

Disjoint from #96 (data-frame-slot-id) by attribute name. SPEC anchor: docs/architecture/PHASE-Z-CONTENT-OBJECT-SUBZONE-SPEC.md §6.4 + §7.2 (Layer A read targets + render-path activation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:15:08 +09:00
Kyeongmin ed391af2e8 fix(orchestrator): P7a NameError in P7 KEEP_OPEN guard
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 21s
P7 Patch B used `comments[-1]` at line 1868 but `comments` is defined
inside run_stage, not run_issue scope. The KEEP_OPEN guard runs after
run_stage returns, where `comments` is no longer in scope, causing
NameError crash after Stage 6 YES was already accepted and exit report
generated.

Fix: fetch comments fresh via get_comments(n) at the guard entry.
exit_path file check (fallback) still works as designed.

Refs: #84 (Stage 6 crash during normal close path)
2026-05-26 14:30:21 +09:00
KyeongminandClaude Opus 4.7 b9747c2f4a feat(#84): IMP-84 u1~u3 silent automation policy enforcement (FramePanel reject confirm + slide_base provisional badge/outline + IMP-30 visual assertions inverted)
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 21s
- u1 FramePanel.tsx: extract `applyFrameSelection(candidate, onFrameSelect)`
  pure helper; collapse `handleFrameSelect` to direct onFrameSelect for every
  V4 label; drop `window.confirm` reject popup (IMP-47B u11 regression noise
  per `feedback_auto_pipeline_first`). New vitest pin `imp84_framepanel_reject_silent.test.ts`
  covers helper invocation across all 4 V4 labels + source-presence pins.
- u2 templates/phase_z2/slide_base.html: delete `.zone--provisional` CSS,
  `.zone__needs-adaptation-badge` CSS, the zone--provisional class fragment
  in the zone div, and the badge `<span>` render at the provisional zone.
  Preserve `data-provisional="1"` attribute as silent telemetry. New pytest
  `tests/phase_z2/test_imp84_provisional_silent_render.py` pins the silent
  contract independently of the IMP-30 first-render file.
- u3 tests/test_phase_z2_imp30_first_render.py: invert the three IMP-30 u5
  positive provisional-visual assertions to IMP-84 silent-contract negatives
  (no class, no badge, no CSS selectors); preserve positive `data-provisional`
  telemetry assertions. Docstrings updated to IMP-84 silent contract.

Out of scope (Round #4 + #92 contract): Home.tsx `toast.error(aiReviewMsg)`
call line, designAgentApi.ts `api_error_kinds`/`api_error_kind` schema and
operational-only formatter, FramePanel reject badge/tooltip read-only labels
(L102/L147/L156), and backend `zone.provisional` flag emission.

Stage 4 PASS: u1 vitest 10/10, u2 pytest 5/5, u3 pytest 29/29 (incl. 3
IMP-84 inverted assertions: `test_imp84_provisional_zone_silent_no_class_no_badge`,
`test_imp84_provisional_badge_never_rendered_in_mixed_zones`,
`test_imp84_slide_base_css_strips_provisional_visual_selectors`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:15:02 +09:00
KyeongminandClaude Opus 4.7 f0d4494409 fix(orchestrator): P7 governance guards for false-positive YES
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 21s
- Block Stage 2 YES when IMPLEMENTATION_UNITS contains tests: [].
- Prevent fallback from accepting orchestrator supplement examples as valid plans.
- Honor KEEP_OPEN/DO NOT CLOSE final-close dispositions by skipping close PATCH.
- Add final-close casual self-contradiction guard for YES bodies (allows explicit
  `disposition: KEEP_OPEN_*` to pass through to Patch B).
- Inject rejected approaches from failure reports into next-round context with
  BANNED_APPROACHES block (tests: [] / DOM mount without jsdom / Home.tsx toast
  removal / git add -A).

Refs: #83 (governance break — reopen pending user decision)
      #84 (Stage 2 round 5 slip — replay required after this fix)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:05:39 +09:00
KyeongminandClaude Opus 4.7 4da22adb43 feat(#90): IMP-56 u1-u19 catch-up before final close (post-u20 push fix)
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
u1: text_overrides axis in user_overrides_io
u2: structure_overrides axis in user_overrides_io
u3: vite allowlist for new endpoints
u4: text_override_resolver
u5: Step 12 text_overrides apply in phase_z2_pipeline
u6: structure_override_resolver
u7: text_path_stamper
u8: SlideCanvas text-edit capture
u9: SlideCanvas structure-edit overlay
u10: userOverridesApi service extension
u11: designAgent types extension
u12: slidePlanUtils restore
u13: user_overrides endpoint tests
u14: user_overrides restore tests
u15: pipeline fallback tests
u16: edit-mode state + gating tests
u17: slide_base print mode CSS
u18: /api/connect endpoint (vite)
u19: /api/export endpoint (vite)

Recovery scope: 29 files (12 modified + 17 new). u20 already pushed in
9439575; this commit lands u1-u19 that were authored but not committed
before #90 was externally closed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 06:12:13 +09:00
KyeongminandClaude Opus 4.7 943957562f feat(#90): IMP-56 u20 BottomActions wiring to /api/connect + /api/export (replace placeholder toasts + standalone HTML download + cel mirror connect; pure builders exported for vitest)
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
Stage 2 final unit for Step 22 (user edit + export). u20 wires the previously
placeholder bottom-action footer to the u18 /api/connect and u19 /api/export
middlewares living in Front/vite.config.ts:

- BottomActions.tsx
  • drops the dead `serializeSlidePlan` import (TS2305 blocker since u14;
    project-wide `tsc --noEmit` now exits 0)
  • exports three pure builders for vitest (no jsdom / RTL devDep needed):
      buildConnectRequest(run_id, slug) -> POST /api/connect {run_id, slug}
      buildExportRequest(run_id)        -> POST /api/export  {run_id}
      buildDownloadFilename(run_id)     -> "<run_id>.html"
  • handleExport: POST -> blob -> a[download] click chain; toast on
    success / failure / network error.
  • handleConnect: derives slug via deriveUserOverridesKey(uploadedFile.name)
    and PUTs to u18 cel mirror; reports assets_copied count.
  • both buttons disable when runMeta is null so the UI cannot fire
    requests with an undefined run_id.

- Home.tsx
  • mounts <BottomActions/> in the footer with
    {slidePlan, runMeta, uploadedFile, isLoading, onGenerate}.
  • removes 2 of 3 placeholder `toast.info('… 준비 중입니다.')` buttons
    (LeftMdxPanel MDX-edit placeholder remains — out of u20 scope).
  • adds handleTextEdit (u15 wire to text_overrides axis) and
    handleStructureEdit (u15 wire to structure_overrides axis) to satisfy
    the SlideCanvas props introduced earlier in the u-series.

- imp90_bottom_actions.test.ts (new)
  • 11 vitest specs locking the builder URL + JSON shape against u18/u19
    middleware contracts. Verified 11/11 pass.

Stage 4 verification (all PASS):
  • u20 vitest: 11/11
  • u18/u19 endpoint vitest: 31/31
  • npx tsc --noEmit: exit 0 (carry-forward TS2305 resolved)
  • backend pytest (u1~u9 + u17 print mode, 9 files): 185/185

Out of scope:
  • LeftMdxPanel.tsx:333 MDX-edit placeholder toast (separate unit)
  • #1 / #72 / #74 / #79 / #80 / #81 / #93 closed dependencies (no re-impl)
  • AI-generated HTML structure (Phase Z regression guard)
  • frame swap via structure_overrides (locked to slot_order + hidden_slots)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 02:31:38 +09:00
Kyeongmin ec7471ed59 docs(#1): IMP-01 A-6 u1~u5 zone_geometries_px runtime verification log (driver chain + 4-topology runs + schema lock + no-drift guardrail + pytest baseline gate; production source untouched, impl at 1dc81e0)
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
2026-05-25 15:49:23 +09:00
KyeongminandClaude Opus 4.7 4e281a20d8 feat(#93): IMP-55 u1~u12 frontend manual section swap detection (manual_section_assignment bool axis + drag-only marker gate + dual-axis persistence + backend manual-true gate)
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 9s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:27:09 +09:00
KyeongminandClaude Opus 4.7 9062931863 feat(#74): IMP-45 u1~u8 slide-level CSS override (frontmatter slide_overrides.css + --override-slide-css/--slide-css-file + idempotent Step 13 injector)
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 22s
u1 KNOWN_AXES tuple gains slide_css entry in src/user_overrides_io.py
(snake_case parity with image_overrides); round-trip test extends
to 6 axes.
u2 src/mdx_normalizer.py surfaces nested slide_overrides.css from the
MDX frontmatter into the normalize_mdx_content return dict; absent
key -> {}, non-string css drops. 4 unit cases in tests/test_mdx_normalizer.py
(present / absent / non-string / title-only).
u3 src/slide_css_injector.py NEW (88 lines) mirrors the
inject_image_overrides_style contract from src/image_id_stamper.py:
marker pair <!--IMP45-SLIDE-CSS:OPEN--> / <!--IMP45-SLIDE-CSS:CLOSE-->,
idempotent re-injection, </head> > <body> > document-start three-tier
fallback, empty/None -> unchanged. 8 fixtures in
tests/test_slide_css_injector.py mirror test_image_id_stamper.py.
u4 run_phase_z2_mvp1 accepts override_slide_css: Optional[str] = None;
None -> frontmatter slide_overrides.css fallback. Step 13 calls
inject_slide_css after image override injection and before the
final.html disk write, so CLI/CI/regression renders observe the same
backend artifact.
u5 argparse adds mutually-exclusive --override-slide-css TEXT (inline
CSS, <style> wrapper optional) and --slide-css-file PATH (UTF-8 read,
fail-closed sys.exit(2) on missing path / decode error / both flags
present). Resolved string is forwarded as override_slide_css kwarg.
6 cases in tests/test_phase_z2_cli_overrides.py (inline / file / both
/ missing / non-utf8 / neither).
u6 samples/mdx_batch/04.mdx frontmatter gains slide_overrides.css
block (verbatim of the former MDX04_DEFAULT_OVERRIDE_CSS constant,
no sample/frame gate). Subprocess smoke in
tests/test_phase_z2_slide_css_smoke.py verifies the marker pair and
CSS substring land in final.html.
u7 Front/client removes the sample/frame-gated frontend-only injection:
Home.tsx drops the MDX04_DEFAULT_OVERRIDE_CSS constant and the
sample==="04"+frame==="process_product_two_way" branch (-28 lines);
SlideCanvas.tsx drops the iframe contentDocument.head injection of
that prop (-14 lines). Live preview now reads backend final.html only.
u8 tests/regression/fixtures/89a_pre_baseline_sha.json 04.mdx entry
resyncs to the live SHA ddb6bf2f... / 28042 bytes (overwrites the
earlier 5-byte-drift d02c76fd... / 28047). Other entries untouched.
Note: 01.mdx baseline drift (ad6f16a3... / 29089 -> live f26a7fac...
/ 29084) predates this branch and is split to a follow-up issue per
the closed-issue fresh validation rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:26:03 +09:00
KyeongminandClaude Opus 4.7 b4be6c1cd0 feat(#72): IMP-43 u1~u8 --reuse-from incremental rerun (Step 0/1/2/5/6 reuse + Step 7+ re-execute)
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 25s
u1 argparse --reuse-from PREV_RUN_ID + post-merge fail-closed guard (rejects
layout/zone_geometry/zone_section/image override axes by name; only
--override-frame is preserved).
u2 src/phase_z2_reuse_snapshot.py — JSON-only Step 6 snapshot with mdx_sha256
integrity key and {value, source_path, upstream_step} provenance per axis
(pickle forbidden per Stage 2 guardrail).
u3 _write_reuse_snapshot at the Step 6 boundary; soft-fails to stderr without
aborting the seed run.
u4 prev_run_dir RO copy of step00/01/02/05/06 + _reuse_snapshot.json into
new run_dir, state rehydration, reuse marker, frame-override application on
restored units, Step 7+ resume.
u4b fail-closed for missing prev_run_dir / missing/corrupt/invalid snapshot /
mdx_sha256 mismatch / accidental new==prev write, with value+path+upstream
diagnostics per axis.
u5 reuse_from Optional[str] threaded through run_phase_z2_mvp1 signature and
CLI dispatch; default None preserves byte-identical pre-IMP-43 behavior.
u6 Front /api/run optional reuseFromRunId forwarding (vite.config.ts +
designAgentApi.ts + run_pipeline_reuse_from.test.ts).
u7a fast CI equivalence (1 mdx × 1 layout × 2 frames); step13 whitelist =
run_id/timestamps/prev_run_id only. u7b 3 layouts × 3 mdx × 32 frames
sweep gated by pytest.mark.sweep (registered in pyproject.toml; default CI
must use -m 'not sweep').
u8 scripts/measure_reuse_savings.py argv-driven A/B/C harness with frame
pin self-discovery + seed-time exclusion; status board §8 TBD anchor
(issue-body 50-70% / 10-20s→3-8s claim explicitly unverified, not mirrored).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:44:27 +09:00
KyeongminandClaude Opus 4.7 8648a468d9 feat(#69): IMP-40 u1~u6 frame contract label_default placeholder/fallback role discriminator (BIM/DX leak fix)
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 26s
- catalog (frame_contracts.yaml): F18 bim_dx_comparison_table col_a/col_b
  label_default_role=placeholder; F30 industry_current_status_three_col +
  F31 industry_characteristics_three_col col_a/col_b/col_c forward-compat
  placeholder; F33 engn_sw_three_types untouched (no label_default).
- mapper (_build_compare_table_2col): generic _resolve_label_default(col_key)
  branches on <col>_label_default_role — placeholder -> '' (Figma placeholder
  suppressed at runtime), fallback -> catalog literal (legacy default), unknown
  -> ValueError with template_id + role_key + value. Absent role defaults to
  fallback (backward compat for contracts without discriminator).
- tests (tests/phase_z2/test_imp40_label_default_role.py): u4 generic matrix
  (placeholder / fallback / absent / unknown / 3-col axis) + u5 F18-reuse
  non-BIM/DX synthetic rows asserting placeholder labels emit '' and BIM/DX
  literal tokens do not leak.
- snapshot (tests/integration/__snapshots__/slot_payload.json): mdx 01 F18
  string_slot_nonempty.col_a_label/col_b_label True -> False (u6 expected
  drift from u3 placeholder -> empty string flip). slot_names + rows + title
  preserved.

Verification:
- imp40_label_default_role: 6/6 PASSED
- phase_z2 sweep: 608/608 PASSED
- multi_mdx_regression: 50/50 PASSED
- cross-suite sweep: 662/662 PASSED
- BIM/DX literal grep on mapper + new test: 0 hits
- No mdx-specific branches (mdx 03/04/05 grep on mapper: 0 hits)

Guardrails: no MDX 03/04/05 hardcoding (catalog policy only); no spacing
shrink; no auto frame swap on reject; no AI call at Step 12; F33 untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:53:20 +09:00
KyeongminandClaude Opus 4.7 028042aaa9 feat(#68): IMP-39 u1~u8 ranking_sort_policy single-source + backend↔frontend label-priority mirror
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 23s
u1: templates/phase_z2/catalog/ranking_sort_policy.yaml — single-source policy
    (label_priority asc {use_as_is:0, light_edit:1, restructure:2, reject:3}
    + confidence desc + v4_rank asc tie-break).
u2: src/phase_z2_pipeline.py — apply_ranking_sort helper + lookup_v4_match_with_fallback
    applies policy AFTER IMP-38 raw-window selection (raw default_window + usable_count
    preserved on RAW all_judgments).
u3: src/phase_z2_pipeline.py — _build_application_plan_unit forwards ranking_sort_policy
    + sorted_candidate_evidence into Step 9 payload.
u4: Front/client/src/services/designAgentApi.ts — frame_candidates builder reads
    unit.sorted_candidate_evidence + unit.ranking_sort_policy first; local LABEL_PRIORITY
    retained only on warn-fallback path.
u5: tests/test_ranking_sort_policy.py — pure permutation coverage (sample-agnostic).
u6: tests/phase_z2/test_label_priority_synthetic.py + fixtures/ranking_sort_policy/
    synthetic_divergence.yaml — low-conf use_as_is behind high-conf restructure.
u7: tests/phase_z2/test_imp39_mdx04_env_toggle_e2e.py — samples/mdx_batch/04.mdx with
    AI_FALLBACK_ENABLED=off; backend selected_v4_rank == frontend frame_candidates[0].
u8: tests/phase_z2/test_imp39_corpus_audit.py — real corpus sweep over
    tests/matching/v4_full32_result.yaml (10 MDX sections); section IDs loaded
    dynamically (RULE 0 / RULE 7 sample-agnostic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:12:07 +09:00
KyeongminandClaude Opus 4.7 2e3747c5ab feat(#88): IMP-88 u1~u7 Step 17 retry chain — layout_adjust + image_fit + frame_internal_fit_candidate executors + dispatcher + entry
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 23s
Step 17 salvage dispatcher previously only ran the 3 actions in
_SALVAGE_FAIL_BY_ACTION (cross_zone_redistribute / glue_compression /
font_step_compression). Any next_proposed_action outside that set hit
salvage_terminal_action and dropped through, so visual_check aborted on
layout_adjust / image_fit / frame_internal_fit_candidate cascades.

u1 — router data surface (src/phase_z2_router.py)
  - ACTION_BY_CATEGORY: image_aspect_mismatch -> image_fit (new row),
    frame_capacity_mismatch -> frame_internal_fit_candidate (was
    frame_reselect).
  - ACTION_IMPLEMENTATION_STATUS: layout_adjust / image_fit /
    frame_internal_fit_candidate flipped MISSING -> IMPLEMENTED with
    inline IMP-88 rationale.

u2 — failure_router cascade surface (src/phase_z2_failure_router.py)
  - FAILURE_TYPE_DESCRIPTIONS + SALVAGE_FAILURE_TYPE_BY_ACTION extended
    with layout_adjust_insufficient / image_fit_insufficient /
    frame_internal_fit_insufficient producers.
  - NEXT_ACTION_BY_FAILURE + NEXT_ACTION_RATIONALE +
    NEXT_ACTION_IMPLEMENTATION_STATUS rows added; cascade chain becomes
    font_step_compression -> layout_adjust -> frame_internal_fit_candidate
    -> frame_reselect -> details_popup_escalation (#64 terminal).

u3~u5 — planners + apply helpers (src/phase_z2_retry.py)
  - plan_layout_adjust / apply_layout_adjust_layout_css with
    _layout_swap_priority across 8-preset LAYOUT_PRESETS (preset switch,
    no shared-margin shrink per Phase Z spacing direction).
  - plan_image_fit / apply_image_fit_css scoped to frame slot using
    existing classifier image_event payload (object-fit + max-w/h
    derivation).
  - plan_frame_internal_fit_candidate / apply_frame_internal_fit_candidate_css
    stays inside declared frame contract envelope; emits infeasible path
    when envelope is absent.

u6~u7 — pipeline wiring (src/phase_z2_pipeline.py)
  - _SALVAGE_FAIL_BY_ACTION extended; _attempt_salvage_chain gains
    layout_adjust distinct-render branch + frame_internal_fit_candidate
    CSS-overlay branch + loop cap.
  - _attempt_step17_image_fit_single_pass added for image_fit entry.
  - §11.7.1 / §11.7.2 entry triggers wired; Step 17/18/19 artifact
    refresh + note logging closes the salvage_terminal_action fall-through
    for the 3 IMP-88 actions.

Tests
  - New: test_router_actions_imp88.py (12),
    test_failure_router_imp88_cascade.py (12),
    test_phase_z2_retry_layout_adjust.py (10),
    test_phase_z2_retry_image_fit.py (13),
    test_phase_z2_retry_frame_internal_fit.py (13),
    test_phase_z2_pipeline_salvage_imp88.py (8),
    test_phase_z2_pipeline_step17_entry_imp88.py.
  - Regression-aligned: test_phase_z2_failure_router_cascade.py,
    test_phase_z2_step17_salvage_chain.py — pre-existing cascade +
    salvage-chain assertions updated to the IMPLEMENTED surface.

Out of scope (separate axes / issues)
  - details_popup_escalation terminal body (#64).
  - frame_reselect MISSING flip (different axis).
  - Step 14/16 detection refinement.
  - Stage 0 mdx_normalizer integration (locked 2026-05-08).
  - AI fallback activation.

Guardrails respected
  - Phase Z spacing direction: layout_adjust switches preset; no shared
    margin shrink.
  - AI isolation contract: planners + dispatcher are deterministic; zero
    AI calls in u1~u7.
  - No hardcoding: routing + cascade live in router/failure_router data
    rows, not inline conditionals.
  - IMP-46 (#62) cache carve-out: untouched.
  - 1 commit = 1 decision unit: u1~u7 grouped as a single IMP-88 unit.

Stage 4 verification: 7 IMP-88 test files + 2 modified regression files
PASS (Claude #12 + Codex #12 consensus YES). Full-suite sweep deferred to
a separate step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:01:55 +09:00
KyeongminandClaude Opus 4.7 e0c39f1bc1 feat(#73): IMP-44 u1~u5 layout override unknown-key guard + frontend zone_geometries validation
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 23s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:12:24 +09:00
KyeongminandClaude Opus 4.7 5deeb97cf6 feat(#71): IMP-42 u1~u5 silent fail chain diagnostics (assert + invalid-char detector + DIAG log)
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 24s
Stage 4 binding scope — diagnostic-only, fail-loud, sample-agnostic
(RULE 0 / AI-isolation contract). No production behavior change beyond
fail-loud raises on previously-silent failure classes.

u1 src/phase_z2_pipeline.py:2747-2772 — render_slide precondition assert
   (template_id non-empty str + slot_payload dict), placed after the
   `__empty__` short-circuit at 2740 to preserve empty-zone grid behavior.
u2 src/phase_z2_pipeline.py:2681-2710 — _scan_rendered_html_for_invalid_path_chars
   helper covering src / href / url(...) values for backslash, &amp;, &#39;.
   Invoked on partial render (2778) and slide_base assembly (2798).
u3 src/phase_z2_pipeline.py:2638-2676,2733,5509 — _emit_diag_zones_shape
   shape-only [DIAG] JSON at Step 12 slot_payload emit and Step 13
   render_slide entry. No env gate — silence is the bug.
u4 Front/client/src/pages/Home.tsx:388-392 — unconditional [DIAG raw overrides]
   console.log on handleGenerate boundary, after flushUserOverrides() and
   immediately before runPipeline.
u5 tests/phase_z2/test_phase_z2_diag_smoke_general.py — 32-frame general
   smoke driven by load_frame_contracts() registry (not literal MDX 03/04/05),
   parametrizes u1/u2/u3 across the full frame_contracts.yaml top-level.

Tests (Stage 4 verification PASS):
- u1 8 passed, u2 14 passed, u3 12 passed, u4 5 passed, u5 97 passed.
- Backend full regression tests/phase_z2/ 499 passed in 110.84s.
- Frontend full regression 182 passed in 1.10s.

Out of scope (separate axes):
- Path normalization / as_posix migration.
- Autoescape policy change.
- build_layout_css refactor (Stage 1 category-error rejection).
- Recovery / auto-fix on detected invalid path.
- MDX content / frame-selection / zone-composition change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:54 +09:00
KyeongminandClaude Opus 4.7 c59864eb9a feat(#91): IMP-91 u2~u15 multi-mdx regression CI suite + status-board auto-update
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 31s
- u2~u5: tests/integration/test_multi_mdx_regression.py — MDX_SET=(01..05)
  cached integration runs + status/structural/visual snapshots +
  full_mdx_coverage assertion (9 snapshots populated for 01-05).
- u6~u11: F0 normalize / F1 V4 ranking / F2 slot_payload /
  F3 classifier-only AI / F4 layout / F5 final.html axis per MDX_SET.
- u12: pyproject.toml — pytest-json-report>=1.5 in dev extras.
- u13: .github/workflows/multi-mdx-regression.yml — pytest+artifact CI.
- u14: scripts/update_status_board.py + tests/scripts/test_update_status_board.py
  — idempotent JSON marker updater (3 unit tests pass).
- u15: PHASE-Z-PIPELINE-STATUS-BOARD.md — 30 F0-F5 × mdx01-05 markers
  initialized `?` + workflow wiring.

Stage 4 verify: 59/59 PASS targeted (smoke 6 + updater 3 + integration 50),
386/386 PASS regression umbrella, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:01:58 +09:00
108 changed files with 22576 additions and 1047 deletions
@@ -0,0 +1,71 @@
name: Multi-MDX Regression (IMP-91)
# IMP-#91 u13 — auto-gate the mdx 01-05 acceptance set on every push to main
# and on PRs targeting main. Failure of any integration test blocks the
# commit. JSON report is emitted via pytest-json-report (u12 dep) and
# uploaded as an artifact for u14/u15 status-board updater consumption.
#
# [[feedback_validation_first_for_closed_issues]] — fresh subprocess per CI run.
# [[feedback_auto_pipeline_first]] — no manual review queue; deterministic gate.
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
multi-mdx-regression:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- name: Install Chrome and ChromeDriver
uses: browser-actions/setup-chrome@v1
with:
install-chromedriver: true
- name: Install project (dev extras + selenium)
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
python -m pip install "selenium>=4.20"
- name: Run multi-mdx regression tests
run: |
python -m pytest -q -m integration \
tests/integration/test_multi_mdx_regression.py \
--json-report \
--json-report-file=imp91-report.json \
--json-report-omit keywords streams
- name: Upload pytest JSON report
if: always()
uses: actions/upload-artifact@v4
with:
name: imp91-multi-mdx-report
path: imp91-report.json
if-no-files-found: warn
- name: Update status-board markers (IMP-91 u15)
if: always()
run: |
python scripts/update_status_board.py \
--report imp91-report.json \
--board docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md
- name: Upload updated status board
if: always()
uses: actions/upload-artifact@v4
with:
name: imp91-status-board
path: docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md
if-no-files-found: warn
+141 -40
View File
@@ -1,99 +1,200 @@
/** /**
* BottomActions - 하단 액션 버튼 영역 * BottomActions — Step 22 footer wire-up (IMP-56 #90 u20).
* *
* 생성하기, 다운로드, 연동하기 버튼 컴포넌트 * Two real endpoints replace the prior placeholder toasts:
* • POST /api/connect (u18 / Front/vite.config.ts) — copies
* data/runs/<run_id>/phase_z2/final.html + assets/ into the cel mirror
* (`<CEL_PROJECT_ROOT>/public/slides/<slug>.html`).
* • POST /api/export (u19 / Front/vite.config.ts) — returns a standalone
* text/html body with every `url(assets/...)` ref inlined as base64
* data URLs. Response is piped into a Blob → a[download] click chain so
* the user receives `<run_id>.html` portable for file:// or any host.
*
* The prior `serializeSlidePlan` JSON-download path was a dead reference
* (the export never existed in slidePlanUtils) and is removed here — the
* "다운로드" button now means standalone HTML download via /api/export.
* Both buttons disable when no run is loaded (runMeta == null) so the
* UI cannot fire requests with an undefined run_id.
*/ */
import { useState } from "react";
import { Sparkles, Download, Link2, Loader2 } from "lucide-react"; import { Sparkles, Download, Link2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
import type { SlidePlan, UserSelection } from "../types/designAgent"; import type { SlidePlan } from "../types/designAgent";
import { serializeSlidePlan } from "../utils/slidePlanUtils"; import type { RunMeta } from "../services/designAgentApi";
import { deriveUserOverridesKey } from "../utils/slidePlanUtils";
// ─── pure request builders (exported for vitest; jsdom-free) ─────────────
// The component below uses these verbatim. Each returns a {url, body} pair
// so the test surface is the *literal* HTTP payload sent to the u18 / u19
// middlewares — any future shape drift fails here before the network call.
export function buildConnectRequest(
run_id: string,
slug: string,
): { url: string; body: string } {
return {
url: "/api/connect",
body: JSON.stringify({ run_id, slug }),
};
}
export function buildExportRequest(
run_id: string,
): { url: string; body: string } {
return {
url: "/api/export",
body: JSON.stringify({ run_id }),
};
}
export function buildDownloadFilename(run_id: string): string {
return `${run_id}.html`;
}
interface BottomActionsProps { interface BottomActionsProps {
slidePlan: SlidePlan | null; slidePlan: SlidePlan | null;
userSelection: UserSelection; runMeta: RunMeta | null;
uploadedFile: File | null;
isLoading: boolean; isLoading: boolean;
onGenerate: () => void; onGenerate: () => void;
} }
export default function BottomActions({ export default function BottomActions({
slidePlan, slidePlan,
userSelection, runMeta,
uploadedFile,
isLoading, isLoading,
onGenerate, onGenerate,
}: BottomActionsProps) { }: BottomActionsProps) {
const handleDownload = () => { const [isConnecting, setIsConnecting] = useState(false);
if (!slidePlan) { const [isExporting, setIsExporting] = useState(false);
toast.error("슬라이드 플랜이 없습니다. 먼저 생성하기를 눌러주세요.");
const runReady = !!runMeta && !!slidePlan;
const handleExport = async () => {
if (!runMeta) {
toast.error("Run 산출물이 없습니다. 먼저 생성하기를 눌러주세요.");
return; return;
} }
setIsExporting(true);
const json = serializeSlidePlan(slidePlan, userSelection); try {
console.log("[Download] SlidePlan JSON:", json); const exportReq = buildExportRequest(runMeta.run_id);
const resp = await fetch(exportReq.url, {
// JSON 파일 다운로드 method: "POST",
const blob = new Blob([json], { type: "application/json" }); headers: { "Content-Type": "application/json" },
const url = URL.createObjectURL(blob); body: exportReq.body,
});
if (!resp.ok) {
const text = await resp.text();
toast.error(`Export 실패 (${resp.status}): ${text.slice(0, 160)}`);
return;
}
const blob = await resp.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = objectUrl;
a.download = `slide-plan-${Date.now()}.json`; a.download = buildDownloadFilename(runMeta.run_id);
document.body.appendChild(a);
a.click(); a.click();
URL.revokeObjectURL(url); a.remove();
URL.revokeObjectURL(objectUrl);
toast.success("SlidePlan JSON이 다운로드되었습니다."); toast.success(`standalone HTML 다운로드 — ${runMeta.run_id}.html`);
} catch (err) {
toast.error(`Export 네트워크 오류: ${(err as Error).message}`);
} finally {
setIsExporting(false);
}
}; };
const handleConnect = () => { const handleConnect = async () => {
toast.info("연동하기 기능은 파이프라인 연결 후 활성화됩니다."); if (!runMeta) {
toast.error("Run 산출물이 없습니다. 먼저 생성하기를 눌러주세요.");
return;
}
if (!uploadedFile) {
toast.error("MDX 파일이 없습니다 — slug 도출 불가.");
return;
}
const slug = deriveUserOverridesKey(uploadedFile.name);
setIsConnecting(true);
try {
const connectReq = buildConnectRequest(runMeta.run_id, slug);
const resp = await fetch(connectReq.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: connectReq.body,
});
const payload = (await resp.json().catch(() => ({}))) as {
success?: boolean;
assets_copied?: number;
error?: string;
};
if (!resp.ok || !payload.success) {
toast.error(
`Connect 실패 (${resp.status}): ${payload.error ?? "unknown error"}`,
);
return;
}
toast.success(
`cel 미러 연동 완료 — ${slug}.html (assets ${payload.assets_copied ?? 0}개 복사)`,
);
} catch (err) {
toast.error(`Connect 네트워크 오류: ${(err as Error).message}`);
} finally {
setIsConnecting(false);
}
}; };
return ( return (
<div className="flex items-center justify-center gap-3 px-6 py-3 bg-white border-t border-slate-200"> <div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 mr-2">
<span className="w-5 h-5 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center font-bold">4</span>
<span className="text-xs text-slate-500 font-medium">액션</span>
</div>
{/* 생성하기 */}
<Button <Button
onClick={onGenerate} onClick={onGenerate}
disabled={isLoading} disabled={isLoading}
className="gap-2 min-w-[120px]" className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest bg-slate-900 hover:bg-slate-800"
size="default" size="default"
> >
{isLoading ? ( {isLoading ? (
<> <>
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-3.5 h-3.5 animate-spin" />
생성 중... 생성 중...
</> </>
) : ( ) : (
<> <>
<Sparkles className="w-4 h-4" /> <Sparkles className="w-3.5 h-3.5" />
생성하기 생성하기
</> </>
)} )}
</Button> </Button>
{/* 다운로드 */}
<Button <Button
variant="outline" variant="outline"
onClick={handleDownload} onClick={handleExport}
disabled={!slidePlan || isLoading} disabled={!runReady || isExporting || isLoading}
className="gap-2 min-w-[120px]" className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"
size="default" size="default"
> >
<Download className="w-4 h-4" /> {isExporting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Download className="w-3.5 h-3.5" />
)}
다운로드 다운로드
</Button> </Button>
{/* 연동하기 */}
<Button <Button
variant="outline" variant="outline"
onClick={handleConnect} onClick={handleConnect}
className="gap-2 min-w-[120px] text-slate-500" disabled={!runReady || isConnecting || isLoading}
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"
size="default" size="default"
> >
<Link2 className="w-4 h-4" /> {isConnecting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Link2 className="w-3.5 h-3.5" />
)}
연동하기 연동하기
</Button> </Button>
</div> </div>
+15 -10
View File
@@ -20,6 +20,19 @@ interface FramePanelProps {
onNoDesignToggle: () => void; onNoDesignToggle: () => void;
} }
// IMP-#84 u1 — silent-automation contract: frame selection delegates directly
// to onFrameSelect for every V4 label (use_as_is / light_edit / restructure /
// reject). Prior IMP-47B u11 surfaced a window.confirm popup on reject; that
// popup is informational UI noise per `feedback_auto_pipeline_first` and is
// removed. Frame identity is preserved on reject (AI 재구성 = content-only,
// per AI 격리 contract); the popup never gated that contract.
export function applyFrameSelection(
candidate: FrameCandidate,
onFrameSelect: (frameId: string) => void,
): void {
onFrameSelect(candidate.id);
}
export default function FramePanel({ export default function FramePanel({
slidePlan, slidePlan,
selectedZone, selectedZone,
@@ -49,17 +62,9 @@ export default function FramePanel({
const handleFrameSelect = React.useCallback( const handleFrameSelect = React.useCallback(
(candidate: FrameCandidate) => { (candidate: FrameCandidate) => {
const isReject = candidate.label === "reject"; applyFrameSelection(candidate, onFrameSelect);
const alreadyApplied = currentFrameId === candidate.id;
if (isReject && !alreadyApplied) {
const ok = window.confirm(
`"${candidate.name}" 은 V4 reject 라벨입니다.\n선택 시 frame 은 유지되고 AI 가 콘텐츠를 frame 구조에 맞게 재구성합니다.\n계속하시겠습니까?`,
);
if (!ok) return;
}
onFrameSelect(candidate.id);
}, },
[currentFrameId, onFrameSelect], [onFrameSelect],
); );
if (!selectedZone) { if (!selectedZone) {
+294 -54
View File
@@ -28,7 +28,12 @@ import {
crossedDragThreshold, crossedDragThreshold,
type ImageDragDirection, type ImageDragDirection,
} from "./slideCanvasDragMath"; } from "./slideCanvasDragMath";
import type { ImageOverridesOverride } from "../services/userOverridesApi"; import type {
ImageOverridesOverride,
StructureOverridesOverride,
StructureOverridePerZone,
} from "../services/userOverridesApi";
import StructureEditOverlay from "./StructureEditOverlay";
interface SlideCanvasProps { interface SlideCanvasProps {
slidePlan: SlidePlan | null; slidePlan: SlidePlan | null;
@@ -36,10 +41,6 @@ interface SlideCanvasProps {
userSelection: UserSelection; userSelection: UserSelection;
/** Phase Z 가 만든 final.html URL (iframe 으로 표시). */ /** Phase Z 가 만든 final.html URL (iframe 으로 표시). */
finalHtmlUrl?: string; finalHtmlUrl?: string;
/** 슬라이드 단위 inline CSS override (catalog/template 무변, iframe contentDocument 에
* 동적 inject). Home 이 mdx 별 default visual 보완 등을 지정. 빈 문자열/undefined =
* inject 안 함. 사용자 lock 2026-05-14 — slide-level only. */
slideOverrideCss?: string;
/** 파이프라인 실행 중 표시 (loading state). */ /** 파이프라인 실행 중 표시 (loading state). */
isPipelineRunning?: boolean; isPipelineRunning?: boolean;
/** Phase 2 : pending layout 모드 — final.html iframe 숨기고 빈 layout zone 만 표시. */ /** Phase 2 : pending layout 모드 — final.html iframe 숨기고 빈 layout zone 만 표시. */
@@ -77,16 +78,116 @@ interface SlideCanvasProps {
imageId: string, imageId: string,
geometry: { x: number; y: number; w: number; h: number } geometry: { x: number; y: number; w: number; h: number }
) => void; ) => void;
/** IMP-90 (#90) u13 — focusout-emitted capture; u15 debounces + PUTs. */
onTextEdit?: (capture: TextEditCapture) => void;
/** IMP-90 (#90) u14 — persisted structure overrides per zone
* (slot_order + hidden_slots). When `editMode === "structure"` the
* StructureEditOverlay reads from this to render the current state. */
structureOverrides?: StructureOverridesOverride;
/** IMP-90 (#90) u14 — emitted whenever the user reorders or hides a
* slot in structure-mode. u15 will debounce + PUT to /api/user-
* overrides; u14 only exposes the capture. SCOPE LOCK: inner shape is
* `{slot_order, hidden_slots}` only (frame swap stays on `frames` axis). */
onStructureEdit?: (zoneId: string, capture: StructureOverridePerZone) => void;
} }
const SLIDE_W = 1280; const SLIDE_W = 1280;
const SLIDE_H = 720; const SLIDE_H = 720;
// IMP-90 (#90) u11 — discriminated edit mode. Replaces the prior single
// `isEditMode` boolean. u11 introduces the enum + the toolbar UI surface;
// gesture gating (text contentEditable vs structure reorder vs image-zone
// drag/resize) stays unified behind `isEditMode = editMode !== 'off'` so
// existing behavior is preserved byte-identical. u12 will discriminate the
// gestures per mode (mutually exclusive). The 'off' state is the no-edit
// baseline; 'image-zone' bundles image edit (#79) + zone resize (#81)
// because both are pointer-driven canvas gestures on slide geometry.
export type EditMode = "off" | "text" | "structure" | "image-zone";
export const EDIT_MODES: ReadonlyArray<EditMode> = ["text", "structure", "image-zone"];
/** Pure helper — given the current edit mode and the user's requested mode,
* return the next mode. Clicking the active mode toggles back to 'off';
* clicking a different mode switches; explicit 'off' always exits. */
export function nextEditMode(current: EditMode, requested: EditMode): EditMode {
if (requested === "off") return "off";
return current === requested ? "off" : requested;
}
// IMP-90 (#90) u12 — per-mode gesture gating. Pure helper deriving the
// boolean gates that drive SlideCanvas's useEffect branches (designMode
// + iframe-side image click listener) and JSX conditionals (iframe
// pointer-events, zone resize/drag affordances, image overlay). The
// mapping enforces the mutually-exclusive contract from the issue body:
// text -> contentEditable + iframe pointer-events:auto only.
// structure -> nothing here; u14 will plant the structure overlay.
// image-zone -> zone resize/drag + image overlay; iframe pe:auto so
// in-iframe user-content images can be click-selected.
// off -> every gate false (baseline).
// pendingLayout fully suppresses every gate — mirrors the existing
// useEffect (line ~248) that forces editMode='off' on pendingLayout
// entry. The helper still defensively returns all-false so a stray
// pendingLayout=true with a non-'off' editMode never leaks gestures.
export interface EditModeGates {
textEditing: boolean;
imageSelection: boolean;
iframePointerAuto: boolean;
zoneGestures: boolean;
imageOverlay: boolean;
}
export function computeEditModeGates(
editMode: EditMode,
isPendingLayout: boolean
): EditModeGates {
if (isPendingLayout) {
return {
textEditing: false,
imageSelection: false,
iframePointerAuto: false,
zoneGestures: false,
imageOverlay: false,
};
}
return {
textEditing: editMode === "text",
imageSelection: editMode === "image-zone",
iframePointerAuto: editMode === "text" || editMode === "image-zone",
zoneGestures: editMode === "image-zone",
imageOverlay: editMode === "image-zone",
};
}
// IMP-90 (#90) u13 — pure helper resolving a contentEditable focusout
// target into (zoneId, textPath, value). data-text-path stamped by u8 at
// Step 13; .zone[data-zone-position] from Phase Z slide-base. Non-stamped
// targets return null so capture silently skips. u15 will debounce + PUT.
export interface TextEditCaptureTarget {
closest(selector: string): TextEditCaptureTarget | null;
getAttribute(name: string): string | null;
textContent: string | null;
}
export interface TextEditCapture {
zoneId: string;
textPath: string;
value: string;
}
export function deriveTextEditCapture(
target: TextEditCaptureTarget | null
): TextEditCapture | null {
if (!target) return null;
const lineEl = target.closest("[data-text-path]");
if (!lineEl) return null;
const textPath = lineEl.getAttribute("data-text-path");
if (!textPath) return null;
const zoneEl = lineEl.closest(".zone[data-zone-position]");
if (!zoneEl) return null;
const zoneId = zoneEl.getAttribute("data-zone-position");
if (!zoneId) return null;
return { zoneId, textPath, value: (lineEl.textContent ?? "").trim() };
}
export default function SlideCanvas({ export default function SlideCanvas({
slidePlan, slidePlan,
userSelection, userSelection,
finalHtmlUrl, finalHtmlUrl,
slideOverrideCss,
isPipelineRunning, isPipelineRunning,
isPendingLayout, isPendingLayout,
pendingLayoutId, pendingLayoutId,
@@ -98,6 +199,9 @@ export default function SlideCanvas({
onZoneResize, onZoneResize,
imageOverrides, imageOverrides,
onImageResize, onImageResize,
onTextEdit,
structureOverrides,
onStructureEdit,
}: SlideCanvasProps) { }: SlideCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
@@ -140,7 +244,15 @@ export default function SlideCanvas({
// HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용. // HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용.
// 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업. // 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업.
// pendingLayout 과 배타적 (충돌 방지). // pendingLayout 과 배타적 (충돌 방지).
const [isEditMode, setIsEditMode] = useState(false); // IMP-90 (#90) u11 — `editMode` enum replaces the prior boolean. The
// `isEditMode` shim is kept ONLY for the pendingLayout coupling +
// zone-wrapper visual cues (border / hover / selected styling) that
// fire whenever any edit mode is active. u12 routes gesture-activating
// gates through `editGates` so text / structure / image-zone gestures
// are mutually exclusive.
const [editMode, setEditMode] = useState<EditMode>("off");
const isEditMode = editMode !== "off";
const editGates = computeEditModeGates(editMode, !!isPendingLayout);
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
// 편집 모드 toggle 시 iframe contentDocument 에 글벗 패턴 적용 / 해제. // 편집 모드 toggle 시 iframe contentDocument 에 글벗 패턴 적용 / 해제.
@@ -164,11 +276,22 @@ export default function SlideCanvas({
const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"]; const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"];
let inputHandler: ((e: Event) => void) | null = null; let inputHandler: ((e: Event) => void) | null = null;
// IMP-90 (#90) u13 — focusout (= bubbling blur) emits one capture per
// finished line edit; u15 will debounce + PUT.
let textEditCaptureHandler: ((e: Event) => void) | null = null;
// IMP-51 (#79) u8 — user-content image click listeners installed // IMP-51 (#79) u8 — user-content image click listeners installed
// inside the iframe contentDocument. Tracked here so the cleanup // inside the iframe contentDocument. Tracked here so the cleanup
// callback can remove them when edit mode exits (or iframe reloads). // callback can remove them when edit mode exits (or iframe reloads).
const imageClickBindings: Array<{ el: HTMLImageElement; handler: (e: Event) => void; prevCursor: string; prevOutline: string }> = []; const imageClickBindings: Array<{ el: HTMLImageElement; handler: (e: Event) => void; prevCursor: string; prevOutline: string }> = [];
if (isEditMode) {
// IMP-90 (#90) u12 — text-editing gate: only the 'text' editMode
// turns designMode on + makes the editable tags contentEditable.
// The else branch tears the prior state down so leaving text mode
// (to structure / image-zone / off) immediately disables in-place
// text editing — required for mutual exclusivity vs the image-zone
// overlay's drag/resize gestures (a contentEditable cursor would
// otherwise be placed by every image click).
if (editGates.textEditing) {
doc.designMode = "on"; doc.designMode = "on";
doc.querySelectorAll(".slide *").forEach((el) => { doc.querySelectorAll(".slide *").forEach((el) => {
if (editableTags.includes((el as HTMLElement).tagName)) { if (editableTags.includes((el as HTMLElement).tagName)) {
@@ -181,11 +304,28 @@ export default function SlideCanvas({
}; };
doc.addEventListener("input", inputHandler); doc.addEventListener("input", inputHandler);
// IMP-51 (#79) u8 — wire click → selectedImageId on every stamped textEditCaptureHandler = (ev: Event) => {
// user-content image. Selector mirrors USER_CONTENT_IMAGE_SELECTOR const cap = deriveTextEditCapture(
// in src/image_id_stamper.py (+ requires data-image-id which the ev.target as unknown as TextEditCaptureTarget | null
// stamper always emits). Decorative / frame imgs lacking the role );
// attribute are intentionally NOT clickable here. if (cap) onTextEdit?.(cap);
};
doc.addEventListener("focusout", textEditCaptureHandler);
} else {
doc.designMode = "off";
doc.querySelectorAll("[contenteditable]").forEach((el) => {
(el as HTMLElement).removeAttribute("contenteditable");
});
}
// IMP-90 (#90) u12 — image-selection gate: only the 'image-zone'
// editMode wires the in-iframe user-content image click → selection.
// Selector mirrors USER_CONTENT_IMAGE_SELECTOR in image_id_stamper.py
// (requires data-image-id which the stamper always emits). Decorative
// / frame imgs lacking the role attribute are NOT clickable. The
// else branch clears `selectedImageId` so the React-side overlay
// never lingers on a non-image-zone edit mode.
if (editGates.imageSelection) {
const imgEls = doc.querySelectorAll<HTMLImageElement>( const imgEls = doc.querySelectorAll<HTMLImageElement>(
'.slide img[data-image-role="user-content"][data-image-id]' '.slide img[data-image-role="user-content"][data-image-id]'
); );
@@ -205,12 +345,6 @@ export default function SlideCanvas({
imageClickBindings.push({ el: imgEl, handler, prevCursor, prevOutline }); imageClickBindings.push({ el: imgEl, handler, prevCursor, prevOutline });
}); });
} else { } 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); setSelectedImageId(null);
} }
@@ -218,19 +352,60 @@ export default function SlideCanvas({
if (inputHandler && doc) { if (inputHandler && doc) {
doc.removeEventListener("input", inputHandler); doc.removeEventListener("input", inputHandler);
} }
if (textEditCaptureHandler && doc) {
doc.removeEventListener("focusout", textEditCaptureHandler);
}
imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => { imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => {
el.removeEventListener("click", handler); el.removeEventListener("click", handler);
el.style.cursor = prevCursor; el.style.cursor = prevCursor;
el.style.outline = prevOutline; el.style.outline = prevOutline;
}); });
}; };
}, [isEditMode, finalHtmlUrl, onContentEdit]); }, [editGates.textEditing, editGates.imageSelection, finalHtmlUrl, onContentEdit, onTextEdit]);
// pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지). // pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지).
useEffect(() => { useEffect(() => {
if (isPendingLayout && isEditMode) setIsEditMode(false); if (isPendingLayout && isEditMode) setEditMode("off");
}, [isPendingLayout, isEditMode]); }, [isPendingLayout, isEditMode]);
// IMP-90 (#90) u14 — discover slot keys per zone for the structure
// overlay. Source = iframe DOM `data-text-path="{slot_key}.{line_index}"`
// attributes stamped by u8 (`src/text_path_stamper.py`). Unique slot_key
// prefixes per `.zone[data-zone-position]` form the overlay's slot list.
// Discovery runs only when entering structure mode (and resets on exit
// or iframe reload) so off / text / image-zone modes never pay this
// traversal cost.
const [slotKeysByZone, setSlotKeysByZone] = useState<
Record<string, string[]>
>({});
useEffect(() => {
if (editMode !== "structure" || isPendingLayout) {
setSlotKeysByZone({});
return;
}
const doc = iframeRef.current?.contentDocument;
if (!doc) return;
const next: Record<string, string[]> = {};
doc.querySelectorAll(".zone[data-zone-position]").forEach((zEl) => {
const zoneId = (zEl as HTMLElement).getAttribute("data-zone-position");
if (!zoneId) return;
const seen = new Set<string>();
const keys: string[] = [];
zEl.querySelectorAll("[data-text-path]").forEach((lineEl) => {
const path = (lineEl as HTMLElement).getAttribute("data-text-path");
if (!path) return;
const lastDot = path.lastIndexOf(".");
const slotKey = lastDot > 0 ? path.slice(0, lastDot) : path;
if (slotKey && !seen.has(slotKey)) {
seen.add(slotKey);
keys.push(slotKey);
}
});
next[zoneId] = keys;
});
setSlotKeysByZone(next);
}, [editMode, isPendingLayout, finalHtmlUrl]);
// finalHtmlUrl 이 바뀌면 (= 다른 run / 재실행) stale 측정값 reset. // finalHtmlUrl 이 바뀌면 (= 다른 run / 재실행) stale 측정값 reset.
// 새 iframe 의 onLoad 가 발화하면서 measuredZones 다시 채움. // 새 iframe 의 onLoad 가 발화하면서 measuredZones 다시 채움.
useEffect(() => { useEffect(() => {
@@ -337,28 +512,50 @@ export default function SlideCanvas({
</button> </button>
)} )}
{/* 편집 모드 toggle 버튼 — normal mode + final.html 있을 때만. {/* IMP-90 (#90) u11 — discriminated edit-mode toolbar.
글벗 패턴 차용 — designMode + contentEditable. backend 반영은 별 작업. */} Replaces the prior single ✏ toggle. Three modes (text /
structure / image-zone) are mutually exclusive; clicking the
active mode toggles back to 'off'. Gesture gating per mode is
u12 — u11 only plants the state + UI surface, so all three
modes currently share the same `isEditMode` shim behavior. */}
{!isPendingLayout && finalHtmlUrl && ( {!isPendingLayout && finalHtmlUrl && (
<div
data-testid="edit-mode-toolbar"
className="absolute top-2 right-2 z-30 flex gap-1"
style={{ pointerEvents: "auto" }}
>
{EDIT_MODES.map((mode) => {
const active = editMode === mode;
const label =
mode === "text" ? "✏ 텍스트" : mode === "structure" ? "▦ 구조" : "🖼 이미지/존";
const title =
mode === "text"
? "텍스트 편집 — 텍스트 클릭하여 직접 수정"
: mode === "structure"
? "구조 편집 — slot 순서 / 숨김 변경 (u14 펜딩)"
: "이미지/존 편집 — 이미지 드래그·리사이즈 + 존 리사이즈";
return (
<button <button
key={mode}
type="button"
data-testid={`edit-mode-${mode}`}
aria-pressed={active}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setIsEditMode((p) => !p); setEditMode((prev) => nextEditMode(prev, mode));
}} }}
className={`absolute top-2 right-2 z-30 text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${ className={`text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
isEditMode active
? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200" ? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200" : "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
}`} }`}
style={{ pointerEvents: "auto" }} title={title}
title={
isEditMode
? "편집 모드 — 텍스트 클릭하여 수정. 다시 클릭하여 종료. (변경은 frontend 만, backend 반영 미구현)"
: "텍스트 직접 편집 모드 진입"
}
> >
{isEditMode ? "✏ 편집 중 (클릭 종료)" : "✏ 편집"} {label}
</button> </button>
);
})}
</div>
)} )}
<div <div
@@ -380,7 +577,13 @@ export default function SlideCanvas({
className="w-full h-full border-0 block" className="w-full h-full border-0 block"
scrolling="no" scrolling="no"
sandbox="allow-same-origin allow-scripts" sandbox="allow-same-origin allow-scripts"
style={{ pointerEvents: isEditMode ? "auto" : "none" }} // IMP-90 (#90) u12 — iframe pointer-events gate. 'text' needs
// pe:auto so the user can click into text fields; 'image-zone'
// needs pe:auto so user-content image clicks can reach the
// in-iframe click handler that drives `selectedImageId`.
// 'structure' and 'off' keep pe:none — structure has no
// in-iframe gesture (u14 will overlay React-side controls).
style={{ pointerEvents: editGates.iframePointerAuto ? "auto" : "none" }}
onLoad={(e) => { onLoad={(e) => {
// IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend // IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend
// slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상 // slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상
@@ -391,15 +594,6 @@ export default function SlideCanvas({
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument; const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
if (!doc) return; if (!doc) return;
// 2026-05-14 — slide-level override CSS (catalog/template 무변).
// Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정.
if (slideOverrideCss && slideOverrideCss.trim()) {
const overrideStyle = doc.createElement("style");
overrideStyle.setAttribute("data-purpose", "slide-level-override");
overrideStyle.textContent = slideOverrideCss;
doc.head.appendChild(overrideStyle);
}
// ── Zone DOM 측정 ── // ── Zone DOM 측정 ──
// backend final.html 의 .zone[data-zone-position="..."] 요소를 // backend final.html 의 .zone[data-zone-position="..."] 요소를
// 찾아서 boundingClientRect 측정 → 1280×720 기준 정규화. // 찾아서 boundingClientRect 측정 → 1280×720 기준 정규화.
@@ -578,9 +772,11 @@ export default function SlideCanvas({
const makeResizeHandler = ( const makeResizeHandler = (
direction: ResizeDir direction: ResizeDir
) => (ev: React.MouseEvent<HTMLDivElement>) => { ) => (ev: React.MouseEvent<HTMLDivElement>) => {
// resize 는 pendingLayout OR 편집 모드 활성. 2026-05-22 demo hot-fix — // resize 는 pendingLayout OR image-zone 편집 모드 활성. 2026-05-22
// frame partial 에 @container aspect-ratio 회전이 들어가서 fixed px 제약 사라짐. // demo hot-fix — frame partial 에 @container aspect-ratio 회전이
if ((!isPendingLayout && !isEditMode) || !onZoneResize) return; // 들어가서 fixed px 제약 사라짐. IMP-90 u12: text/structure 모드
// 에서는 zone resize 비활성 (mutually exclusive per editGates).
if ((!isPendingLayout && !editGates.zoneGestures) || !onZoneResize) return;
if (!measuredSlideBody) return; if (!measuredSlideBody) return;
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@@ -651,7 +847,10 @@ export default function SlideCanvas({
ev: React.MouseEvent<HTMLDivElement> ev: React.MouseEvent<HTMLDivElement>
) => { ) => {
ev.stopPropagation(); ev.stopPropagation();
const canDrag = !!((isPendingLayout || isEditMode) && measuredSlideBody && onZoneResize); // IMP-90 u12: zone drag is image-zone-mode-only (text /
// structure suppress canDrag; non-zoneGestures click still
// triggers onZoneClick via the !dragged branch on mouse-up).
const canDrag = !!((isPendingLayout || editGates.zoneGestures) && measuredSlideBody && onZoneResize);
const startMouseX = ev.clientX; const startMouseX = ev.clientX;
const startMouseY = ev.clientY; const startMouseY = ev.clientY;
const startGeom = { ...localGeom }; const startGeom = { ...localGeom };
@@ -870,12 +1069,14 @@ export default function SlideCanvas({
</div> </div>
)} )}
{/* Step C : zone resize handles — 8 방향. pendingLayout OR 편집 모드 활성. {/* Step C : zone resize handles — 8 방향. pendingLayout OR image-zone
2026-05-22 demo hot-fix — frame partial 에 @container aspect-ratio 회전 편집 모드 활성. 2026-05-22 demo hot-fix — frame partial 에 @container
들어간 후 fixed px 제약 사라져 편집 모드 resize 도 의미 있음. aspect-ratio 회전 들어간 후 fixed px 제약 사라져 image-zone 모드 resize
도 의미 있음. IMP-90 u12: text / structure 모드에서는 zone resize
affordance 미노출 (editGates.zoneGestures = image-zone only).
edge handle (top/bottom/left/right) : 한 boundary 이동 edge handle (top/bottom/left/right) : 한 boundary 이동
corner handle (nw/ne/sw/se) : 두 boundary 동시. */} corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
{(isPendingLayout || isEditMode) && onZoneResize && ( {(isPendingLayout || editGates.zoneGestures) && onZoneResize && (
<> <>
{/* top edge */} {/* top edge */}
<div <div
@@ -955,8 +1156,10 @@ export default function SlideCanvas({
strips intercept the perimeter while the un-covered iframe strips intercept the perimeter while the un-covered iframe
interior keeps text-edit reachability intact. interior keeps text-edit reachability intact.
pendingLayout mode already has wrapper pointerEvents:auto, pendingLayout mode already has wrapper pointerEvents:auto,
so these surfaces are only needed in edit mode. */} so these surfaces are only needed in edit mode.
{isEditMode && !isPendingLayout && onZoneResize && ( IMP-90 u12: image-zone-mode-only — text / structure 모드는
zone drag 안 함 (editGates.zoneGestures = false 두 모드 모두). */}
{editGates.zoneGestures && !isPendingLayout && onZoneResize && (
<> <>
<div <div
onMouseDown={handleZoneMouseDown} onMouseDown={handleZoneMouseDown}
@@ -1001,6 +1204,39 @@ export default function SlideCanvas({
); );
})} })}
{/* IMP-90 (#90) u14 — structure edit overlay (slot reorder +
hide). Renders only in `editMode === "structure"` over each
measured zone, positioned at the zone's top-right inside the
slide-absolute coord space. Slot keys come from u14 iframe
traversal (`slotKeysByZone`). Mutations emit through
onStructureEdit; u15 will debounce + PUT. */}
{!isPendingLayout && editMode === "structure" && finalHtmlUrl &&
slidePlan?.zones.map((zone) => {
const m = measuredZones[zone.zone_id];
if (!m) return null;
const slotKeys = slotKeysByZone[zone.zone_id] ?? [];
const current = structureOverrides?.[zone.zone_id];
return (
<div
key={`struct-${zone.id}`}
className="absolute z-30"
style={{
left: m.x * W_SCALED,
top: m.y * H_SCALED,
width: m.w * W_SCALED,
pointerEvents: "none",
}}
>
<StructureEditOverlay
zoneId={zone.zone_id}
slotKeys={slotKeys}
current={current}
onChange={onStructureEdit}
/>
</div>
);
})}
{/* ── IMP-51 (#79) u8 — user-content image edit overlay ── {/* ── IMP-51 (#79) u8 — user-content image edit overlay ──
Activates only in edit mode when an image_id appears in either Activates only in edit mode when an image_id appears in either
`imageOverrides` (u11-fed persisted axis) or `measuredImages` `imageOverrides` (u11-fed persisted axis) or `measuredImages`
@@ -1028,7 +1264,11 @@ export default function SlideCanvas({
`slideCanvasDragMath.ts` so the boundary contract Codex #16 `slideCanvasDragMath.ts` so the boundary contract Codex #16
verified is exercised directly by vitest (mirror of how IMP-54 verified is exercised directly by vitest (mirror of how IMP-54
u3 split the zone math out of SlideCanvas). */} u3 split the zone math out of SlideCanvas). */}
{!isPendingLayout && isEditMode && finalHtmlUrl && onImageResize && {/* IMP-90 u12: image overlay is image-zone-mode-only. text /
structure 모드에서는 image drag/resize affordance 미노출
(editGates.imageOverlay = false). pendingLayout 도 동일하게
suppress (computeEditModeGates 가 모두 false 반환). */}
{!isPendingLayout && editGates.imageOverlay && finalHtmlUrl && onImageResize &&
Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map( Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map(
([imageId]) => { ([imageId]) => {
const persisted = imageOverrides?.[imageId]; const persisted = imageOverrides?.[imageId];
@@ -0,0 +1,165 @@
/**
* IMP-90 (#90) u14 — Structure edit overlay.
*
* React component + pure helpers that present a per-zone slot list with
* reorder (↑ / ↓) and hide (👁 / 🚫) affordances. Mounted by SlideCanvas
* when `editMode === "structure"`. Emits a `StructureOverridePerZone`
* tuple `{slot_order, hidden_slots}` through `onChange`; u15 will debounce
* + PUT this to `/api/user-overrides` (NOT u14 scope), and u16 reads the
* persisted axis at the next CLI generate run.
*
* SCOPE LOCK (binding contract):
* - inner shape = `{slot_order, hidden_slots}` ONLY.
* - frame swap stays on the existing `frames` axis (u6 backend resolver
* rejects frame-swap-shaped inner keys).
* - per-slot text content NEVER mutated here — `text_overrides` axis
* (u4/u5/u13) handles that exclusively.
*
* The exported pure helpers (`resolveEffectiveSlotOrder`, `moveItem`) are
* the unit's vitest surface; React rendering is NOT tested because the
* Front package devDependencies do not include jsdom / @testing-library
* (verified by u11/u12/u13 test pattern).
*/
import type {
StructureOverridePerZone,
} from "../services/userOverridesApi";
export interface StructureEditOverlayProps {
zoneId: string;
/** Discovered slot keys for this zone (e.g. from iframe DOM
* `data-text-path` prefixes). Order = backend default. */
slotKeys: ReadonlyArray<string>;
/** Current persisted override (or undefined). `slot_order` reorders the
* discovered keys; missing keys keep backend order at the tail. */
current?: StructureOverridePerZone;
/** Emitted on every user mutation. u15 wires this to autosave. */
onChange?: (zoneId: string, next: StructureOverridePerZone) => void;
}
/** Apply `slot_order` override to the discovered slot list. Unknown
* override entries are dropped; missing discovered keys are appended in
* backend order so the user never loses a slot by partial-override. */
export function resolveEffectiveSlotOrder(
slotKeys: ReadonlyArray<string>,
slotOrder?: ReadonlyArray<string> | null,
): string[] {
if (!slotOrder || slotOrder.length === 0) return [...slotKeys];
const allowed = new Set(slotKeys);
const seen = new Set<string>();
const ordered: string[] = [];
for (const k of slotOrder) {
if (typeof k === "string" && allowed.has(k) && !seen.has(k)) {
ordered.push(k);
seen.add(k);
}
}
for (const k of slotKeys) {
if (!seen.has(k)) ordered.push(k);
}
return ordered;
}
/** Move `arr[index]` by `delta` positions. Out-of-range returns a fresh
* copy of the input (defensive: caller can always treat the result as a
* new reference). */
export function moveItem<T>(
arr: ReadonlyArray<T>,
index: number,
delta: number,
): T[] {
const next = arr.slice();
const target = index + delta;
if (
index < 0 ||
index >= next.length ||
target < 0 ||
target >= next.length
) {
return next;
}
const tmp = next[index];
next[index] = next[target];
next[target] = tmp;
return next;
}
export default function StructureEditOverlay({
zoneId,
slotKeys,
current,
onChange,
}: StructureEditOverlayProps) {
const effective = resolveEffectiveSlotOrder(slotKeys, current?.slot_order);
const hidden = new Set(current?.hidden_slots ?? []);
const emit = (nextOrder: string[], nextHidden: Set<string>) => {
onChange?.(zoneId, {
slot_order: nextOrder,
hidden_slots: Array.from(nextHidden),
});
};
return (
<div
data-testid={`structure-overlay-${zoneId}`}
className="bg-white/95 border border-emerald-300 rounded shadow p-2 flex flex-col gap-1 text-[10px]"
style={{ pointerEvents: "auto" }}
>
<div className="font-bold uppercase tracking-wider text-emerald-700 mb-1">
▦ {zoneId}
</div>
{effective.length === 0 ? (
<div className="text-slate-400 italic">slot 없음</div>
) : (
effective.map((key, i) => (
<div
key={key}
data-testid={`slot-${zoneId}-${key}`}
className="flex items-center gap-1"
>
<span
className={`flex-1 truncate ${
hidden.has(key) ? "text-slate-400 line-through" : "text-slate-700"
}`}
>
{key}
</span>
<button
type="button"
data-testid={`slot-up-${zoneId}-${key}`}
disabled={i === 0}
onClick={() => emit(moveItem(effective, i, -1), hidden)}
className="px-1 rounded border border-slate-200 disabled:opacity-30 hover:bg-slate-100"
title="위로"
>
↑
</button>
<button
type="button"
data-testid={`slot-down-${zoneId}-${key}`}
disabled={i === effective.length - 1}
onClick={() => emit(moveItem(effective, i, 1), hidden)}
className="px-1 rounded border border-slate-200 disabled:opacity-30 hover:bg-slate-100"
title="아래로"
>
↓
</button>
<button
type="button"
data-testid={`slot-hide-${zoneId}-${key}`}
aria-pressed={hidden.has(key)}
onClick={() => {
const nh = new Set(hidden);
if (nh.has(key)) nh.delete(key);
else nh.add(key);
emit(effective, nh);
}}
className="px-1 rounded border border-slate-200 hover:bg-slate-100"
title={hidden.has(key) ? "표시" : "숨김"}
>
{hidden.has(key) ? "🚫" : "👁"}
</button>
</div>
))
)}
</div>
);
}
+197 -55
View File
@@ -16,9 +16,12 @@ import {
moveSectionToZone, moveSectionToZone,
saveZoneSizes, saveZoneSizes,
saveImageOverride, saveImageOverride,
saveTextOverride,
saveStructureOverride,
deriveUserOverridesKey, deriveUserOverridesKey,
applyPersistedNonFrameOverrides, applyPersistedNonFrameOverrides,
remapPersistedFramesToZoneFrames, remapPersistedFramesToZoneFrames,
validateZoneGeometriesAgainstLayout,
} from "../utils/slidePlanUtils"; } from "../utils/slidePlanUtils";
import { import {
parseMdxFile, parseMdxFile,
@@ -40,8 +43,9 @@ import LeftMdxPanel from "../components/LeftMdxPanel";
import SlideCanvas from "../components/SlideCanvas"; import SlideCanvas from "../components/SlideCanvas";
import LayoutPanel from "../components/LayoutPanel"; import LayoutPanel from "../components/LayoutPanel";
import FramePanel from "../components/FramePanel"; import FramePanel from "../components/FramePanel";
import BottomActions from "../components/BottomActions";
import { import {
Sparkles, Download, Link2, Loader2, Sparkles, Loader2,
CheckCircle2, HelpCircle, CheckCircle2, HelpCircle,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -154,6 +158,31 @@ export default function Home() {
} }
carriedZoneSections[targetPos].push(...zone.section_ids); carriedZoneSections[targetPos].push(...zone.section_ids);
}); });
// IMP-44 (#73) u4 — clear in-memory zone_geometries on layout flip.
// The persisted keys were valid for the *prior* preset; carrying them
// forward into the new preset would either trip the u1/u2 backend
// [override-warning] guards (foreign keys dropped, override_applied
// forced back to None) or partially apply on shared keys. Drop them
// up-front so the new layout starts from a clean even-split baseline,
// and persist a clear sentinel (null) so a subsequent reopen does not
// resurrect the stale snapshot from user_overrides.json.
const priorGeoms = p.userSelection.overrides.zone_geometries;
const hadPriorGeoms =
priorGeoms && typeof priorGeoms === "object" && Object.keys(priorGeoms).length > 0;
if (p.uploadedFile && hadPriorGeoms) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { zone_geometries: null });
}
// IMP-55 (#93) u12 — persist the marker reset to disk so a stale
// `manual_section_assignment: true` from a prior drag (written via
// u6's co-PUT) cannot survive the layout apply. The in-memory reset
// on line 192 protects the current session, but a page reload would
// re-seed from disk via u3's restore branch and re-arm the u7 gate.
// Unconditional — apply always resets, independent of hadPriorGeoms.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { manual_section_assignment: false });
}
return { return {
...p, ...p,
userSelection: { userSelection: {
@@ -162,6 +191,18 @@ export default function Home() {
...p.userSelection.overrides, ...p.userSelection.overrides,
layout_preset: layoutId, layout_preset: layoutId,
zone_sections: carriedZoneSections, zone_sections: carriedZoneSections,
zone_geometries: {},
// IMP-55 (#93) u5 — reset the bool intent marker to `false` on
// layout apply. `carriedZoneSections` above is auto-carry (old
// zone.section_ids → new layout positions), NOT user drag-drop
// intent. Without this explicit reset the spread of
// `...p.userSelection.overrides` would carry a prior-drag `true`
// into the new layout, causing handleGenerate (u7) to forward
// auto-carried assignments as user overrides and re-trigger the
// PARTIAL_COVERAGE regression. The marker flips back to `true`
// only when the user actually drag-drops a section in the new
// layout (u6 handleSectionDrop).
manual_section_assignment: false,
}, },
selectedZoneId: null, selectedZoneId: null,
selectedRegionId: null, selectedRegionId: null,
@@ -176,10 +217,27 @@ export default function Home() {
// pending 모드 취소 → 평소 (final.html iframe) 모드 복귀. // pending 모드 취소 → 평소 (final.html iframe) 모드 복귀.
const handleCancelPendingLayout = useCallback(() => { const handleCancelPendingLayout = useCallback(() => {
setPendingLayout(null); setPendingLayout(null);
setState((p) => ({ setState((p) => {
// IMP-55 (#93) u12 — persist marker=false to disk on cancel. In-memory
// the u3 seed via createInitialUserSelection already pins false (u5
// contract), but if a prior drag-drop wrote `true` to disk via u6's
// co-PUT, that value would survive a reopen and re-arm the u7
// forwarding gate on the next page load. Symmetric with the apply
// path's disk PUT above.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { manual_section_assignment: false });
}
return {
...p, ...p,
// IMP-55 (#93) u5 — cancel discards all pending overrides via
// `createInitialUserSelection`, whose u3 seed pins
// `manual_section_assignment: false`. In-memory reset is implicit
// via the seed; u12 adds the disk-side PUT above to keep persisted
// state consistent so a reopen does not re-arm the marker.
userSelection: createInitialUserSelection(p.slidePlan), userSelection: createInitialUserSelection(p.slidePlan),
})); };
});
setHasPendingChanges(false); setHasPendingChanges(false);
}, []); }, []);
@@ -225,22 +283,6 @@ export default function Home() {
// 호출되는 단일 callback. handleFileUpload 가 자동 분석 trigger. // 호출되는 단일 callback. handleFileUpload 가 자동 분석 trigger.
const [selectedSample, setSelectedSample] = useState<"03" | "04" | "05" | null>(null); const [selectedSample, setSelectedSample] = useState<"03" | "04" | "05" | null>(null);
// 2026-05-14 — mdx 별 slide-level CSS override (catalog/template 무변, frontend layer only).
// SlideCanvas 의 iframe onLoad 에서 동적 inject. 사용자 룰 : "보고용 슬라이드 결과물 단위"
// 변경. mdx04 의 default (rank 1 = process_product_two_way) 일 때만 적용 — 사용자 frame
// override 후 (rank 2 = bim_dx_comparison_table 등) 다른 frame 시 무적용.
const MDX04_DEFAULT_OVERRIDE_CSS = `
.slide-body {
grid-template-rows: 0.38fr 0.60fr !important;
gap: 1.5% !important;
}
.f29b__cell .text-line + .text-line { margin-top: 1px !important; }
.f29b__cell:nth-child(n+3) {
padding-top: 3px !important;
margin-top: 2px !important;
}
`.trim();
const handleSelectSample = useCallback(async (which: "03" | "04" | "05") => { const handleSelectSample = useCallback(async (which: "03" | "04" | "05") => {
try { try {
const res = await fetch(`/api/sample-mdx?mdx=${encodeURIComponent(which)}`); const res = await fetch(`/api/sample-mdx?mdx=${encodeURIComponent(which)}`);
@@ -329,34 +371,69 @@ export default function Home() {
// zone-geometry override — backend 의 build_layout_css 에 전달 (horizontal-2 / // zone-geometry override — backend 의 build_layout_css 에 전달 (horizontal-2 /
// vertical-2 만 적용). zone_id (top/bottom/...) → slide-body 내부 0~1 비율. // vertical-2 만 적용). zone_id (top/bottom/...) → slide-body 내부 0~1 비율.
// IMP-44 (#73) u4 — validate against the active layout *before* the
// round-trip so foreign-preset keys never reach the backend. Mirrors
// the u1/u2 WARN+DROP guards on the frontend side: dropped keys surface
// as a toast (so the user knows why their resize "vanished"), and only
// the `kept` subset is forwarded. The active layout = the layout the
// backend will use, which is `overrides.layout` when the user has set
// one, else the default slidePlan preset (mirrors backend resolution).
const zoneGeometries = state.userSelection.overrides.zone_geometries; const zoneGeometries = state.userSelection.overrides.zone_geometries;
if (zoneGeometries && Object.keys(zoneGeometries).length > 0) { if (zoneGeometries && Object.keys(zoneGeometries).length > 0) {
overrides.zoneGeometries = zoneGeometries; const activeLayout = overrides.layout ?? sourcePlan.layout_preset;
const validation = validateZoneGeometriesAgainstLayout(
zoneGeometries,
activeLayout,
);
if (Object.keys(validation.dropped).length > 0) {
toast.error(
`zone_geometries layout-mismatch: dropped ${Object.keys(validation.dropped).join(", ")} (expected ${validation.expectedPositions.join(", ") || "—"}; layout=${activeLayout}).`,
);
}
if (Object.keys(validation.kept).length > 0) {
overrides.zoneGeometries = validation.kept;
}
} }
// 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀. // IMP-55 (#93) u7 — Replace the IMP-08 B-3 self-compare with the bool
// 시연 안정성 우선. section swap 은 별 path (수동 drag detection) 로 풀어야 함. // `manual_section_assignment` intent marker gate. The prior code built
// 임시 over-aggressive fix 가 default flow 깨뜨려 PARTIAL_COVERAGE 발생했음. // `defaultByZone` from `sourcePlan.zones` and compared against the
// user's `overrides.zone_sections`, but `sourcePlan === effectiveSlidePlan`
// (Home.tsx:305) and `effectiveSlidePlan.zones === pendingZones`
// (Home.tsx:649), which is itself derived from
// `state.userSelection.overrides.zone_sections` via slidePlanUtils.ts.
// The comparison was degenerate (user input vs itself), so real drag-drop
// swaps were classified `sameAsDefault` and silently dropped from
// `overrides.zoneSections` — the exact regression IMP-55 fixes.
// - true → forward `zone_sections` filtered to zone_ids that exist in
// `sourcePlan.zones` (cross-layout safety so foreign zone keys from a
// stale persisted layout never reach backend `--override-section-
// assignment`). u6 is the SOLE setter of true (real drag-drop).
// - false → skip. Backend determines assignment from its own default
// policy. u3 seeds false on first load, u5 resets false on layout
// apply auto-carry, u12 persists false so a stale disk `true` cannot
// survive a reopen-after-apply window.
// No `sameAsDefault` heuristic — the marker is the source of intent.
const manualMarker =
state.userSelection.overrides.manual_section_assignment;
if (manualMarker === true) {
const userZoneSections = state.userSelection.overrides.zone_sections; const userZoneSections = state.userSelection.overrides.zone_sections;
if (userZoneSections) { if (userZoneSections) {
const defaultByZone = new Map<string, string[]>(); const validZoneIds = new Set(
sourcePlan.zones.forEach((z) => { sourcePlan.zones.map((z) => z.zone_id),
defaultByZone.set(z.zone_id, z.section_ids); );
}); const zoneSectionsForward: Record<string, string[]> = {};
const zoneSectionsDiff: Record<string, string[]> = {};
for (const [zoneId, sids] of Object.entries(userZoneSections)) { for (const [zoneId, sids] of Object.entries(userZoneSections)) {
if (!validZoneIds.has(zoneId)) continue;
if (!Array.isArray(sids)) continue; if (!Array.isArray(sids)) continue;
const cleaned = sids.filter((s) => typeof s === "string" && s.trim()); const cleaned = sids.filter(
const defaults = defaultByZone.get(zoneId) ?? []; (s) => typeof s === "string" && s.trim(),
const sameAsDefault = );
cleaned.length === defaults.length && zoneSectionsForward[zoneId] = cleaned;
cleaned.every((sid, i) => sid === defaults[i]);
if (!sameAsDefault) {
zoneSectionsDiff[zoneId] = cleaned;
} }
if (Object.keys(zoneSectionsForward).length > 0) {
overrides.zoneSections = zoneSectionsForward;
} }
if (Object.keys(zoneSectionsDiff).length > 0) {
overrides.zoneSections = zoneSectionsDiff;
} }
} }
} }
@@ -384,6 +461,14 @@ export default function Home() {
// clicks Generate would race the PUT against /api/run; the u2 // clicks Generate would race the PUT against /api/run; the u2
// fallback could then load a stale persisted document. // fallback could then load a stale persisted document.
await flushUserOverrides(); await flushUserOverrides();
// IMP-42 u4 — unconditional DIAG console.log on the handleGenerate
// entry-to-backend boundary. Surfaces the override payload + uploaded
// file name so the user can see exactly what crossed the wire when
// the pipeline fails silently. No env gate (silence is the bug).
console.log("[DIAG raw overrides]", {
file: state.uploadedFile.name,
overrides,
});
const result = await runPipeline(state.uploadedFile, overrides); const result = await runPipeline(state.uploadedFile, overrides);
if (!result.success || !result.final_html_exists) { if (!result.success || !result.final_html_exists) {
@@ -450,7 +535,21 @@ export default function Home() {
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => { const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
setState((p) => { setState((p) => {
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId); const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
const finalSelection = selectZone(newSelection, zoneId); // 이동된 존 자동 선택 const zoneSelected = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
// IMP-55 (#93) u6 — flip the bool intent marker to `true` on real
// user drag-drop. Inverse of the u5 reset (layout apply/cancel
// auto-carry → false). handleGenerate (u7) gates `overrides.zoneSections`
// forwarding on this marker, so an unflipped drop would never reach
// the backend (the IMP-55 self-compare regression). The marker is
// flipped BEFORE persistence so the in-memory selection and the
// co-PUT body stay in sync atomically.
const finalSelection = {
...zoneSelected,
overrides: {
...zoneSelected.overrides,
manual_section_assignment: true,
},
};
// IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk // IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk
// schema axis (`zone_sections`) shares the in-memory shape (zone_id → // schema axis (`zone_sections`) shares the in-memory shape (zone_id →
// section_ids), so we forward the full mutated value; the u4 PUT path // section_ids), so we forward the full mutated value; the u4 PUT path
@@ -458,10 +557,15 @@ export default function Home() {
// p.uploadedFile gate skips persistence before any MDX is loaded — // p.uploadedFile gate skips persistence before any MDX is loaded —
// the demo-mode initial render path would otherwise PUT to the empty // the demo-mode initial render path would otherwise PUT to the empty
// key. saveUserOverrides is debounced (300ms) and per-key coalesced. // key. saveUserOverrides is debounced (300ms) and per-key coalesced.
// IMP-55 (#93) u6 — co-PUT `manual_section_assignment: true` in the
// SAME body so the disk file never has the post-drop zone_sections
// without the marker (would otherwise look like an unmotivated
// IMP-52 zone_sections write to the u9 backend fallback).
if (p.uploadedFile) { if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name); const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { void saveUserOverrides(key, {
zone_sections: finalSelection.overrides.zone_sections, zone_sections: finalSelection.overrides.zone_sections,
manual_section_assignment: true,
}); });
} }
return { ...p, userSelection: finalSelection }; return { ...p, userSelection: finalSelection };
@@ -579,6 +683,51 @@ export default function Home() {
setHasPendingChanges(true); setHasPendingChanges(true);
}, []); }, []);
// IMP-56 (#90) u15 — wire SlideCanvas u13 focusout capture into the new
// `text_overrides` persist axis. Mirrors handleImageResize: merge the
// (zoneId, textPath, value) tick via `saveTextOverride` (u15 pure helper)
// and schedule the 300ms-debounced PUT under the `text_overrides` axis.
// Per-axis coalescing in `saveUserOverrides` collapses rapid edits in
// the same line into a single PUT; per-key buckets isolate cross-MDX.
const handleTextEdit = useCallback(
(capture: { zoneId: string; textPath: string; value: string }) => {
setState((p) => {
const nextSelection = saveTextOverride(
p.userSelection, capture.zoneId, capture.textPath, capture.value,
);
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, {
text_overrides: nextSelection.overrides.text_overrides,
});
}
return { ...p, userSelection: nextSelection };
});
setHasPendingChanges(true);
},
[],
);
// IMP-56 (#90) u15 — wire SlideCanvas u14 structure overlay capture into
// the `structure_overrides` axis. Scope-locked to {slot_order,
// hidden_slots} — frame swap stays on the existing `frames` axis.
const handleStructureEdit = useCallback(
(zoneId: string, perZone: { slot_order?: string[]; hidden_slots?: string[] }) => {
setState((p) => {
const nextSelection = saveStructureOverride(p.userSelection, zoneId, perZone);
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, {
structure_overrides: nextSelection.overrides.structure_overrides,
});
}
return { ...p, userSelection: nextSelection };
});
setHasPendingChanges(true);
},
[],
);
// pending mode 일 때 effectiveSlidePlan = pendingZones 가 swap 된 plan. // pending mode 일 때 effectiveSlidePlan = pendingZones 가 swap 된 plan.
// 그 외 = state.slidePlan. 모든 zone / region lookup 이 일관되게 이걸 사용 → // 그 외 = state.slidePlan. 모든 zone / region lookup 이 일관되게 이걸 사용 →
// pending mode 의 region.id ("pending-region-N") 가 zone_frames key 로 들어가 // pending mode 의 region.id ("pending-region-N") 가 zone_frames key 로 들어가
@@ -590,17 +739,6 @@ export default function Home() {
return state.slidePlan; return state.slidePlan;
}, [pendingZones, state.slidePlan, pendingLayout]); }, [pendingZones, state.slidePlan, pendingLayout]);
// 2026-05-14 — slide-level CSS override 계산. mdx04 default (rank 1 = process_product_two_way)
// 일 때만 적용 (catalog 무변, slide 결과물에만 inject). 사용자 frame override 후 다른
// frame 시 무적용 (rank 2 의 frame visual 유지).
const slideOverrideCss = useMemo<string | undefined>(() => {
if (selectedSample !== "04") return undefined;
const zone04_2 = state.slidePlan?.zones.find((z) => z.zone_id === "bottom");
const frameId = zone04_2?.internal_regions[0]?.frame_match_strategy.frame_id;
if (frameId !== "process_product_two_way") return undefined;
return MDX04_DEFAULT_OVERRIDE_CSS;
}, [selectedSample, state.slidePlan]);
// ── Frame 선택 ── // ── Frame 선택 ──
const handleFrameSelect = useCallback((frameId: string) => { const handleFrameSelect = useCallback((frameId: string) => {
const zone = getSelectedZone(effectiveSlidePlan, state.userSelection); const zone = getSelectedZone(effectiveSlidePlan, state.userSelection);
@@ -761,7 +899,6 @@ export default function Home() {
normalizedContent={state.normalizedContent} normalizedContent={state.normalizedContent}
userSelection={state.userSelection} userSelection={state.userSelection}
finalHtmlUrl={runMeta?.final_html_url} finalHtmlUrl={runMeta?.final_html_url}
slideOverrideCss={slideOverrideCss}
isPipelineRunning={state.isLoading} isPipelineRunning={state.isLoading}
isPendingLayout={!!pendingLayout} isPendingLayout={!!pendingLayout}
pendingLayoutId={pendingLayout} pendingLayoutId={pendingLayout}
@@ -779,6 +916,9 @@ export default function Home() {
onZoneResize={handleZoneResize} onZoneResize={handleZoneResize}
imageOverrides={state.userSelection.overrides.image_overrides} imageOverrides={state.userSelection.overrides.image_overrides}
onImageResize={handleImageResize} onImageResize={handleImageResize}
onTextEdit={handleTextEdit}
structureOverrides={state.userSelection.overrides.structure_overrides}
onStructureEdit={handleStructureEdit}
/> />
</main> </main>
@@ -821,11 +961,13 @@ export default function Home() {
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">Phase Z Engine Active</span> <span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">Phase Z Engine Active</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <BottomActions
<Button variant="outline" onClick={() => toast.info("연동하기 기능은 준비 중입니다.")} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"><Link2 className="w-3.5 h-3.5" />Connect</Button> slidePlan={state.slidePlan}
<Button variant="outline" onClick={() => toast.info("다운로드 기능은 준비 중입니다.")} disabled={!state.slidePlan} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"><Download className="w-3.5 h-3.5" />Download</Button> runMeta={runMeta}
<Button onClick={() => toast.success("슬라이드 설정이 확정되었습니다.")} disabled={!state.slidePlan || state.isLoading} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest bg-slate-900 hover:bg-slate-800"><Sparkles className="w-3.5 h-3.5" />Finalize Slide</Button> uploadedFile={state.uploadedFile}
</div> isLoading={state.isLoading}
onGenerate={handleGenerate}
/>
</footer> </footer>
</div> </div>
); );
+72 -7
View File
@@ -345,13 +345,25 @@ export interface PipelineOverrides {
export async function runPipeline( export async function runPipeline(
file: File, file: File,
overrides?: PipelineOverrides overrides?: PipelineOverrides,
// IMP-43 (#72) u6 — optional prev RUN_ID for incremental rerun. When set,
// the vite plugin forwards `--reuse-from <PREV_RUN_ID>` to the backend
// and the pipeline resumes at Step 7 (Step 0/1/2/5/6 artifacts copied
// from the prior run). When omitted / empty, the POST body is
// byte-identical to pre-u6 (no reuseFromRunId key → no flag forwarded).
reuseFromRunId?: string,
): Promise<RunPipelineResult> { ): Promise<RunPipelineResult> {
const content = await file.text(); const content = await file.text();
const body: Record<string, unknown> = {
filename: file.name,
content,
overrides,
};
if (reuseFromRunId) body.reuseFromRunId = reuseFromRunId;
const res = await fetch("/api/run", { const res = await fetch("/api/run", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, content, overrides }), body: JSON.stringify(body),
}); });
const data = (await res.json()) as RunPipelineResult; const data = (await res.json()) as RunPipelineResult;
if (!res.ok && !data.run_id) { if (!res.ok && !data.run_id) {
@@ -565,6 +577,13 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
// sort 우선순위 = label (use_as_is > light_edit > restructure > reject) + confidence desc. // sort 우선순위 = label (use_as_is > light_edit > restructure > reject) + confidence desc.
// 모두 reject 인 경우 confidence desc 만 적용 (사용자 명시). // 모두 reject 인 경우 confidence desc 만 적용 (사용자 명시).
const TOP_N_FRAMES = 6; const TOP_N_FRAMES = 6;
// IMP-39 u4 (issue #68) — local LABEL_PRIORITY is now a documentation
// mirror of templates/phase_z2/catalog/ranking_sort_policy.yaml (u1).
// Primary ordering arrives pre-sorted from the backend selector
// (src/phase_z2_pipeline.py lookup_v4_match_with_fallback :1186-1196 +
// _build_application_plan_unit u3 payload fields). This constant is read
// ONLY on the warn-fallback path below (legacy fixtures pre-u3 / payload
// missing). Kept verbatim so the fallback ordering matches u1/u2 contract.
const LABEL_PRIORITY: Record<string, number> = { const LABEL_PRIORITY: Record<string, number> = {
use_as_is: 0, use_as_is: 0,
light_edit: 1, light_edit: 1,
@@ -576,9 +595,6 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
// 2) unit.v4_all_judgments (pre-IMP-05 audit array) // 2) unit.v4_all_judgments (pre-IMP-05 audit array)
// 3) unit.v4_candidates (legacy minimal) // 3) unit.v4_candidates (legacy minimal)
// fallback_chain alias is intentionally NOT read (Stage 2 guardrail). // fallback_chain alias is intentionally NOT read (Stage 2 guardrail).
const candidateEvidence = Array.isArray(unit.candidate_evidence)
? unit.candidate_evidence
: [];
const candidateMap = new Map<string, any>(); const candidateMap = new Map<string, any>();
const pushCandidate = (c: any) => { const pushCandidate = (c: any) => {
if (!c) return; if (!c) return;
@@ -586,15 +602,64 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
if (!key) return; if (!key) return;
if (!candidateMap.has(key)) candidateMap.set(key, c); if (!candidateMap.has(key)) candidateMap.set(key, c);
}; };
// IMP-39 u4 (issue #68) — primary path: consume the backend Step 9
// payload as the single source of ordering truth.
// • ``unit.sorted_candidate_evidence`` = policy-sorted selector trace
// (src/phase_z2_pipeline.py :4163, alias of selection_trace[
// "candidates"] sorted by u2 at :1186-1196). Same IMP-05 L2 schema
// consumed below (template_id, label, confidence, frame_number,
// frame_id, rank, catalog_registered, capacity_fit, route_hint, ...).
// • ``unit.ranking_sort_policy`` = full single-source policy dict
// (policy_type / label_priority / unknown_label_priority /
// tie_break_axes) forwarded for telemetry + fallback parity check.
// When both are present we feed sorted_candidate_evidence through the
// existing dedup map (first occurrence wins, mirrors backend
// ``seen_template_ids`` semantics at :1204-1236) and SKIP the local
// re-sort — backend "rank 1" then equals frontend frame_candidates[0]
// by construction (Stage 1 root-cause fix).
const sortedCandidateEvidence: any[] | null = Array.isArray(
unit.sorted_candidate_evidence,
)
? unit.sorted_candidate_evidence
: null;
const rankingSortPolicy = unit.ranking_sort_policy ?? null;
const backendPolicyPayloadPresent =
sortedCandidateEvidence !== null &&
sortedCandidateEvidence.length > 0 &&
rankingSortPolicy !== null;
let v4Source: any[];
if (backendPolicyPayloadPresent) {
sortedCandidateEvidence!.forEach(pushCandidate);
v4Source = Array.from(candidateMap.values());
} else {
// IMP-39 u4 — warn-fallback path. Legacy fixtures predating u3 (or
// any code path that strips the payload) lack the backend-sorted
// evidence; ordering then derives from local LABEL_PRIORITY mirror.
// Warning surfaces drift in dev console without hard-failing the UI
// (graceful: production sample audit deck remains renderable).
if (typeof console !== "undefined" && typeof console.warn === "function") {
console.warn(
`[IMP-39 u4] unit ${unit.unit_id ?? "<unknown>"}: backend payload ` +
"missing ranking_sort_policy / sorted_candidate_evidence — " +
"falling back to local LABEL_PRIORITY (legacy fixture path).",
);
}
const candidateEvidence = Array.isArray(unit.candidate_evidence)
? unit.candidate_evidence
: [];
candidateEvidence.forEach(pushCandidate); candidateEvidence.forEach(pushCandidate);
(unit.v4_all_judgments ?? []).forEach(pushCandidate); (unit.v4_all_judgments ?? []).forEach(pushCandidate);
(unit.v4_candidates ?? []).forEach(pushCandidate); (unit.v4_candidates ?? []).forEach(pushCandidate);
const rawSource = Array.from(candidateMap.values()); const rawSource = Array.from(candidateMap.values());
const v4Source = [...rawSource].sort((a: any, b: any) => { v4Source = [...rawSource].sort((a: any, b: any) => {
const lp = (LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99); const lp =
(LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99);
if (lp !== 0) return lp; if (lp !== 0) return lp;
return (b.confidence ?? 0) - (a.confidence ?? 0); return (b.confidence ?? 0) - (a.confidence ?? 0);
}); });
}
// ─── IMP-41 u4 — 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 // 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_candidates_for_unit, :3071-3092) one entry per v4 candidate with
@@ -65,6 +65,47 @@ export type ImageOverride = {
}; };
export type ImageOverridesOverride = Record<string, ImageOverride>; export type ImageOverridesOverride = Record<string, ImageOverride>;
/**
* IMP-55 #93 u1 — bool intent marker that gates whether persisted
* `zone_sections` are consumed by the backend pipeline. Frontend sets
* `true` only on a real user drag-drop (Home.tsx handleSectionDrop, u6)
* and `false` on layout apply/cancel auto-carry (u5/u12). Mirrors the
* Python KNOWN_AXES (`manual_section_assignment`) added in u1 and the
* Vite KNOWN_USER_OVERRIDES_AXES allowlist entry added in u1.
*/
export type ManualSectionAssignmentOverride = boolean;
/**
* IMP-56 #90 u10 — Step-22 text-edit persist axis. Keyed by `zone_id`; the
* inner mapping is `text_path` (= `{slot_key}.{line_index}`) → line value.
* The `text_path` stamp is emitted by `src/text_path_stamper.py` (u8) and
* applied at Step 13 (u9); the value is consumed by `text_override_resolver`
* (u4) and applied at Step 12 (u5). Stale paths (frame swap / layout
* regression between sessions) are tolerated by the backend resolver as
* `skipped`, NOT raised — so the on-disk axis is forward-compat with layout
* and frame churn. Mirrors Python `KNOWN_AXES` entry (u1) and Vite
* `KNOWN_USER_OVERRIDES_AXES` allowlist entry (u3).
*/
export type TextOverridesPerZone = Record<string, string>;
export type TextOverridesOverride = Record<string, TextOverridesPerZone>;
/**
* IMP-56 #90 u10 — Step-22 structure-edit persist axis. Keyed by `zone_id`;
* the inner mapping is SCOPE-LOCKED to `{slot_order, hidden_slots}` — slot
* reorder + slot hide only. Frame swap stays on the existing `frames` axis;
* the `structure_override_resolver` (u6) rejects frame-swap-shaped inner
* keys at the validate gate so Phase Z's no-AI-HTML-structure invariant
* holds across this persisted axis too. Per-slot `list[str]` line content
* is NEVER mutated by the u7 Step-12 apply — that is the `text_overrides`
* axis above. Mirrors Python `KNOWN_AXES` entry (u2) and Vite
* `KNOWN_USER_OVERRIDES_AXES` allowlist entry (u3).
*/
export type StructureOverridePerZone = {
slot_order?: string[];
hidden_slots?: string[];
};
export type StructureOverridesOverride = Record<string, StructureOverridePerZone>;
/** Full on-disk schema. All axes optional — file may carry any subset. */ /** Full on-disk schema. All axes optional — file may carry any subset. */
export interface UserOverrides { export interface UserOverrides {
layout: string; layout: string;
@@ -72,6 +113,9 @@ export interface UserOverrides {
zone_geometries: ZoneGeometriesOverride; zone_geometries: ZoneGeometriesOverride;
zone_sections: ZoneSectionsOverride; zone_sections: ZoneSectionsOverride;
image_overrides: ImageOverridesOverride; image_overrides: ImageOverridesOverride;
manual_section_assignment: ManualSectionAssignmentOverride;
text_overrides: TextOverridesOverride;
structure_overrides: StructureOverridesOverride;
} }
/** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */ /** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */
+23
View File
@@ -213,6 +213,29 @@ export interface UserSelection {
// `image_overrides` axis (KNOWN_AXES, src/user_overrides_io.py u1) and // `image_overrides` axis (KNOWN_AXES, src/user_overrides_io.py u1) and
// the typed-client `ImageOverridesOverride` (services/userOverridesApi.ts u3). // the typed-client `ImageOverridesOverride` (services/userOverridesApi.ts u3).
image_overrides: Record<string, { x: number; y: number; w: number; h: number }>; image_overrides: Record<string, { x: number; y: number; w: number; h: number }>;
// IMP-55 (#93) u3 — bool intent marker gating whether the backend
// consumes persisted `zone_sections` as a user override. Set to `true`
// only by the real drag-drop path (Home.tsx handleSectionDrop, u6); set
// back to `false` by the layout apply/cancel auto-carry path (u5/u12).
// handleGenerate (u7) reads this flag to decide whether to forward
// `overrides.zoneSections` to the backend, replacing the pre-IMP-55
// self-compare against `effectiveSlidePlan`. Seeded `false` in
// `createInitialUserSelection` and only restored on reopen when the
// persisted value is a real boolean (slidePlanUtils.ts u3 layering).
// Mirrors the on-disk axis added in u1 — Python KNOWN_AXES
// (src/user_overrides_io.py), Vite KNOWN_USER_OVERRIDES_AXES
// (Front/vite.config.ts), and `ManualSectionAssignmentOverride`
// (services/userOverridesApi.ts).
manual_section_assignment: boolean;
// IMP-56 #90 u10/u15 — Step-22 text + structure persist axes. Mirrors
// services/userOverridesApi.ts (`TextOverridesOverride` /
// `StructureOverridesOverride`). `text_overrides[zoneId][textPath] = value`
// is fed by SlideCanvas u13 focusout capture + Home u15 autosave;
// `structure_overrides[zoneId] = {slot_order, hidden_slots}` is fed by
// u14 overlay + u15 autosave. Both seeded `{}` in createInitialUserSelection
// and restored on reopen via applyPersistedNonFrameOverrides.
text_overrides: Record<string, Record<string, string>>;
structure_overrides: Record<string, { slot_order?: string[]; hidden_slots?: string[] }>;
}; };
} }
+229 -1
View File
@@ -1,5 +1,12 @@
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent"; import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
import type { UserOverrides } from "../services/userOverridesApi"; import type {
StructureOverridePerZone,
StructureOverridesOverride,
TextOverridesOverride,
TextOverridesPerZone,
UserOverrides,
} from "../services/userOverridesApi";
import { computeZonePositions } from "../services/designAgentApi";
// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ──── // ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ────
// These helpers compose persisted `user_overrides.json` payloads (typed by // These helpers compose persisted `user_overrides.json` payloads (typed by
@@ -84,9 +91,88 @@ export function applyPersistedNonFrameOverrides(
) { ) {
next.image_overrides = { ...persisted.image_overrides }; next.image_overrides = { ...persisted.image_overrides };
} }
// IMP-55 (#93) u3 — restore the bool intent marker only when the persisted
// value is a real `boolean`. A missing axis, `null` (the u4 clear sentinel
// observed post-flush), or any non-boolean shape (string "true", 1, {})
// intentionally falls through to the `createInitialUserSelection` seed of
// `false`. This is the fail-closed half of the marker contract: the
// backend pipeline (u9) consumes persisted `zone_sections` only when
// `manual_section_assignment is True`, so anything other than a real
// `true` MUST end up as `false` in memory to avoid resurrecting stale
// auto-carry assignments as user intent. Both `true` and `false` are
// restored verbatim (the explicit `false` from u12's apply/cancel write
// is meaningful — it pins the marker off across reopens).
if (typeof persisted.manual_section_assignment === "boolean") {
next.manual_section_assignment = persisted.manual_section_assignment;
}
// IMP-56 (#90) u15 — layer the two Step-22 persist axes through the
// u10 extract helpers; their `_isPlainObject` + dedupe gates already
// sanitize foreign / hand-edited payloads, so reopen never poisons
// memory with non-string values or non-list slot_order entries.
next.text_overrides = extractPersistedTextOverrides(persisted);
next.structure_overrides = extractPersistedStructureOverrides(persisted);
return { ...selection, overrides: next }; return { ...selection, overrides: next };
} }
// ─── IMP-56 #90 u10 — typed extract helpers for the two new persist axes ───
// Pure helpers that defensively sanitize Step-22 text_overrides and
// structure_overrides payloads off a `Partial<UserOverrides>` (typed by u10's
// userOverridesApi extension). They mirror the backend validation gates
// (`text_override_resolver` u4 / `structure_override_resolver` u6) on the
// frontend so a hand-edited or schema-drift payload cannot poison memory.
// Layering onto `UserSelection.overrides` arrives in u14~u16; until then
// capture / autosave / restore wiring units consume these as typed.
function _isPlainObject(x: unknown): x is Record<string, unknown> {
return !!x && typeof x === "object" && !Array.isArray(x);
}
function _dedupeStringList(arr: unknown): string[] {
if (!Array.isArray(arr)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const k of arr) {
if (typeof k === "string" && k.length > 0 && !seen.has(k)) {
seen.add(k);
out.push(k);
}
}
return out;
}
export function extractPersistedTextOverrides(
persisted: Partial<UserOverrides> | null | undefined,
): TextOverridesOverride {
const raw = persisted?.text_overrides;
if (!_isPlainObject(raw)) return {};
const out: TextOverridesOverride = {};
for (const [zoneId, perZone] of Object.entries(raw)) {
if (!zoneId || !_isPlainObject(perZone)) continue;
const safe: TextOverridesPerZone = {};
for (const [textPath, value] of Object.entries(perZone)) {
if (textPath && typeof value === "string") safe[textPath] = value;
}
out[zoneId] = safe;
}
return out;
}
export function extractPersistedStructureOverrides(
persisted: Partial<UserOverrides> | null | undefined,
): StructureOverridesOverride {
const raw = persisted?.structure_overrides;
if (!_isPlainObject(raw)) return {};
const out: StructureOverridesOverride = {};
for (const [zoneId, perZone] of Object.entries(raw)) {
if (!zoneId || !_isPlainObject(perZone)) continue;
const safe: StructureOverridePerZone = {};
if (Array.isArray(perZone.slot_order)) safe.slot_order = _dedupeStringList(perZone.slot_order);
if (Array.isArray(perZone.hidden_slots)) safe.hidden_slots = _dedupeStringList(perZone.hidden_slots);
out[zoneId] = safe;
}
return out;
}
/** /**
* Remap persisted frames (`unit_id` → template_id) to the in-memory * Remap persisted frames (`unit_id` → template_id) to the in-memory
* `zone_frames` (region.id → template_id) using the freshly built * `zone_frames` (region.id → template_id) using the freshly built
@@ -159,6 +245,20 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
// here via `saveImageOverride` (SlideCanvas drag/resize handler) and // here via `saveImageOverride` (SlideCanvas drag/resize handler) and
// are seeded on reopen via `applyPersistedNonFrameOverrides`. // are seeded on reopen via `applyPersistedNonFrameOverrides`.
image_overrides: {}, image_overrides: {},
// IMP-56 (#90) u15 — Step-22 axes seeded empty. Entries land here
// via `saveTextOverride` (u13 focusout capture) and
// `saveStructureOverride` (u14 overlay) and are restored on reopen
// via `applyPersistedNonFrameOverrides`.
text_overrides: {},
structure_overrides: {},
// IMP-55 (#93) u3 — bool intent marker seeded `false` so a fresh
// MDX open (no persisted file, or persisted file with axis absent)
// never forwards `overrides.zoneSections` to the backend. The marker
// flips to `true` only via the real drag-drop path (Home.tsx u6) and
// is reset to `false` by layout apply/cancel auto-carry (u5/u12).
// `applyPersistedNonFrameOverrides` may restore a persisted boolean
// verbatim on reopen — see the bool-only guard there.
manual_section_assignment: false,
}, },
}; };
} }
@@ -206,6 +306,60 @@ export function saveImageOverride(
}; };
} }
/**
* IMP-56 (#90) u15 — record a single text-line capture (zone_id, text_path,
* value) onto the in-memory selection's `text_overrides` axis. Mirrors
* `saveImageOverride` (pure / immutable). u13's focusout capture emits one
* entry per finished edit; Home u15's handler funnels each emit through this
* helper before scheduling the debounced PUT (`saveUserOverrides` 300ms).
*/
export function saveTextOverride(
selection: UserSelection,
zoneId: string,
textPath: string,
value: string,
): UserSelection {
const prevZone = selection.overrides.text_overrides[zoneId] ?? {};
return {
...selection,
overrides: {
...selection.overrides,
text_overrides: {
...selection.overrides.text_overrides,
[zoneId]: { ...prevZone, [textPath]: value },
},
},
};
}
/**
* IMP-56 (#90) u15 — record a single structure capture (zone_id ↦
* {slot_order, hidden_slots}) onto the in-memory selection's
* `structure_overrides` axis. Scope-locked to slot reorder + hide (frame
* swap stays on the `frames` axis). u14's overlay emits one entry per
* user mutation; Home u15's handler funnels each emit through this
* helper before scheduling the debounced PUT.
*/
export function saveStructureOverride(
selection: UserSelection,
zoneId: string,
perZone: StructureOverridePerZone,
): UserSelection {
return {
...selection,
overrides: {
...selection.overrides,
structure_overrides: {
...selection.overrides.structure_overrides,
[zoneId]: {
...(perZone.slot_order !== undefined && { slot_order: [...perZone.slot_order] }),
...(perZone.hidden_slots !== undefined && { hidden_slots: [...perZone.hidden_slots] }),
},
},
},
};
}
export function saveZoneSizes(selection: UserSelection, groupId: string, sizes: number[]): UserSelection { export function saveZoneSizes(selection: UserSelection, groupId: string, sizes: number[]): UserSelection {
return { return {
...selection, ...selection,
@@ -320,3 +474,77 @@ export function getEffectiveLayoutId(slidePlan: SlidePlan | null, selection: Use
if (selection.overrides.layout_preset) return selection.overrides.layout_preset; if (selection.overrides.layout_preset) return selection.overrides.layout_preset;
return slidePlan?.layout_preset || 'single'; return slidePlan?.layout_preset || 'single';
} }
// ─── IMP-44 (#73) u3 — zone_geometries layout-mismatch validation ───────────
// Pure helper paired with the backend [override-warning] guards added in u1
// (1-D horizontal-2 / vertical-2 branches of `build_layout_css`) and u2 (2-D
// `_override_to_grid_tracks` call site). Same WARN+DROP / KEEP-known contract,
// but expressed on the frontend so handleGenerate (u4) can validate against
// the active layout *before* forwarding and surface a toast on dropped keys.
//
// Source of truth for expected positions = `computeZonePositions(layoutPreset)`
// (designAgentApi.ts), which mirrors backend `layouts.yaml` (positions field).
// Unknown layout (null / undefined / not in LAYOUT_PRESET_IDS) ⇒ fail-safe
// drop-all: caller has no contract for projecting geometries onto an unknown
// preset, so we keep zero keys rather than passing them through verbatim.
export interface ZoneGeometryValue {
x: number;
y: number;
w: number;
h: number;
}
export interface ZoneGeometriesValidationResult {
kept: Record<string, ZoneGeometryValue>;
dropped: Record<string, ZoneGeometryValue>;
expectedPositions: string[];
valid: boolean;
}
export function validateZoneGeometriesAgainstLayout(
geoms: Record<string, ZoneGeometryValue> | null | undefined,
layoutPreset: LayoutPresetId | string | null | undefined,
): ZoneGeometriesValidationResult {
const kept: Record<string, ZoneGeometryValue> = {};
const dropped: Record<string, ZoneGeometryValue> = {};
const safeGeoms =
geoms && typeof geoms === "object" && !Array.isArray(geoms) ? geoms : null;
// Unknown-layout fail-safe — drop everything; no expected positions known.
if (typeof layoutPreset !== "string" || !LAYOUT_PRESET_IDS.has(layoutPreset)) {
if (safeGeoms) {
for (const [k, v] of Object.entries(safeGeoms)) {
dropped[k] = v;
}
}
return {
kept,
dropped,
expectedPositions: [],
valid: Object.keys(dropped).length === 0,
};
}
const expectedPositions = computeZonePositions(
layoutPreset as LayoutPresetId,
).map((p) => p.name);
const expectedSet = new Set(expectedPositions);
if (safeGeoms) {
for (const [k, v] of Object.entries(safeGeoms)) {
if (expectedSet.has(k)) {
kept[k] = v;
} else {
dropped[k] = v;
}
}
}
return {
kept,
dropped,
expectedPositions,
valid: Object.keys(dropped).length === 0,
};
}
@@ -0,0 +1,117 @@
// IMP-42 u4 — Source-slice coverage for the unconditional handleGenerate
// DIAG console.log on the frontend → backend boundary (issue #71).
//
// Scope (Stage 2 unit u4 contract):
// 1) A single `console.log("[DIAG raw overrides]", ...)` call exists
// inside handleGenerate and precedes the runPipeline call site.
// 2) The DIAG call is unconditional — not wrapped in `if (...)` / `?:` /
// env-var gate / `__DEV__`-style guard. "Silence is the bug" per
// Stage 1 scope-lock (Codex #3) and the Step 13 backend mirror
// already landed in u3.
// 3) The DIAG payload carries shape-only metadata — uploaded file name
// and the override payload object — without referencing raw MDX
// content or any other sample-specific identifier (RULE 0).
//
// Why source-slice (per Stage 2 plan): Home.tsx handleGenerate is wired to
// React state, toast, and a 700-line component tree; the cheapest way to
// pin a single-line surface and prove placement relative to runPipeline is
// to read the source and assert ordering. No React rendering, no fetch
// mock, no DOM. Mirrors the existing pure-helper pattern in
// tests/imp41_application_mode.test.ts.
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const HOME_TSX_PATH = resolve(__dirname, "..", "src", "pages", "Home.tsx");
const HOME_TSX_SOURCE = readFileSync(HOME_TSX_PATH, "utf-8");
// Locate the handleGenerate callback body. The closing brace of
// useCallback's `async () => { ... }` is the next line whose indent matches
// the opening `useCallback(async () => {` exactly — but a simpler proxy is
// "from the handleGenerate keyword to the next useCallback declaration or
// the end-of-file." This is sufficient to scope every assertion below to
// the right function body.
function sliceHandleGenerateBody(source: string): string {
const startMarker = "const handleGenerate = useCallback(async () =>";
const startIdx = source.indexOf(startMarker);
if (startIdx === -1) {
throw new Error("handleGenerate declaration not found in Home.tsx");
}
// End at the next top-level `const ` that begins a new useCallback /
// useMemo / hook binding. handleGenerate is followed by additional
// hooks (handleFileUpload sibling pattern); slicing to the next
// declaration is more than enough to capture the full body.
const afterStart = source.slice(startIdx + startMarker.length);
const nextDeclIdx = afterStart.search(/\n {2}const [A-Za-z]/);
return nextDeclIdx === -1 ? afterStart : afterStart.slice(0, nextDeclIdx);
}
const HANDLE_GENERATE_BODY = sliceHandleGenerateBody(HOME_TSX_SOURCE);
describe("handleGenerate [DIAG raw overrides] (IMP-42 u4)", () => {
it("emits exactly one console.log labelled '[DIAG raw overrides]' inside handleGenerate", () => {
const matches = HANDLE_GENERATE_BODY.match(
/console\.log\(\s*"\[DIAG raw overrides\]"/g,
);
expect(matches).not.toBeNull();
// Exactly one DIAG site per Stage 2 contract — multiple calls would
// either be a copy-paste regression or evidence that the helper
// moved without removing the old site.
expect(matches?.length).toBe(1);
});
it("places the DIAG console.log before the runPipeline call site", () => {
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
const runPipelineIdx = HANDLE_GENERATE_BODY.indexOf(
"runPipeline(state.uploadedFile, overrides)",
);
expect(diagIdx).toBeGreaterThan(-1);
expect(runPipelineIdx).toBeGreaterThan(-1);
expect(diagIdx).toBeLessThan(runPipelineIdx);
});
it("is unconditional — no env-var gate or if-guard wraps the DIAG call", () => {
// Slice the 80 chars immediately preceding the DIAG console.log and
// confirm none of the common gating patterns appear directly above.
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
const preface = HANDLE_GENERATE_BODY.slice(Math.max(0, diagIdx - 200), diagIdx);
// Stage 1 contract: silence is the bug. Any gate here is a regression.
expect(preface).not.toMatch(/if\s*\([^)]*\)\s*$/m);
expect(preface).not.toMatch(/process\.env/);
expect(preface).not.toMatch(/import\.meta\.env/);
expect(preface).not.toMatch(/__DEV__/);
expect(preface).not.toMatch(/DIAG_VERBOSE/i);
expect(preface).not.toMatch(/DEBUG/);
});
it("forwards the file name and overrides object as shape-only payload", () => {
// The DIAG payload must include the uploaded file name (so the user
// can correlate the log line with the MDX they uploaded) and the
// overrides object (so the user can see what crossed the wire).
// It must NOT spread MDX text content or any other large blob —
// sample-agnostic and reviewable in a single log line.
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
const window = HANDLE_GENERATE_BODY.slice(diagIdx, diagIdx + 300);
// Both fields appear in the payload object literal.
expect(window).toMatch(/file:\s*state\.uploadedFile\.name/);
expect(window).toMatch(/\boverrides\b/);
// Sanity: the payload does not pass MDX raw content / a File blob.
expect(window).not.toMatch(/mdxContent|rawMdx|normalizedContent/);
});
it("runs after flushUserOverrides() so the persisted PUT is already committed", () => {
// Ordering invariant from IMP-52 u10 (already in place):
// flushUserOverrides() → DIAG → runPipeline
// Asserts the DIAG sits between the flush and the network call so the
// logged overrides match what backend reads from disk.
const flushIdx = HANDLE_GENERATE_BODY.indexOf("await flushUserOverrides()");
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
const runPipelineIdx = HANDLE_GENERATE_BODY.indexOf(
"runPipeline(state.uploadedFile, overrides)",
);
expect(flushIdx).toBeGreaterThan(-1);
expect(diagIdx).toBeGreaterThan(flushIdx);
expect(diagIdx).toBeLessThan(runPipelineIdx);
});
});
@@ -0,0 +1,122 @@
// IMP-#84 u1 — FramePanel reject silent-automation contract.
//
// Stage 2 unit u1 scope:
// 1) `applyFrameSelection(candidate, onFrameSelect)` invokes onFrameSelect
// with candidate.id verbatim for EVERY V4 label
// (use_as_is / light_edit / restructure / reject) — no window.confirm
// gate, no label-conditional branch, no frame swap.
// 2) Source-presence checks pin the FramePanel.tsx wiring so the runtime
// button → handler → helper chain stays intact even though we cannot
// mount React (no jsdom / RTL / happy-dom in Front devDependencies —
// verified against the IMP-56 u20 `imp90_bottom_actions.test.ts` and
// IMP-92 u5 `imp47b_human_review_toast.test.tsx` precedent that
// explicitly skip DOM mounting).
// 3) No `window.confirm` substring remains in FramePanel.tsx after u1.
//
// Out of scope (Stage 2 exit-report contract):
// - Home.tsx:523-524 `toast.error(aiReviewMsg)` (#92 operational-only).
// - FramePanel reject badge/tooltip read-only labels at L102/L147/L156
// (no popup trigger; preserved as silent operator hint).
// - Backend `zone.provisional` emission (handled by u2 template-only).
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, it, expect, vi } from "vitest";
import { applyFrameSelection } from "../src/components/FramePanel";
import type { FrameCandidate } from "../src/types/designAgent";
const __dirname = dirname(fileURLToPath(import.meta.url));
const FRAME_PANEL_SOURCE = readFileSync(
resolve(__dirname, "../src/components/FramePanel.tsx"),
"utf-8",
);
function makeCandidate(
label: FrameCandidate["label"],
id: string,
): FrameCandidate {
return {
id,
name: `Frame ${id}`,
score: 0.5,
confidence: "medium",
label,
};
}
describe("applyFrameSelection (IMP-#84 u1 — silent-automation contract)", () => {
it("forwards candidate.id to onFrameSelect for use_as_is label", () => {
const onFrameSelect = vi.fn();
applyFrameSelection(makeCandidate("use_as_is", "frame_a"), onFrameSelect);
expect(onFrameSelect).toHaveBeenCalledTimes(1);
expect(onFrameSelect).toHaveBeenCalledWith("frame_a");
});
it("forwards candidate.id to onFrameSelect for light_edit label", () => {
const onFrameSelect = vi.fn();
applyFrameSelection(makeCandidate("light_edit", "frame_b"), onFrameSelect);
expect(onFrameSelect).toHaveBeenCalledTimes(1);
expect(onFrameSelect).toHaveBeenCalledWith("frame_b");
});
it("forwards candidate.id to onFrameSelect for restructure label", () => {
const onFrameSelect = vi.fn();
applyFrameSelection(makeCandidate("restructure", "frame_c"), onFrameSelect);
expect(onFrameSelect).toHaveBeenCalledTimes(1);
expect(onFrameSelect).toHaveBeenCalledWith("frame_c");
});
it("forwards candidate.id to onFrameSelect for reject label — no popup, no frame swap", () => {
// Reject is the silent-automation pivot case: prior IMP-47B u11 gated
// this path with window.confirm; post-IMP-#84 the helper invokes
// onFrameSelect with the reject frame.id directly. Backend / AI 격리
// contract handles AI 재구성 (content-only, frame preserved).
const onFrameSelect = vi.fn();
applyFrameSelection(makeCandidate("reject", "frame_d"), onFrameSelect);
expect(onFrameSelect).toHaveBeenCalledTimes(1);
expect(onFrameSelect).toHaveBeenCalledWith("frame_d");
});
it("does not call onFrameSelect more than once per invocation", () => {
const onFrameSelect = vi.fn();
applyFrameSelection(makeCandidate("reject", "frame_e"), onFrameSelect);
applyFrameSelection(makeCandidate("use_as_is", "frame_f"), onFrameSelect);
expect(onFrameSelect).toHaveBeenCalledTimes(2);
expect(onFrameSelect).toHaveBeenNthCalledWith(1, "frame_e");
expect(onFrameSelect).toHaveBeenNthCalledWith(2, "frame_f");
});
});
describe("FramePanel.tsx source — silent-automation wiring pins (IMP-#84 u1)", () => {
it("has no window.confirm(...) call (popup removed; narrative mentions in comments are allowed)", () => {
// Match the call form `window.confirm(` rather than the bare substring
// so that explanatory comments documenting the removed popup are not
// flagged. A re-introduced call would carry an opening paren.
expect(FRAME_PANEL_SOURCE).not.toMatch(/\bwindow\.confirm\s*\(/);
});
it("does not embed the legacy reject-confirm Korean prompt body", () => {
// Prior IMP-47B u11 string fragment; absence guards against re-introduction.
expect(FRAME_PANEL_SOURCE).not.toContain("V4 reject 라벨입니다");
expect(FRAME_PANEL_SOURCE).not.toContain("계속하시겠습니까?");
});
it("wires the button onClick to handleFrameSelect(candidate)", () => {
expect(FRAME_PANEL_SOURCE).toContain(
"onClick={() => handleFrameSelect(candidate)}",
);
});
it("delegates handleFrameSelect body to applyFrameSelection", () => {
expect(FRAME_PANEL_SOURCE).toContain(
"applyFrameSelection(candidate, onFrameSelect)",
);
});
it("exports applyFrameSelection as a named export for caller-independent reuse", () => {
expect(FRAME_PANEL_SOURCE).toMatch(
/export function applyFrameSelection\(/,
);
});
});
@@ -0,0 +1,90 @@
// IMP-56 (#90) u20 — vitest coverage for the pure request builders exported
// by `BottomActions`. The React component itself is not rendered (jsdom /
// @testing-library NOT in Front devDependencies — verified against the prior
// u14 `imp90_structure_overlay.test.tsx` pattern); we test the deterministic
// pieces that drive the network payload sent to the u18 / u19 middlewares.
//
// Upstream / downstream contracts (verified by prior units):
// - u18 /api/connect : body shape = { run_id, slug } (Front/vite.config.ts
// handleConnectMirror — `imp90_connect_endpoint.test.ts`).
// - u19 /api/export : body shape = { run_id }; response = raw text/html
// with `Content-Disposition: attachment; filename="<run_id>.html"`
// (Front/vite.config.ts handleExportStandalone —
// `imp90_export_endpoint.test.ts`).
//
// u20 scope: builders only. Any drift in URL or JSON shape fails here before
// the request leaves the client. Toast / fetch / blob plumbing is not tested
// (it would require jsdom + a fetch mock; the existing server-side tests
// already pin the wire contract).
import { describe, it, expect } from "vitest";
import {
buildConnectRequest,
buildExportRequest,
buildDownloadFilename,
} from "../src/components/BottomActions";
describe("buildConnectRequest", () => {
it("targets /api/connect", () => {
const { url } = buildConnectRequest("run_42", "mdx_03");
expect(url).toBe("/api/connect");
});
it("emits { run_id, slug } JSON body — matches u18 middleware shape", () => {
const { body } = buildConnectRequest("run_42", "mdx_03");
expect(JSON.parse(body)).toEqual({ run_id: "run_42", slug: "mdx_03" });
});
it("preserves zero-length and unicode run_id verbatim (server validates)", () => {
const { body } = buildConnectRequest("", "x");
expect(JSON.parse(body)).toEqual({ run_id: "", slug: "x" });
const { body: uni } = buildConnectRequest("런", "슬러그");
expect(JSON.parse(uni)).toEqual({ run_id: "런", slug: "슬러그" });
});
it("does not leak extra keys (frame swap / overrides etc.)", () => {
const { body } = buildConnectRequest("r", "s");
expect(Object.keys(JSON.parse(body)).sort()).toEqual(["run_id", "slug"]);
});
});
describe("buildExportRequest", () => {
it("targets /api/export", () => {
const { url } = buildExportRequest("run_42");
expect(url).toBe("/api/export");
});
it("emits { run_id } JSON body — matches u19 middleware shape", () => {
const { body } = buildExportRequest("run_42");
expect(JSON.parse(body)).toEqual({ run_id: "run_42" });
});
it("does not leak extra keys (slug / format etc.)", () => {
const { body } = buildExportRequest("r");
expect(Object.keys(JSON.parse(body))).toEqual(["run_id"]);
});
it("preserves zero-length and unicode run_id verbatim (server validates)", () => {
expect(JSON.parse(buildExportRequest("").body)).toEqual({ run_id: "" });
expect(JSON.parse(buildExportRequest("런").body)).toEqual({ run_id: "런" });
});
});
describe("buildDownloadFilename", () => {
it("returns <run_id>.html for the a[download] click chain", () => {
expect(buildDownloadFilename("run_42")).toBe("run_42.html");
});
it("appends exactly one .html suffix even when run_id already ends in .html", () => {
// The server-side `Content-Disposition` already carries the same
// filename; we mirror it verbatim so browser default behavior wins.
// We intentionally do NOT strip a trailing `.html` — run_id is the
// backend's `Path(args.mdx_path).stem`-style key, which never contains
// a dot suffix (validated by `isValidUserOverridesKey` at u18/u19).
expect(buildDownloadFilename("foo.html")).toBe("foo.html.html");
});
it("returns just .html for empty run_id (server rejects upstream)", () => {
expect(buildDownloadFilename("")).toBe(".html");
});
});
@@ -0,0 +1,282 @@
// IMP-56 (#90) u18 — vitest coverage for the vite POST /api/connect
// middleware and its supporting mirrorDirRecursive helper.
//
// Scope:
// 1) mirrorDirRecursive (pure helper):
// - absent src → returns 0 (no-throw, no dst creation).
// - file-only src → flat copy + count.
// - nested src → recursive copy + count.
// - overwrites pre-existing dst files (cel mirror semantics).
// 2) handleConnectMirror (POST):
// - method != POST → false (chain continues; next middleware may handle).
// - invalid JSON / non-object body → 400.
// - missing run_id or slug → 400.
// - invalid run_id or slug (key gate / path traversal) → 400.
// - final.html missing → 404.
// - success without run-assets dir → 200, assets_copied: 0, html copy ok.
// - success with run-assets dir → 200, assets_copied = file count, dst dir
// populated.
// - dstSlidesDir auto-created when celRoot/public/slides missing.
//
// Tests exercise the pure handler 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 {
handleConnectMirror,
mirrorDirRecursive,
} from "../../vite.config";
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;
},
},
};
}
function makeMockReq(opts: {
method?: string;
}): EventEmitter & { method?: string; send: (body: string) => void } {
const ee = new EventEmitter() as EventEmitter & {
method?: string;
send: (body: string) => void;
};
ee.method = opts.method;
ee.send = (body: string) => {
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
ee.emit("end");
};
return ee;
}
function seedRun(daRoot: string, runId: string, htmlBody: string): string {
const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2");
fs.mkdirSync(runDir, { recursive: true });
const html = path.join(runDir, "final.html");
fs.writeFileSync(html, htmlBody, "utf-8");
return runDir;
}
describe("mirrorDirRecursive (IMP-56 #90 u18)", () => {
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-mirror-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("returns 0 and does not throw when src absent", () => {
const dst = path.join(tmp, "dst");
const n = mirrorDirRecursive(path.join(tmp, "missing"), dst);
expect(n).toBe(0);
expect(fs.existsSync(dst)).toBe(false);
});
it("returns 0 when src exists but is a file (not a directory)", () => {
const srcFile = path.join(tmp, "src.txt");
fs.writeFileSync(srcFile, "x", "utf-8");
const dst = path.join(tmp, "dst");
const n = mirrorDirRecursive(srcFile, dst);
expect(n).toBe(0);
expect(fs.existsSync(dst)).toBe(false);
});
it("flat-copies file entries and returns the file count", () => {
const src = path.join(tmp, "src");
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, "a.css"), "/*a*/", "utf-8");
fs.writeFileSync(path.join(src, "b.png"), "PNG", "utf-8");
const dst = path.join(tmp, "dst");
const n = mirrorDirRecursive(src, dst);
expect(n).toBe(2);
expect(fs.readFileSync(path.join(dst, "a.css"), "utf-8")).toBe("/*a*/");
expect(fs.readFileSync(path.join(dst, "b.png"), "utf-8")).toBe("PNG");
});
it("recurses into nested directories and counts only files", () => {
const src = path.join(tmp, "src");
fs.mkdirSync(path.join(src, "nested", "deep"), { recursive: true });
fs.writeFileSync(path.join(src, "root.txt"), "r", "utf-8");
fs.writeFileSync(path.join(src, "nested", "n.txt"), "n", "utf-8");
fs.writeFileSync(path.join(src, "nested", "deep", "d.txt"), "d", "utf-8");
const dst = path.join(tmp, "dst");
const n = mirrorDirRecursive(src, dst);
expect(n).toBe(3);
expect(fs.readFileSync(path.join(dst, "nested", "deep", "d.txt"), "utf-8"))
.toBe("d");
});
it("overwrites pre-existing files in dst (cel mirror semantics)", () => {
const src = path.join(tmp, "src");
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, "a.css"), "NEW", "utf-8");
const dst = path.join(tmp, "dst");
fs.mkdirSync(dst);
fs.writeFileSync(path.join(dst, "a.css"), "OLD", "utf-8");
mirrorDirRecursive(src, dst);
expect(fs.readFileSync(path.join(dst, "a.css"), "utf-8")).toBe("NEW");
});
});
describe("handleConnectMirror (IMP-56 #90 u18)", () => {
let daRoot: string;
let celRoot: string;
beforeEach(() => {
daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-da-"));
celRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-cel-"));
});
afterEach(() => {
fs.rmSync(daRoot, { recursive: true, force: true });
fs.rmSync(celRoot, { recursive: true, force: true });
});
it("returns false (next chained) when method != POST", () => {
const req = makeMockReq({ method: "GET" });
const { res, state } = makeMockRes();
const handled = handleConnectMirror(req, res, daRoot, celRoot);
expect(handled).toBe(false);
expect(state.ended).toBe(false);
});
it("returns 400 on invalid JSON body", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
const handled = handleConnectMirror(req, res, daRoot, celRoot);
expect(handled).toBe(true);
req.send("{not-json}");
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid JSON");
});
it("returns 400 when body is not a JSON object (array root)", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify(["not", "an", "object"]));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("body must be a JSON object");
});
it("returns 400 when run_id or slug is missing", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "abc" })); // slug missing
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("missing run_id or slug");
});
it("returns 400 when run_id contains path traversal", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "../escape", slug: "03" }));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid run_id or slug");
});
it("returns 400 when slug contains a forward slash", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "valid_id", slug: "03/etc" }));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid run_id or slug");
});
it("returns 404 when final.html does not exist for run_id", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "ghost_run", slug: "03" }));
expect(state.statusCode).toBe(404);
expect(JSON.parse(state.body).error).toBe("final.html not found");
});
it("copies final.html to cel/public/slides/<slug>.html on success", () => {
seedRun(daRoot, "mdx03_run", "<html>03</html>");
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "mdx03_run", slug: "03" }));
expect(state.statusCode).toBe(200);
const dstHtml = path.join(celRoot, "public", "slides", "03.html");
expect(fs.existsSync(dstHtml)).toBe(true);
expect(fs.readFileSync(dstHtml, "utf-8")).toBe("<html>03</html>");
const body = JSON.parse(state.body);
expect(body.success).toBe(true);
expect(body.run_id).toBe("mdx03_run");
expect(body.slug).toBe("03");
expect(body.assets_copied).toBe(0);
expect(body.html_target).toBe(dstHtml);
});
it("auto-creates cel/public/slides when missing", () => {
seedRun(daRoot, "mdx04_run", "<html>04</html>");
expect(fs.existsSync(path.join(celRoot, "public", "slides"))).toBe(false);
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "mdx04_run", slug: "04" }));
expect(state.statusCode).toBe(200);
expect(fs.existsSync(path.join(celRoot, "public", "slides", "04.html"))).toBe(true);
});
it("mirrors assets/ recursively when present in the run dir", () => {
const runDir = seedRun(daRoot, "mdx05_run", "<html>05</html>");
fs.mkdirSync(path.join(runDir, "assets", "css"), { recursive: true });
fs.writeFileSync(path.join(runDir, "assets", "main.css"), "*{}", "utf-8");
fs.writeFileSync(path.join(runDir, "assets", "css", "extra.css"), "p{}", "utf-8");
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "mdx05_run", slug: "05" }));
expect(state.statusCode).toBe(200);
expect(JSON.parse(state.body).assets_copied).toBe(2);
expect(fs.readFileSync(path.join(celRoot, "public", "slides", "assets", "main.css"), "utf-8"))
.toBe("*{}");
expect(fs.readFileSync(path.join(celRoot, "public", "slides", "assets", "css", "extra.css"), "utf-8"))
.toBe("p{}");
});
it("overwrites pre-existing cel slide html (re-Connect semantics)", () => {
seedRun(daRoot, "mdx03_run", "NEW");
const dstSlidesDir = path.join(celRoot, "public", "slides");
fs.mkdirSync(dstSlidesDir, { recursive: true });
fs.writeFileSync(path.join(dstSlidesDir, "03.html"), "OLD", "utf-8");
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleConnectMirror(req, res, daRoot, celRoot);
req.send(JSON.stringify({ run_id: "mdx03_run", slug: "03" }));
expect(state.statusCode).toBe(200);
expect(fs.readFileSync(path.join(dstSlidesDir, "03.html"), "utf-8")).toBe("NEW");
});
});
@@ -0,0 +1,219 @@
// IMP-90 (#90) u12 — vitest coverage for `computeEditModeGates`, the pure
// helper that drives SlideCanvas's mutually-exclusive gesture gating.
// u11 introduced the `EditMode` enum + toolbar; u12 splits the prior
// `isEditMode` shim (which fired ALL gates whenever any edit mode was
// active) into 5 per-gate booleans:
// textEditing — designMode + contentEditable (text mode only).
// imageSelection — in-iframe user-content image click listener
// (image-zone mode only).
// iframePointerAuto — iframe pointer-events:auto so in-iframe gestures
// (text caret OR image click) can reach the doc.
// text mode + image-zone mode; structure stays
// pe:none because u14 will overlay React controls.
// zoneGestures — zone resize 8-handle ring + drag perimeter strips
// + canDrag in handleZoneMouseDown
// (image-zone mode only).
// imageOverlay — React-side image edit overlay (image-zone only).
//
// Mutually-exclusive contract (from the issue body's "discriminated edit
// mode"): no editMode value enables both `textEditing` and either
// `imageSelection` or `zoneGestures` simultaneously. structure mode is
// the no-op placeholder — u14 will plant the structure overlay there.
// pendingLayout fully suppresses every gate (mirrors the existing
// useEffect that forces editMode='off' on pendingLayout entry).
//
// Scope guard: this test exercises the pure helper only — no React
// rendering, no DOM. testing-library/react is NOT in devDependencies
// (verified in Front/package.json); helper-level coverage is the
// established u11 pattern.
import { describe, it, expect } from "vitest";
import {
computeEditModeGates,
type EditMode,
type EditModeGates,
} from "../src/components/SlideCanvas";
const ALL_MODES: EditMode[] = ["off", "text", "structure", "image-zone"];
describe("computeEditModeGates (IMP-90 u12) — pendingLayout suppression", () => {
it.each<EditMode>(ALL_MODES)(
"pendingLayout=true forces every gate false (editMode=%s)",
(mode) => {
const g = computeEditModeGates(mode, true);
expect(g).toEqual<EditModeGates>({
textEditing: false,
imageSelection: false,
iframePointerAuto: false,
zoneGestures: false,
imageOverlay: false,
});
}
);
});
describe("computeEditModeGates (IMP-90 u12) — off baseline", () => {
it("editMode=off pendingLayout=false: every gate false", () => {
expect(computeEditModeGates("off", false)).toEqual<EditModeGates>({
textEditing: false,
imageSelection: false,
iframePointerAuto: false,
zoneGestures: false,
imageOverlay: false,
});
});
});
describe("computeEditModeGates (IMP-90 u12) — text mode", () => {
const g = computeEditModeGates("text", false);
it("textEditing = true (designMode + contentEditable activate)", () => {
expect(g.textEditing).toBe(true);
});
it("iframePointerAuto = true (caret needs to reach the doc)", () => {
expect(g.iframePointerAuto).toBe(true);
});
it("imageSelection = false (no in-iframe image click listener)", () => {
expect(g.imageSelection).toBe(false);
});
it("zoneGestures = false (no zone resize / drag affordances)", () => {
expect(g.zoneGestures).toBe(false);
});
it("imageOverlay = false (no React-side image overlay)", () => {
expect(g.imageOverlay).toBe(false);
});
});
describe("computeEditModeGates (IMP-90 u12) — structure mode", () => {
const g = computeEditModeGates("structure", false);
// structure mode is the u14 placeholder — no gestures here yet. All five
// gates stay false so the iframe and React overlays remain quiescent
// until u14 plants the structure overlay on the React layer.
it("every gate false (u14 will plant the structure overlay later)", () => {
expect(g).toEqual<EditModeGates>({
textEditing: false,
imageSelection: false,
iframePointerAuto: false,
zoneGestures: false,
imageOverlay: false,
});
});
});
describe("computeEditModeGates (IMP-90 u12) — image-zone mode", () => {
const g = computeEditModeGates("image-zone", false);
it("textEditing = false (contentEditable would steal image clicks)", () => {
expect(g.textEditing).toBe(false);
});
it("imageSelection = true (in-iframe img click → selectedImageId)", () => {
expect(g.imageSelection).toBe(true);
});
it("iframePointerAuto = true (so image clicks reach the doc)", () => {
expect(g.iframePointerAuto).toBe(true);
});
it("zoneGestures = true (zone resize + drag affordances visible)", () => {
expect(g.zoneGestures).toBe(true);
});
it("imageOverlay = true (React-side overlay renders the drag handles)", () => {
expect(g.imageOverlay).toBe(true);
});
});
describe("computeEditModeGates (IMP-90 u12) — mutually exclusive contract", () => {
it("text mode never co-activates image-zone gates (imageSelection / zoneGestures / imageOverlay)", () => {
const g = computeEditModeGates("text", false);
expect(g.textEditing).toBe(true);
expect(g.imageSelection).toBe(false);
expect(g.zoneGestures).toBe(false);
expect(g.imageOverlay).toBe(false);
});
it("image-zone mode never co-activates text gates (textEditing)", () => {
const g = computeEditModeGates("image-zone", false);
expect(g.imageSelection).toBe(true);
expect(g.textEditing).toBe(false);
});
it.each<EditMode>(ALL_MODES)(
"for every editMode (%s), textEditing AND zoneGestures are NEVER both true",
(mode) => {
const g = computeEditModeGates(mode, false);
expect(g.textEditing && g.zoneGestures).toBe(false);
}
);
it.each<EditMode>(ALL_MODES)(
"for every editMode (%s), textEditing AND imageOverlay are NEVER both true",
(mode) => {
const g = computeEditModeGates(mode, false);
expect(g.textEditing && g.imageOverlay).toBe(false);
}
);
it.each<EditMode>(ALL_MODES)(
"for every editMode (%s), textEditing AND imageSelection are NEVER both true",
(mode) => {
const g = computeEditModeGates(mode, false);
expect(g.textEditing && g.imageSelection).toBe(false);
}
);
});
describe("computeEditModeGates (IMP-90 u12) — iframePointerAuto coupling", () => {
// pe:auto is the iframe-side prerequisite for ANY in-iframe gesture
// (text caret OR image click). The helper must NOT advertise an
// in-iframe gate as active while pe is none, or those gestures would
// be silently swallowed by the wrapper.
it.each<EditMode>(ALL_MODES)(
"textEditing → iframePointerAuto (editMode=%s)",
(mode) => {
const g = computeEditModeGates(mode, false);
if (g.textEditing) expect(g.iframePointerAuto).toBe(true);
}
);
it.each<EditMode>(ALL_MODES)(
"imageSelection → iframePointerAuto (editMode=%s)",
(mode) => {
const g = computeEditModeGates(mode, false);
if (g.imageSelection) expect(g.iframePointerAuto).toBe(true);
}
);
});
describe("computeEditModeGates (IMP-90 u12) — referential transparency", () => {
it("multiple calls with the same inputs return equal output", () => {
const a = computeEditModeGates("image-zone", false);
const b = computeEditModeGates("image-zone", false);
const c = computeEditModeGates("image-zone", false);
expect(a).toEqual(b);
expect(b).toEqual(c);
});
it("does not mutate captured state across calls (independent invocations)", () => {
const a = computeEditModeGates("text", false);
const _b = computeEditModeGates("image-zone", false);
// a must still reflect text mode after b's call.
expect(a.textEditing).toBe(true);
expect(a.imageSelection).toBe(false);
});
});
describe("computeEditModeGates (IMP-90 u12) — gate truthtable snapshot", () => {
// Snapshot for human-readable inspection — the per-mode flag layout
// is the contract u13 (text capture) and u14 (structure overlay)
// will build against. Any change requires updating both this test
// AND the consuming gates in SlideCanvas.tsx.
it("non-pendingLayout truthtable matches the u12 contract", () => {
const rows = (["off", "text", "structure", "image-zone"] as EditMode[]).map(
(m) => ({ mode: m, ...computeEditModeGates(m, false) })
);
expect(rows).toEqual([
{ mode: "off", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false },
{ mode: "text", textEditing: true, imageSelection: false, iframePointerAuto: true, zoneGestures: false, imageOverlay: false },
{ mode: "structure", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false },
{ mode: "image-zone", textEditing: false, imageSelection: true, iframePointerAuto: true, zoneGestures: true, imageOverlay: true },
]);
});
});
@@ -0,0 +1,133 @@
// IMP-90 (#90) u11 — vitest coverage for the discriminated EditMode enum
// and its pure transition helper `nextEditMode`. Replaces the prior single
// `isEditMode` boolean state. u11 introduces ONLY the state surface + the
// toolbar UI; gesture gating per mode is u12 (mutually exclusive) and must
// not regress this contract.
//
// Scope (Stage 2 unit u11 contract):
// 1) EDIT_MODES is the canonical ['text','structure','image-zone'] list
// in toolbar render order. 'off' is intentionally excluded from the
// iterable because it is the implicit baseline (no button); the
// toolbar only renders the three active modes per the u11 design.
// 2) nextEditMode is a pure (current, requested) -> EditMode mapping
// with three rules:
// - requested === 'off' -> 'off' (explicit exit)
// - requested === current -> 'off' (toggle exit)
// - requested !== current && != 'off'-> requested (mode switch)
// 3) The helper is referentially transparent — no side effects, no
// React, no useState, no DOM. SlideCanvas wires it as the useState
// updater callback (`setEditMode((prev) => nextEditMode(prev, m))`),
// so covering the helper here covers every toolbar click outcome
// directly without DOM rendering. (@testing-library/react is NOT in
// devDependencies; this mirrors the imp47b_human_review_toast pattern.)
// 4) The exported EditMode type union must contain exactly the four
// members 'off' | 'text' | 'structure' | 'image-zone'. The runtime
// EDIT_MODES list intentionally excludes 'off' (see (1) above).
//
// Forward-compat note: u12 will discriminate per-mode gating but MUST NOT
// alter the (current, requested) -> next contract verified here. Any
// change to the toggle/switch/exit semantics is a scope-violation against
// the u11 binding contract.
import { describe, it, expect } from "vitest";
import {
EDIT_MODES,
nextEditMode,
type EditMode,
} from "../src/components/SlideCanvas";
describe("EDIT_MODES (IMP-90 u11 — toolbar render order)", () => {
it("contains exactly the three active modes in toolbar order", () => {
expect(EDIT_MODES).toEqual(["text", "structure", "image-zone"]);
});
it("excludes 'off' — baseline is implicit, no toolbar button", () => {
expect(EDIT_MODES).not.toContain("off" as EditMode);
});
it("has length 3", () => {
expect(EDIT_MODES.length).toBe(3);
});
});
describe("nextEditMode (IMP-90 u11 — pure transition helper)", () => {
describe("explicit 'off' request always exits", () => {
it.each<EditMode>(["off", "text", "structure", "image-zone"])(
"current=%s, requested=off -> off",
(current) => {
expect(nextEditMode(current, "off")).toBe("off");
}
);
});
describe("clicking the active mode toggles back to 'off'", () => {
it.each<EditMode>(["text", "structure", "image-zone"])(
"current=%s, requested=%s -> off",
(mode) => {
expect(nextEditMode(mode, mode)).toBe("off");
}
);
});
describe("clicking a different mode switches", () => {
const cases: Array<[EditMode, EditMode]> = [
["off", "text"],
["off", "structure"],
["off", "image-zone"],
["text", "structure"],
["text", "image-zone"],
["structure", "text"],
["structure", "image-zone"],
["image-zone", "text"],
["image-zone", "structure"],
];
it.each(cases)("current=%s, requested=%s -> requested", (current, requested) => {
expect(nextEditMode(current, requested)).toBe(requested);
});
});
it("is referentially transparent — multiple calls with same inputs return same output", () => {
const a = nextEditMode("text", "structure");
const b = nextEditMode("text", "structure");
const c = nextEditMode("text", "structure");
expect(a).toBe("structure");
expect(b).toBe("structure");
expect(c).toBe("structure");
});
it("never returns a value outside the EditMode union", () => {
const all: EditMode[] = ["off", "text", "structure", "image-zone"];
for (const current of all) {
for (const requested of all) {
const result = nextEditMode(current, requested);
expect(all).toContain(result);
}
}
});
it("preserves toggle semantics under repeated identical clicks", () => {
// off -> text -> off -> text -> off (toggle behavior)
let m: EditMode = "off";
m = nextEditMode(m, "text");
expect(m).toBe("text");
m = nextEditMode(m, "text");
expect(m).toBe("off");
m = nextEditMode(m, "text");
expect(m).toBe("text");
m = nextEditMode(m, "text");
expect(m).toBe("off");
});
it("preserves switch semantics across distinct mode clicks", () => {
// off -> text -> structure -> image-zone -> off (via toggle)
let m: EditMode = "off";
m = nextEditMode(m, "text");
expect(m).toBe("text");
m = nextEditMode(m, "structure");
expect(m).toBe("structure");
m = nextEditMode(m, "image-zone");
expect(m).toBe("image-zone");
m = nextEditMode(m, "image-zone");
expect(m).toBe("off");
});
});
@@ -0,0 +1,255 @@
// IMP-56 (#90) u19 — vitest coverage for the vite POST /api/export
// middleware and its supporting inlineAssetsAsDataUrls helper.
//
// Scope:
// 1) inlineAssetsAsDataUrls (pure helper):
// - no url(assets/...) refs → passthrough.
// - single PNG ref → inlined as base64 data: URL with image/png mime.
// - multiple refs → all inlined.
// - SVG ref → image/svg+xml mime.
// - missing asset file → left as-is (no throw, no rewrite).
// - data:/http:/ URLs (non-asset) → untouched.
// 2) handleExportStandalone (POST):
// - method != POST → false (chain continues; next middleware may handle).
// - invalid JSON / non-object body → 400.
// - missing run_id → 400.
// - invalid run_id (key gate / path traversal) → 400.
// - final.html missing → 404.
// - success → 200 with Content-Disposition: attachment; filename=...,
// Content-Type: text/html; charset=utf-8, body = inlined HTML.
//
// Tests exercise the pure handler 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 {
handleExportStandalone,
inlineAssetsAsDataUrls,
} from "../../vite.config";
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;
},
},
};
}
function makeMockReq(opts: {
method?: string;
}): EventEmitter & { method?: string; send: (body: string) => void } {
const ee = new EventEmitter() as EventEmitter & {
method?: string;
send: (body: string) => void;
};
ee.method = opts.method;
ee.send = (body: string) => {
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
ee.emit("end");
};
return ee;
}
function seedRun(
daRoot: string,
runId: string,
htmlBody: string,
assets?: Record<string, Buffer | string>,
): string {
const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2");
fs.mkdirSync(runDir, { recursive: true });
const html = path.join(runDir, "final.html");
fs.writeFileSync(html, htmlBody, "utf-8");
if (assets) {
for (const [rel, buf] of Object.entries(assets)) {
const dst = path.join(runDir, "assets", rel);
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.writeFileSync(dst, buf);
}
}
return runDir;
}
describe("inlineAssetsAsDataUrls (IMP-56 #90 u19)", () => {
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-inline-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("returns html unchanged when no url(assets/...) refs are present", () => {
const html = "<html><style>body{color:red;}</style><body>hi</body></html>";
expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html);
});
it("inlines a single PNG asset as a base64 data: URL with image/png mime", () => {
fs.mkdirSync(path.join(tmp, "frame_x"), { recursive: true });
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
fs.writeFileSync(path.join(tmp, "frame_x", "a.png"), pngBytes);
const html = "background: url(assets/frame_x/a.png);";
const out = inlineAssetsAsDataUrls(html, tmp);
expect(out).toContain(`url("data:image/png;base64,${pngBytes.toString("base64")}")`);
expect(out).not.toContain("url(assets/frame_x/a.png)");
});
it("inlines multiple refs across the same HTML body", () => {
fs.mkdirSync(path.join(tmp, "f"), { recursive: true });
fs.writeFileSync(path.join(tmp, "f", "one.png"), Buffer.from("ONE"));
fs.writeFileSync(path.join(tmp, "f", "two.png"), Buffer.from("TWO"));
const html = "a{background:url(assets/f/one.png)} b{background:url(assets/f/two.png)}";
const out = inlineAssetsAsDataUrls(html, tmp);
expect(out).toContain(`data:image/png;base64,${Buffer.from("ONE").toString("base64")}`);
expect(out).toContain(`data:image/png;base64,${Buffer.from("TWO").toString("base64")}`);
});
it("uses image/svg+xml mime for .svg refs", () => {
fs.mkdirSync(path.join(tmp, "f"), { recursive: true });
fs.writeFileSync(path.join(tmp, "f", "icon.svg"), "<svg/>", "utf-8");
const html = "url(assets/f/icon.svg)";
const out = inlineAssetsAsDataUrls(html, tmp);
expect(out).toContain("data:image/svg+xml;base64,");
});
it("leaves the ref untouched when the asset file is missing", () => {
const html = "url(assets/missing/file.png)";
const out = inlineAssetsAsDataUrls(html, tmp);
expect(out).toBe(html);
});
it("does not touch data: or http(s): url() values (only matches assets/...)", () => {
const html =
"x{background:url(data:image/png;base64,AAA)} " +
"y{background:url(https://cdn.x/a.png)}";
expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html);
});
it("handles quoted url(...) refs (single and double quotes)", () => {
fs.mkdirSync(path.join(tmp, "q"), { recursive: true });
fs.writeFileSync(path.join(tmp, "q", "k.png"), Buffer.from("K"));
const html =
"a{background:url('assets/q/k.png')} b{background:url(\"assets/q/k.png\")}";
const out = inlineAssetsAsDataUrls(html, tmp);
const data = `data:image/png;base64,${Buffer.from("K").toString("base64")}`;
expect(out.split(data).length - 1).toBe(2);
});
});
describe("handleExportStandalone (IMP-56 #90 u19)", () => {
let daRoot: string;
beforeEach(() => {
daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-da-"));
});
afterEach(() => {
fs.rmSync(daRoot, { recursive: true, force: true });
});
it("returns false (next chained) when method != POST", () => {
const req = makeMockReq({ method: "GET" });
const { res, state } = makeMockRes();
const handled = handleExportStandalone(req, res, daRoot);
expect(handled).toBe(false);
expect(state.ended).toBe(false);
});
it("returns 400 on invalid JSON body", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
const handled = handleExportStandalone(req, res, daRoot);
expect(handled).toBe(true);
req.send("{nope");
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid JSON");
});
it("returns 400 when body is not a JSON object (array root)", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify(["x"]));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("body must be a JSON object");
});
it("returns 400 when run_id is missing", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({}));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("missing run_id");
});
it("returns 400 when run_id contains path traversal", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({ run_id: "../escape" }));
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body).error).toBe("invalid run_id");
});
it("returns 404 when final.html does not exist for run_id", () => {
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({ run_id: "ghost_run" }));
expect(state.statusCode).toBe(404);
expect(JSON.parse(state.body).error).toBe("final.html not found");
});
it("returns 200 with text/html body + Content-Disposition on success", () => {
seedRun(daRoot, "mdx03_run", "<html><body>03</body></html>");
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({ run_id: "mdx03_run" }));
expect(state.statusCode).toBe(200);
expect(state.headers["Content-Type"]).toBe("text/html; charset=utf-8");
expect(state.headers["Content-Disposition"]).toBe(
'attachment; filename="mdx03_run.html"',
);
expect(state.body).toBe("<html><body>03</body></html>");
});
it("inlines assets in final.html when run dir has assets/", () => {
const pngBytes = Buffer.from("PNGDATA");
seedRun(
daRoot,
"mdx05_run",
"<html><body><div style=\"background: url(assets/f/x.png)\"></div></body></html>",
{ "f/x.png": pngBytes },
);
const req = makeMockReq({ method: "POST" });
const { res, state } = makeMockRes();
handleExportStandalone(req, res, daRoot);
req.send(JSON.stringify({ run_id: "mdx05_run" }));
expect(state.statusCode).toBe(200);
expect(state.body).toContain(
`data:image/png;base64,${pngBytes.toString("base64")}`,
);
expect(state.body).not.toContain("url(assets/f/x.png)");
});
});
@@ -0,0 +1,150 @@
// IMP-90 (#90) u14 — vitest coverage for the pure helpers exported by
// `StructureEditOverlay`. The React component itself is not rendered
// (jsdom / @testing-library NOT in Front devDependencies — verified in
// `Front/package.json`); we test the deterministic pieces that drive its
// JSX: `resolveEffectiveSlotOrder` (effective-order resolution under
// override) and `moveItem` (immutable reorder primitive).
//
// Upstream / downstream contracts (verified by prior units):
// - u2 KNOWN_AXES += structure_overrides (Python backend).
// - u3 vite allowlist += structure_overrides.
// - u6 structure_override_resolver — inner shape locked to
// {slot_order, hidden_slots}; frame swap REJECTED to existing
// frames axis.
// - u10 typed-client `StructureOverridePerZone` + extract helper.
// - u15 (next) will debounce + PUT the emitted capture.
//
// u14 scope: pure helpers only. React render path is verified by Codex
// auditor via static read of the JSX (no runtime test possible without
// jsdom). Tests below are intentionally side-effect-free.
import { describe, it, expect } from "vitest";
import {
resolveEffectiveSlotOrder,
moveItem,
} from "../src/components/StructureEditOverlay";
// ─────────────────────────────────────────────────────────────────────
// resolveEffectiveSlotOrder
// ─────────────────────────────────────────────────────────────────────
describe("resolveEffectiveSlotOrder — no override", () => {
it("returns a fresh copy of the discovered keys when slotOrder is undefined", () => {
const discovered = ["a", "b", "c"];
const out = resolveEffectiveSlotOrder(discovered, undefined);
expect(out).toEqual(["a", "b", "c"]);
expect(out).not.toBe(discovered);
});
it("returns a fresh copy when slotOrder is null", () => {
const out = resolveEffectiveSlotOrder(["a", "b"], null);
expect(out).toEqual(["a", "b"]);
});
it("returns a fresh copy when slotOrder is empty []", () => {
const out = resolveEffectiveSlotOrder(["a", "b"], []);
expect(out).toEqual(["a", "b"]);
});
it("handles empty discovered list (no slots in zone)", () => {
expect(resolveEffectiveSlotOrder([], undefined)).toEqual([]);
expect(resolveEffectiveSlotOrder([], ["x"])).toEqual([]);
});
});
describe("resolveEffectiveSlotOrder — full override", () => {
it("reorders all discovered keys per slotOrder", () => {
expect(
resolveEffectiveSlotOrder(["a", "b", "c"], ["c", "a", "b"]),
).toEqual(["c", "a", "b"]);
});
it("is idempotent when slotOrder matches discovered order", () => {
expect(
resolveEffectiveSlotOrder(["a", "b", "c"], ["a", "b", "c"]),
).toEqual(["a", "b", "c"]);
});
});
describe("resolveEffectiveSlotOrder — partial / drift override", () => {
it("appends missing discovered keys in backend order at the tail", () => {
// user reordered b -> first, but c was added later by backend.
expect(
resolveEffectiveSlotOrder(["a", "b", "c"], ["b", "a"]),
).toEqual(["b", "a", "c"]);
});
it("drops override entries that no longer exist in discovered keys", () => {
// user had slot 'x' before; backend dropped it.
expect(
resolveEffectiveSlotOrder(["a", "b"], ["x", "a", "b"]),
).toEqual(["a", "b"]);
});
it("dedupes duplicate entries within slotOrder", () => {
expect(
resolveEffectiveSlotOrder(["a", "b", "c"], ["a", "a", "b"]),
).toEqual(["a", "b", "c"]);
});
it("dedupe + drop + append all together (stress)", () => {
expect(
resolveEffectiveSlotOrder(
["a", "b", "c", "d"],
["d", "x", "d", "a", "ghost"],
),
).toEqual(["d", "a", "b", "c"]);
});
it("ignores non-string entries in slotOrder", () => {
const bogus = ["a", null as unknown as string, undefined as unknown as string, "b"];
expect(resolveEffectiveSlotOrder(["a", "b"], bogus)).toEqual(["a", "b"]);
});
});
// ─────────────────────────────────────────────────────────────────────
// moveItem
// ─────────────────────────────────────────────────────────────────────
describe("moveItem — happy paths", () => {
it("moves index 0 down by 1 (swap with index 1)", () => {
expect(moveItem(["a", "b", "c"], 0, 1)).toEqual(["b", "a", "c"]);
});
it("moves index 2 up by 1 (swap with index 1)", () => {
expect(moveItem(["a", "b", "c"], 2, -1)).toEqual(["a", "c", "b"]);
});
it("moves across larger delta (swap with target)", () => {
expect(moveItem(["a", "b", "c", "d"], 0, 2)).toEqual(["c", "b", "a", "d"]);
});
});
describe("moveItem — bounds", () => {
it("no-op (fresh copy) when moving first up", () => {
const src = ["a", "b", "c"];
const out = moveItem(src, 0, -1);
expect(out).toEqual(["a", "b", "c"]);
expect(out).not.toBe(src);
});
it("no-op when moving last down", () => {
expect(moveItem(["a", "b", "c"], 2, 1)).toEqual(["a", "b", "c"]);
});
it("no-op when index negative", () => {
expect(moveItem(["a", "b"], -1, 1)).toEqual(["a", "b"]);
});
it("no-op when index past end", () => {
expect(moveItem(["a", "b"], 5, -1)).toEqual(["a", "b"]);
});
it("no-op when target falls out of range from large delta", () => {
expect(moveItem(["a", "b", "c"], 1, 99)).toEqual(["a", "b", "c"]);
});
it("no-op on empty array (any index)", () => {
expect(moveItem<string>([], 0, 1)).toEqual([]);
});
});
describe("moveItem — immutability", () => {
it("never mutates the input array", () => {
const src = ["a", "b", "c"];
moveItem(src, 0, 1);
expect(src).toEqual(["a", "b", "c"]);
});
it("returns a new reference even when no-op", () => {
const src = ["a", "b"];
expect(moveItem(src, 0, -1)).not.toBe(src);
});
it("preserves T-typed values (number array)", () => {
expect(moveItem([1, 2, 3], 0, 1)).toEqual([2, 1, 3]);
});
});
@@ -0,0 +1,259 @@
// IMP-90 (#90) u13 — vitest coverage for `deriveTextEditCapture`, the pure
// helper that resolves a contentEditable focusout target into the
// (zone_id, text_path, value) capture tuple emitted by SlideCanvas.
//
// Upstream contract (verified by prior units):
// - u8 `src/text_path_stamper.py` stamps `data-text-path="{slot_key}.{
// line_index}"` on every rendered text-line opening tag at Step 13.
// - u9 wires the stamper into `render_slide` so the final.html consumed
// by SlideCanvas's iframe carries those attributes.
// - Phase Z slide-base wraps every zone in `.zone[data-zone-position]`
// (verified at SlideCanvas.tsx onLoad measure block).
//
// u13 scope: derive the capture tuple from any descendant of a stamped
// line, OR the stamped line itself. Non-stamped targets (slide-base
// title/footer, decorative spans outside the zone tree) return null so
// the focusout handler silently skips them — never crashes.
//
// Forward-compat note: u15 will debounce + PUT the capture; u15 MUST NOT
// alter the (target) -> {zoneId, textPath, value} | null contract verified
// here. Any change to the resolution semantics is a scope-violation
// against the u13 binding contract.
//
// jsdom is NOT in devDependencies (verified in Front/package.json); this
// test mocks `TextEditCaptureTarget` with structurally-typed objects per
// the established u11/u12 pure-helper pattern.
import { describe, it, expect } from "vitest";
import {
deriveTextEditCapture,
type TextEditCapture,
type TextEditCaptureTarget,
} from "../src/components/SlideCanvas";
// --- minimal closest-aware mock builders -----------------------------
// Each node only needs to know which selectors it matches and its
// parent chain — `closest` is implemented by walking parent pointers.
interface MockNodeSpec {
matches: string[];
attrs?: Record<string, string>;
text?: string | null;
parent?: MockNode | null;
}
interface MockNode extends TextEditCaptureTarget {
matches(sel: string): boolean;
parent: MockNode | null;
}
function makeNode(spec: MockNodeSpec): MockNode {
const node: MockNode = {
parent: spec.parent ?? null,
matches(sel: string) {
return spec.matches.includes(sel);
},
closest(sel: string): TextEditCaptureTarget | null {
let cur: MockNode | null = node;
while (cur) {
if (cur.matches(sel)) return cur;
cur = cur.parent;
}
return null;
},
getAttribute(name: string): string | null {
return spec.attrs?.[name] ?? null;
},
textContent: spec.text === undefined ? null : spec.text,
};
return node;
}
// Canonical zone + line scaffold used across happy-path tests.
// `null` for any field is preserved verbatim so edge cases (missing attr /
// null textContent) can exercise the helper's defensive branches.
function makeZoneLineScaffold(opts: {
zoneId?: string | null;
textPath?: string | null;
lineText?: string | null;
}) {
const zone = makeNode({
matches: [".zone[data-zone-position]"],
attrs: opts.zoneId === null ? {} : { "data-zone-position": opts.zoneId ?? "top" },
});
const line = makeNode({
matches: ["[data-text-path]"],
attrs:
opts.textPath === null
? {}
: { "data-text-path": opts.textPath ?? "row_1_left_body.0" },
text: opts.lineText === undefined ? "hello world" : opts.lineText,
parent: zone,
});
return { zone, line };
}
describe("deriveTextEditCapture (IMP-90 u13) — null inputs / non-stamped", () => {
it("returns null when target is null", () => {
expect(deriveTextEditCapture(null)).toBeNull();
});
it("returns null when no ancestor has data-text-path (e.g., slide title)", () => {
const title = makeNode({
matches: [".slide-title"],
text: "Phase Z 슬라이드",
});
expect(deriveTextEditCapture(title)).toBeNull();
});
it("returns null when the stamped line has no enclosing zone", () => {
// Decorative line stamped by the future u8 but rendered outside a
// zone (e.g., footer pill). u13 silently skips — caller never sees
// a half-resolved capture.
const orphanLine = makeNode({
matches: ["[data-text-path]"],
attrs: { "data-text-path": "footer.0" },
text: "결론",
});
expect(deriveTextEditCapture(orphanLine)).toBeNull();
});
});
describe("deriveTextEditCapture (IMP-90 u13) — happy path", () => {
it("resolves (zoneId, textPath, value) when target IS the stamped line", () => {
const { line } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: "분석 결과",
});
expect(deriveTextEditCapture(line)).toEqual<TextEditCapture>({
zoneId: "top",
textPath: "row_1_left_body.0",
value: "분석 결과",
});
});
it("walks up to the stamped line when target is a nested descendant", () => {
const { zone, line } = makeZoneLineScaffold({
zoneId: "bottom_l",
textPath: "left_body.2",
lineText: "wrapped",
});
// emulate a SPAN inside the stamped line (e.g., bold inline span)
const innerSpan = makeNode({
matches: ["span.highlight"],
text: "ignored — closest walks to the line",
parent: line,
});
void zone;
expect(deriveTextEditCapture(innerSpan)).toEqual<TextEditCapture>({
zoneId: "bottom_l",
textPath: "left_body.2",
value: "wrapped",
});
});
it("preserves the line's textContent without HTML normalization", () => {
const { line } = makeZoneLineScaffold({
zoneId: "primary",
textPath: "headline.0",
lineText: " spaced inner words ",
});
// u13 trims outer whitespace but does NOT collapse interior whitespace
// — value mirrors what user typed, modulo blur-edge trim.
expect(deriveTextEditCapture(line)?.value).toBe("spaced inner words");
});
it("returns empty string when textContent is null (edge: empty line)", () => {
const { line } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: null,
});
expect(deriveTextEditCapture(line)?.value).toBe("");
});
it("returns empty string when textContent is whitespace-only", () => {
const { line } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: " \n \t ",
});
expect(deriveTextEditCapture(line)?.value).toBe("");
});
});
describe("deriveTextEditCapture (IMP-90 u13) — missing attribute defensiveness", () => {
it("returns null when data-text-path attribute is absent on the matched line", () => {
// Should not happen with the u8 stamper, but a downstream mutation
// (e.g., user pasting a fresh element) could create a stamped-class
// node without the actual attribute. u13 stays defensive.
const zone = makeNode({
matches: [".zone[data-zone-position]"],
attrs: { "data-zone-position": "top" },
});
const lineNoPath = makeNode({
matches: ["[data-text-path]"],
attrs: {},
text: "hello",
parent: zone,
});
expect(deriveTextEditCapture(lineNoPath)).toBeNull();
});
it("returns null when data-zone-position attribute is absent on the matched zone", () => {
const zoneNoId = makeNode({
matches: [".zone[data-zone-position]"],
attrs: {},
});
const line = makeNode({
matches: ["[data-text-path]"],
attrs: { "data-text-path": "row_1_left_body.0" },
text: "hello",
parent: zoneNoId,
});
expect(deriveTextEditCapture(line)).toBeNull();
});
});
describe("deriveTextEditCapture (IMP-90 u13) — referential transparency", () => {
it("multiple calls with the same target return equal captures", () => {
const { line } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: "stable",
});
const a = deriveTextEditCapture(line);
const b = deriveTextEditCapture(line);
expect(a).toEqual(b);
expect(a).not.toBe(b); // fresh objects each call (caller-friendly)
});
it("does not mutate the target element (attrs / parent / textContent unchanged)", () => {
const { line, zone } = makeZoneLineScaffold({
zoneId: "top",
textPath: "row_1_left_body.0",
lineText: "immutable",
});
deriveTextEditCapture(line);
expect(line.getAttribute("data-text-path")).toBe("row_1_left_body.0");
expect(line.textContent).toBe("immutable");
expect(zone.getAttribute("data-zone-position")).toBe("top");
});
});
describe("deriveTextEditCapture (IMP-90 u13) — zone id pass-through", () => {
// u13 does not validate the zone id shape — Phase Z slide-base owns the
// canonical zone position vocabulary, and u15 / pipeline-side resolver
// (u4) re-validate downstream. u13 just forwards whatever the stamped
// DOM declared.
const ZONE_IDS = ["top", "bottom_l", "bottom_r", "primary", "secondary"];
it.each(ZONE_IDS)("preserves zone id '%s' verbatim", (zid) => {
const { line } = makeZoneLineScaffold({
zoneId: zid,
textPath: `${zid}.0`,
lineText: "x",
});
const cap = deriveTextEditCapture(line);
expect(cap?.zoneId).toBe(zid);
expect(cap?.textPath).toBe(`${zid}.0`);
});
});
@@ -0,0 +1,250 @@
// IMP-43 (#72) u6 — /api/run reuseFromRunId forwarding coverage.
//
// Stage 2 unit scope:
// 1) Front/client/src/services/designAgentApi.ts `runPipeline`:
// • accepts an optional 3rd arg `reuseFromRunId: string`.
// • includes `reuseFromRunId` in the POST body when truthy.
// • OMITS `reuseFromRunId` from the body when absent / empty / undefined
// → byte-identical to the pre-u6 POST contract (absent flag = full
// pipeline; backend u1 guard never sees an empty PREV_RUN_ID).
// • leaves `filename`, `content`, and `overrides` untouched alongside
// the new field (no payload-shape regression).
// 2) Front/vite.config.ts `/api/run` handler:
// • declares `reuseFromRunId?: string` in the payload type so a typed
// client cannot send a payload the server silently drops.
// • destructures `reuseFromRunId` from `payload` (sibling of
// `overrides`, NOT nested under it — the backend u1 post-merge
// guard treats reuse as a pipeline mode, not an override).
// • forwards `--reuse-from <PREV_RUN_ID>` to spawn cliArgs guarded by
// a truthy check (empty string / undefined ⇒ no flag, per Stage 2
// contract: invalid CLI args must never reach argparse).
// • places the forward block AFTER the `--override-section-assignment`
// loop so the spawn argv preserves backend argparse's no-positional-
// before-flag expectation and so `--override-frame` (still allowed
// by the u1 guard) is positioned ahead of `--reuse-from`.
//
// runPipeline is exercised with a duck-typed `File` plus a `vi.stubGlobal`
// fetch mock — mirrors the user_overrides_service.test.ts pattern. The
// vite handler is source-sliced (mirrors handle_generate_diag.test.ts)
// because the handler spawns python and a real /api/run round-trip is
// out of unit-test scope.
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { runPipeline } from "../src/services/designAgentApi";
// ---------------------------------------------------------------------------
// vite.config.ts source — read once for the handler source-slice assertions.
// Path: Front/client/tests/ → Front/vite.config.ts (two levels up).
// ---------------------------------------------------------------------------
const VITE_CONFIG_PATH = resolve(__dirname, "..", "..", "vite.config.ts");
const VITE_CONFIG_SOURCE = readFileSync(VITE_CONFIG_PATH, "utf-8");
// ---------------------------------------------------------------------------
// fetch mock — minimal Response stub mirroring runPipeline's `.ok` + `.json()`
// + `.status` surface. Same shape as the user_overrides_service.test.ts
// helper so the two test files stay drift-free.
// ---------------------------------------------------------------------------
type MockResponse = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
};
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
return { ok, status, json: async () => body };
}
const SUCCESS_BODY = {
success: true,
run_id: "test_run_id_20260524",
exit_code: 0,
final_html_exists: true,
preview_exists: true,
stdout: "",
stderr: "",
};
// Duck-typed File — runPipeline reads only `.name` and `.text()`. Avoids a
// hard dependency on the global File constructor (varies across node /
// jsdom / happy-dom test environments).
function makeFakeFile(name: string, content: string): File {
return {
name,
text: async () => content,
} as unknown as File;
}
let fetchMock: Mock;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function lastPostBody(): Record<string, 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));
}
// ============================================================================
// runPipeline (designAgentApi.ts) — forwarding/omission coverage
// ============================================================================
describe("runPipeline reuseFromRunId forwarding (IMP-43 #72 u6)", () => {
it("posts to /api/run via POST with JSON content-type", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
await runPipeline(makeFakeFile("03.mdx", "# title"));
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/run");
expect((init as RequestInit).method).toBe("POST");
expect((init as RequestInit).headers).toMatchObject({
"Content-Type": "application/json",
});
});
it("includes reuseFromRunId in the POST body when provided", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
await runPipeline(
makeFakeFile("03.mdx", "# title"),
undefined,
"mdx03_20260524080000",
);
const body = lastPostBody();
expect(body.reuseFromRunId).toBe("mdx03_20260524080000");
expect(body.filename).toBe("03.mdx");
expect(body.content).toBe("# title");
});
it("omits reuseFromRunId when 3rd arg is undefined (pre-u6 byte-identical)", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
await runPipeline(makeFakeFile("03.mdx", "# title"));
const body = lastPostBody();
expect("reuseFromRunId" in body).toBe(false);
// Pre-u6 contract: filename/content are the only keys when overrides
// is undefined (JSON.stringify drops undefined values; pre-u6 emitted
// `JSON.stringify({filename, content, overrides})` with the same
// drop-undefined behaviour, so the wire body is byte-identical).
expect(Object.keys(body).sort()).toEqual(["content", "filename"]);
});
it("omits reuseFromRunId but keeps overrides when only overrides provided", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
await runPipeline(makeFakeFile("03.mdx", "# title"), {
frames: { "03-1": "frame_07" },
});
const body = lastPostBody();
expect("reuseFromRunId" in body).toBe(false);
expect(Object.keys(body).sort()).toEqual([
"content",
"filename",
"overrides",
]);
expect(body.overrides).toEqual({ frames: { "03-1": "frame_07" } });
});
it("omits reuseFromRunId when passed an empty string (truthy guard)", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
await runPipeline(makeFakeFile("03.mdx", "# title"), undefined, "");
const body = lastPostBody();
expect("reuseFromRunId" in body).toBe(false);
});
it("forwards reuseFromRunId alongside frame overrides (the only u1-permitted combo)", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
await runPipeline(
makeFakeFile("03.mdx", "# title"),
{ frames: { "03-1+03-2": "frame_07" } },
"mdx03_20260524080000",
);
const body = lastPostBody();
expect(body.overrides).toEqual({ frames: { "03-1+03-2": "frame_07" } });
expect(body.reuseFromRunId).toBe("mdx03_20260524080000");
});
it("returns the parsed RunPipelineResult on success", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
const res = await runPipeline(
makeFakeFile("03.mdx", "# title"),
undefined,
"mdx03_20260524080000",
);
expect(res.success).toBe(true);
expect(res.run_id).toBe("test_run_id_20260524");
});
});
// ============================================================================
// /api/run handler (vite.config.ts) — source-slice forwarding contract
// ============================================================================
describe("/api/run handler reuseFromRunId source-slice (IMP-43 #72 u6)", () => {
it("declares reuseFromRunId?: string on the /api/run payload type", () => {
// Payload type at the top of the /api/run handler body. The
// optional-string declaration is the single source-of-truth for what
// shape the handler accepts; a typed frontend client (u5 saveUserOverrides
// sibling pattern) cannot silently send a payload the server drops.
expect(VITE_CONFIG_SOURCE).toMatch(/reuseFromRunId\?:\s*string\s*;/);
});
it("destructures reuseFromRunId from payload alongside filename/content/overrides", () => {
expect(VITE_CONFIG_SOURCE).toMatch(
/const\s*\{\s*filename\s*,\s*content\s*,\s*overrides\s*,\s*reuseFromRunId\s*\}\s*=\s*payload\s*;/,
);
});
it("forwards --reuse-from <PREV_RUN_ID> after the override-section-assignment loop", () => {
// Stage 2 contract: reuse_from is a pipeline mode, not an override.
// The forward block must sit AFTER the last override loop so the spawn
// argv preserves the order documented in the u1 backend post-merge
// guard (overrides parsed first; reuse_from precondition runs against
// the merged overrides view).
const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"');
const zoneSectionsIdx = VITE_CONFIG_SOURCE.indexOf(
'"--override-section-assignment"',
);
expect(reuseFromIdx).toBeGreaterThan(-1);
expect(zoneSectionsIdx).toBeGreaterThan(-1);
expect(reuseFromIdx).toBeGreaterThan(zoneSectionsIdx);
});
it("guards the forward with a truthy check on reuseFromRunId", () => {
// Empty string / undefined ⇒ no flag pushed (Stage 2 contract: invalid
// CLI args must never reach argparse — the backend u1 guard would
// fail-closed with `reuse_artifact_missing` on the empty PREV_RUN_ID).
const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"');
expect(reuseFromIdx).toBeGreaterThan(-1);
const preface = VITE_CONFIG_SOURCE.slice(
Math.max(0, reuseFromIdx - 200),
reuseFromIdx,
);
expect(preface).toMatch(/if\s*\(\s*reuseFromRunId/);
expect(preface).toMatch(/typeof\s+reuseFromRunId\s*===\s*"string"/);
});
it("pushes reuseFromRunId as the --reuse-from argument value (no string interpolation)", () => {
// The CLI value must be the raw PREV_RUN_ID — no `=` join, no quoting
// (spawn is shell:false). Mirrors the `--override-layout` shape.
const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"');
expect(reuseFromIdx).toBeGreaterThan(-1);
// Window spans both before (`cliArgs.push(`) and after
// (`reuseFromRunId)`) the literal so the full push expression is
// captured.
const window = VITE_CONFIG_SOURCE.slice(
Math.max(0, reuseFromIdx - 100),
reuseFromIdx + 200,
);
expect(window).toMatch(
/cliArgs\.push\(\s*"--reuse-from"\s*,\s*reuseFromRunId\s*\)/,
);
});
});
@@ -305,19 +305,61 @@ describe("handleGetUserOverrides (IMP-52 u3)", () => {
// IMP-52 u4 — PUT endpoint coverage // IMP-52 u4 — PUT endpoint coverage
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => { describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4 + IMP-56 #90 u3 allowlist sync)", () => {
it("matches the Python KNOWN_AXES tuple in src/user_overrides_io.py", () => { it("matches the Python KNOWN_AXES tuple in src/user_overrides_io.py", () => {
// The on-disk schema is shared with backend pipeline fallback (u2). // The on-disk schema is shared with backend pipeline fallback (u2).
// Any drift here means a PUT could write an axis that the Python // Any drift here means a PUT could write an axis that the Python
// load() ignores, or vice-versa, silently losing user overrides. // load() ignores, or vice-versa, silently losing user overrides.
// IMP-56 #90 u3 closes the prior `slide_css` gap (IMP-45 #74) and
// pre-wires `text_overrides` (IMP-56 #90 u1) +
// `structure_overrides` (IMP-56 #90 u2) — full 9-axis mirror of the
// Python tuple, same order.
expect(KNOWN_USER_OVERRIDES_AXES).toEqual([ expect(KNOWN_USER_OVERRIDES_AXES).toEqual([
"layout", "layout",
"zone_geometries", "zone_geometries",
"zone_sections", "zone_sections",
"frames", "frames",
"image_overrides", "image_overrides",
"slide_css",
"manual_section_assignment",
"text_overrides",
"structure_overrides",
]); ]);
}); });
it("includes the 3 axes added by IMP-56 #90 u3 (allowlist sync)", () => {
// Spot-check the diff in addition to the full-equality assertion so a
// future edit that drops one of the new axes fails with a localized
// error rather than a 9-vs-N tuple-diff that obscures intent.
expect(KNOWN_USER_OVERRIDES_AXES).toContain("slide_css");
expect(KNOWN_USER_OVERRIDES_AXES).toContain("text_overrides");
expect(KNOWN_USER_OVERRIDES_AXES).toContain("structure_overrides");
expect(KNOWN_USER_OVERRIDES_AXES.length).toBe(9);
});
});
describe("mergeUserOverrides (IMP-55 #93 u1) — manual_section_assignment bool axis", () => {
it("merges bool true / false literally and clears on null", () => {
// The PUT handler must treat the bool axis like any other allowlisted
// axis: replace on write, preserve when absent, delete on null. Tests
// both true→false flip and explicit null-clear so the backend (u9)
// sees the exact frontend intent.
let merged = mergeUserOverrides({}, { manual_section_assignment: true });
expect(merged.manual_section_assignment).toBe(true);
merged = mergeUserOverrides(merged, { manual_section_assignment: false });
expect(merged.manual_section_assignment).toBe(false);
merged = mergeUserOverrides(merged, { manual_section_assignment: null });
expect("manual_section_assignment" in merged).toBe(false);
});
it("preserves bool axis when partial touches only a sibling axis", () => {
const existing = { manual_section_assignment: true, layout: "old" };
const merged = mergeUserOverrides(existing, { layout: "new" });
expect(merged.manual_section_assignment).toBe(true);
expect(merged.layout).toBe("new");
});
}); });
describe("mergeUserOverrides (IMP-52 u4)", () => { describe("mergeUserOverrides (IMP-52 u4)", () => {
@@ -35,6 +35,8 @@ import {
deriveUserOverridesKey, deriveUserOverridesKey,
remapPersistedFramesToZoneFrames, remapPersistedFramesToZoneFrames,
saveImageOverride, saveImageOverride,
saveTextOverride,
saveStructureOverride,
} from "../src/utils/slidePlanUtils"; } from "../src/utils/slidePlanUtils";
// ─── Fixtures ─────────────────────────────────────────────────────────────── // ─── Fixtures ───────────────────────────────────────────────────────────────
@@ -54,6 +56,16 @@ function makeSelection(overrides?: Partial<UserSelection["overrides"]>): UserSel
// axis declared on `UserSelection.overrides`. Empty by default so the // axis declared on `UserSelection.overrides`. Empty by default so the
// existing IMP-52 cases remain unchanged in shape. // existing IMP-52 cases remain unchanged in shape.
image_overrides: {}, image_overrides: {},
// IMP-55 (#93) u3 — bool intent marker is REQUIRED on
// `UserSelection.overrides` (not optional). Default to `false` so every
// pre-existing fixture matches the `createInitialUserSelection` seed
// and stays compile-clean after u3 widened the type.
manual_section_assignment: false,
// IMP-56 (#90) u15 — keep the fixture in sync with the two Step-22
// persist axes declared on `UserSelection.overrides`. Empty by
// default so pre-existing cases retain their shape.
text_overrides: {},
structure_overrides: {},
...overrides, ...overrides,
}, },
}; };
@@ -460,3 +472,235 @@ describe("image_overrides axis — saveImageOverride (IMP-51 u11)", () => {
expect(sel.overrides.image_overrides).toEqual(before); expect(sel.overrides.image_overrides).toEqual(before);
}); });
}); });
// ─── IMP-55 (#93) u3 — manual_section_assignment bool axis ──────────────────
// Restore-on-reopen / seed coverage for the bool intent marker. Production
// branch lives at `slidePlanUtils.ts` — `applyPersistedNonFrameOverrides`
// guards with `typeof persisted.manual_section_assignment === "boolean"`,
// and `createInitialUserSelection` seeds the axis to `false`. The marker
// gates whether `handleGenerate` (u7) forwards `overrides.zoneSections`
// to the backend; the pipeline (u9) consumes persisted `zone_sections`
// only when the marker is exactly `true`, so any non-boolean payload MUST
// end up `false` in memory (fail-closed).
describe("manual_section_assignment axis — applyPersistedNonFrameOverrides (IMP-55 #93 u3)", () => {
it("restores literal true verbatim", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
manual_section_assignment: true,
});
expect(next.overrides.manual_section_assignment).toBe(true);
});
it("restores literal false verbatim (u12 apply/cancel write must survive reopen)", () => {
// Seed `true` so the assertion proves `false` overwrites; a truthiness
// check instead of `typeof === \"boolean\"` would silently keep `true`
// and resurrect stale auto-carry assignments as user intent.
const sel = makeSelection({ manual_section_assignment: true });
const next = applyPersistedNonFrameOverrides(sel, {
manual_section_assignment: false,
});
expect(next.overrides.manual_section_assignment).toBe(false);
});
it("leaves the in-memory marker unchanged when the persisted axis is absent", () => {
const sel = makeSelection({ manual_section_assignment: true });
const next = applyPersistedNonFrameOverrides(sel, { layout: "horizontal-2" });
expect(next.overrides.manual_section_assignment).toBe(true);
expect(next.overrides.layout_preset).toBe("horizontal-2");
});
it.each([
["null clear sentinel", null],
['string "true"', "true"],
['string "false"', "false"],
["number 1", 1],
["number 0", 0],
["object {}", {}],
["array []", []],
])("ignores non-boolean payload (%s) — keeps prior in-memory value", (_label, payload) => {
const sel = makeSelection({ manual_section_assignment: true });
const next = applyPersistedNonFrameOverrides(sel, {
manual_section_assignment: payload as unknown as boolean,
});
expect(next.overrides.manual_section_assignment).toBe(true);
});
it("seeds an empty selection with manual_section_assignment=false (createInitialUserSelection)", () => {
const sel = createInitialUserSelection();
expect(sel.overrides.manual_section_assignment).toBe(false);
});
it("returns a NEW selection object (no input mutation) when restoring the marker", () => {
const sel = makeSelection({ manual_section_assignment: false });
const next = applyPersistedNonFrameOverrides(sel, {
manual_section_assignment: true,
});
expect(next).not.toBe(sel);
expect(next.overrides).not.toBe(sel.overrides);
// Input still pristine — proves the helper does not flip the fixture.
expect(sel.overrides.manual_section_assignment).toBe(false);
});
it("layers the bool axis alongside other persisted axes in a single call", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
layout: "vertical-2",
zone_sections: { top: ["03-1"], bottom: ["03-2"] },
manual_section_assignment: true,
});
expect(next.overrides.layout_preset).toBe("vertical-2");
expect(next.overrides.zone_sections).toEqual({
top: ["03-1"],
bottom: ["03-2"],
});
expect(next.overrides.manual_section_assignment).toBe(true);
});
});
// ─── IMP-56 (#90) u15 — text_overrides + structure_overrides axes ───────────
// Pure helpers wired by Home.tsx into the SlideCanvas u13 focusout capture
// (text) and u14 structure overlay emit (structure). Tests cover:
// • saveTextOverride / saveStructureOverride immutability + merge semantics
// • createInitialUserSelection seeding the two new axes empty
// • applyPersistedNonFrameOverrides layering via the u10 extract helpers
describe("text_overrides axis — saveTextOverride (IMP-56 u15)", () => {
it("records a fresh (zoneId, textPath, value) tuple", () => {
const sel = makeSelection();
const next = saveTextOverride(sel, "top", "row_1_left_body.0", "분석 결과");
expect(next.overrides.text_overrides).toEqual({
top: { "row_1_left_body.0": "분석 결과" },
});
});
it("merges within the same zone without erasing prior text_paths", () => {
const sel = makeSelection({
text_overrides: { top: { "row_1_left_body.0": "기존" } },
});
const next = saveTextOverride(sel, "top", "row_1_left_body.1", "신규");
expect(next.overrides.text_overrides.top).toEqual({
"row_1_left_body.0": "기존",
"row_1_left_body.1": "신규",
});
});
it("overwrites the same textPath value within a zone", () => {
const sel = makeSelection({
text_overrides: { top: { "headline.0": "v1" } },
});
const next = saveTextOverride(sel, "top", "headline.0", "v2");
expect(next.overrides.text_overrides.top).toEqual({ "headline.0": "v2" });
});
it("does not mutate the input selection (immutable contract)", () => {
const sel = makeSelection({
text_overrides: { top: { "headline.0": "before" } },
});
saveTextOverride(sel, "top", "headline.0", "after");
expect(sel.overrides.text_overrides).toEqual({
top: { "headline.0": "before" },
});
});
it("seeds an empty text_overrides on a fresh selection", () => {
const sel = createInitialUserSelection();
expect(sel.overrides.text_overrides).toEqual({});
});
});
describe("structure_overrides axis — saveStructureOverride (IMP-56 u15)", () => {
it("records a fresh (zoneId → {slot_order, hidden_slots}) tuple", () => {
const sel = makeSelection();
const next = saveStructureOverride(sel, "top", {
slot_order: ["b", "a"],
hidden_slots: ["c"],
});
expect(next.overrides.structure_overrides).toEqual({
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
});
});
it("replaces an existing zone entry verbatim (no merge within zone)", () => {
const sel = makeSelection({
structure_overrides: { top: { slot_order: ["a", "b"], hidden_slots: [] } },
});
const next = saveStructureOverride(sel, "top", {
slot_order: ["b", "a"],
hidden_slots: ["a"],
});
expect(next.overrides.structure_overrides.top).toEqual({
slot_order: ["b", "a"],
hidden_slots: ["a"],
});
});
it("keeps unrelated zones intact when updating one zone", () => {
const sel = makeSelection({
structure_overrides: {
top: { slot_order: ["x"], hidden_slots: [] },
bottom_l: { slot_order: ["y"], hidden_slots: ["z"] },
},
});
const next = saveStructureOverride(sel, "top", {
slot_order: ["x", "x2"],
hidden_slots: [],
});
expect(next.overrides.structure_overrides.bottom_l).toEqual({
slot_order: ["y"],
hidden_slots: ["z"],
});
});
it("does not mutate the input perZone object after save", () => {
const sel = makeSelection();
const perZone = { slot_order: ["a"], hidden_slots: ["b"] };
const next = saveStructureOverride(sel, "top", perZone);
perZone.slot_order.push("MUTATED");
expect(next.overrides.structure_overrides.top.slot_order).toEqual(["a"]);
});
it("seeds an empty structure_overrides on a fresh selection", () => {
const sel = createInitialUserSelection();
expect(sel.overrides.structure_overrides).toEqual({});
});
});
describe("Step-22 axes — applyPersistedNonFrameOverrides restore (IMP-56 u15)", () => {
it("layers persisted text_overrides through the u10 extract helper", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
text_overrides: {
top: { "row_1_left_body.0": "복원" },
},
});
expect(next.overrides.text_overrides).toEqual({
top: { "row_1_left_body.0": "복원" },
});
});
it("layers persisted structure_overrides through the u10 extract helper", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
structure_overrides: {
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
},
});
expect(next.overrides.structure_overrides).toEqual({
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
});
});
it("drops non-object payloads silently (no throw, axis stays empty)", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
text_overrides: "garbage" as unknown as Record<string, Record<string, string>>,
structure_overrides: ["bad"] as unknown as Record<
string,
{ slot_order?: string[]; hidden_slots?: string[] }
>,
});
expect(next.overrides.text_overrides).toEqual({});
expect(next.overrides.structure_overrides).toEqual({});
});
});
@@ -559,3 +559,67 @@ describe("saveUserOverrides (IMP-51 #79 u3) — image_overrides axis", () => {
}); });
}); });
}); });
// ============================================================================
// IMP-55 #93 u1 — manual_section_assignment axis (7th axis) parity coverage
//
// The bool intent marker rides on the same per-axis coalescing rails as the
// 6 sibling axes. These tests lock the typed client behavior so a regression
// in the boolean serialization (e.g., coercion to "true" string, dropped
// `false` due to truthy filtering) fails here instead of in Home.tsx (u6/u7)
// or the backend gate (u9~u11).
// ============================================================================
describe("saveUserOverrides (IMP-55 #93 u1) — manual_section_assignment axis", () => {
it("PUT body carries only manual_section_assignment when it is the sole mutated axis", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { manual_section_assignment: true });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
expect(body.manual_section_assignment).toBe(true);
});
it("later-wins coalesces true → false within a single debounce window", async () => {
// Drag-then-cancel inside 300 ms — server must see only the final
// `false`, not a transient `true` that would re-enable backend
// consumption of stale zone_sections.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { manual_section_assignment: true });
void saveUserOverrides("03", { manual_section_assignment: false });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({ manual_section_assignment: false });
});
it("forwards null sentinel verbatim (explicit clear)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { manual_section_assignment: null });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(lastPutBody()).toEqual({ manual_section_assignment: null });
});
it("coalesces with zone_sections sibling into a single PUT (drag-drop pair)", async () => {
// Real-world drag flow (u6): one save() sets the bool + zone_sections
// together. Asserts both axes survive coalescing as a single PUT body.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", {
zone_sections: { left: ["03-2"], right: ["03-1"] },
manual_section_assignment: true,
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({
zone_sections: { left: ["03-2"], right: ["03-1"] },
manual_section_assignment: true,
});
});
});
@@ -85,6 +85,22 @@ function sliceHandler(source: string, name: string): string {
return source.slice(start, end); return source.slice(start, end);
} }
/**
* IMP-55 #93 u8 — strip JS/TS line + block comments so source-pattern
* regex checks assert against LIVE code only. The u5 / u7 docblocks in
* Home.tsx intentionally reference removed identifiers (e.g. `defaultByZone`,
* `sameAsDefault`, `zoneSectionsDiff`) and the marker axis name in prose to
* document the Stage 1 root cause for future readers — those references are
* documentation, not behavior, and must not trigger negative-match guards.
* Strips `// ...` to EOL and `/* ... *​/` (incl. multi-line) — keeps string
* literals intact because we only consume the result for regex-match tests.
*/
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/.*$/gm, "");
}
describe("Home.tsx write-side wiring (IMP-52 u10) — source pattern", () => { describe("Home.tsx write-side wiring (IMP-52 u10) — source pattern", () => {
it("handleSectionDrop persists zone_sections behind uploadedFile gate", () => { it("handleSectionDrop persists zone_sections behind uploadedFile gate", () => {
const block = sliceHandler(HOME_TSX, "handleSectionDrop"); const block = sliceHandler(HOME_TSX, "handleSectionDrop");
@@ -567,3 +583,220 @@ describe("restore-on-reopen end-to-end (IMP-52 u10)", () => {
expect(remapPersistedFramesToZoneFrames(plan, persisted.frames)).toEqual({}); expect(remapPersistedFramesToZoneFrames(plan, persisted.frames)).toEqual({});
}); });
}); });
// ─── IMP-55 #93 u8 — manual_section_assignment intent marker contract ─────
// Verifies four axes of the marker contract introduced in u3 (type) / u5
// (apply reset) / u6 (drag flip + co-PUT) / u7 (generate gate):
// 1) Drag dual-axis persistence — handleSectionDrop persists BOTH
// `zone_sections` AND `manual_section_assignment: true` in the SAME
// PUT body (co-PUT atomicity — disk never sees post-drop zone_sections
// without the marker).
// 2) Apply / cancel reset — handleApplyPendingLayout writes explicit
// `manual_section_assignment: false` after the `...overrides` spread,
// and handleCancelPendingLayout relies on createInitialUserSelection
// (which u3 seeds to `false`) to drop a prior `true`.
// 3) Marker-gated forwarding — handleGenerate gates `overrides.zoneSections`
// forwarding strictly on `manualMarker === true` (NOT truthiness, NOT
// `!= null`, NOT presence). u3-seeded `false` and absent values both
// skip forwarding.
// 4) sameAsDefault NOT required — the Stage 1 anti-pattern (defaultByZone
// / sameAsDefault / zoneSectionsDiff self-compare loop) is gone from
// `handleGenerate` entirely; the marker is the source of intent.
describe("IMP-55 #93 u8 — manual_section_assignment marker contract", () => {
it("handleSectionDrop sets marker true in-memory before persistence", () => {
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
// finalSelection literal (built from zoneSelected, then marker = true)
// must occur BEFORE the saveUserOverrides call so the in-memory state
// and the PUT body source from the same overrides shape.
const markerIdx = block.search(/manual_section_assignment:\s*true/);
const saveIdx = block.search(/saveUserOverrides\(/);
expect(markerIdx).toBeGreaterThan(-1);
expect(saveIdx).toBeGreaterThan(-1);
expect(markerIdx).toBeLessThan(saveIdx);
});
it("handleSectionDrop co-PUTs zone_sections + manual_section_assignment:true (single body)", () => {
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
// Single saveUserOverrides call carrying BOTH axes. The regex spans the
// call body to prove the two keys live in the same object literal — a
// future split into two PUTs would race the 300ms debounce and re-open
// the IMP-55 stale-disk window.
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?zone_sections:[\s\S]*?manual_section_assignment:\s*true[\s\S]*?\)/,
);
// Exactly ONE saveUserOverrides call in the handler.
const calls = block.match(/saveUserOverrides\(/g) ?? [];
expect(calls.length).toBe(1);
});
it("handleApplyPendingLayout resets the marker to false in overrides literal", () => {
const block = sliceHandler(HOME_TSX, "handleApplyPendingLayout");
// After spreading `...p.userSelection.overrides`, the explicit
// `manual_section_assignment: false` overrides any prior-drag `true`.
// Without this the layout flip would carry the marker through, and u7
// would forward auto-carried assignments as user overrides → the
// PARTIAL_COVERAGE regression that motivated IMP-55.
expect(block).toMatch(/\.\.\.p\.userSelection\.overrides[\s\S]*?manual_section_assignment:\s*false/);
});
it("handleCancelPendingLayout uses createInitialUserSelection (u3 seeds false)", () => {
const block = sliceHandler(HOME_TSX, "handleCancelPendingLayout");
// Cancel discards all pending in-memory edits via the fresh-selection
// helper — the seed (u3) is the single source of truth for the
// in-memory marker on this path. u12 adds a separate disk-side
// saveUserOverrides PUT (covered by the u12 describe block below);
// the in-memory userSelection literal still has no explicit marker
// field — the seed handles it.
expect(block).toMatch(/createInitialUserSelection\(p\.slidePlan\)/);
// In-memory contract: no `manual_section_assignment` property appears
// inside the userSelection assignment. The only marker reference in
// live code lives inside the u12 saveUserOverrides(...) call body.
const codeOnly = stripComments(block);
expect(codeOnly).not.toMatch(
/userSelection:[\s\S]*?manual_section_assignment/,
);
});
it("handleGenerate gates overrides.zoneSections on manualMarker === true (strict bool)", () => {
const block = sliceHandler(HOME_TSX, "handleGenerate");
// Marker read AND strict-equality gate. `===` not `==`, not truthiness,
// not presence — so `false` / absent both skip forwarding (fail-closed).
expect(block).toMatch(/state\.userSelection\.overrides\.manual_section_assignment/);
expect(block).toMatch(/manualMarker\s*===\s*true/);
// The assignment to `overrides.zoneSections` must live INSIDE the
// marker-true branch.
const gateIdx = block.search(/if\s*\(\s*manualMarker\s*===\s*true\s*\)/);
const assignIdx = block.search(/overrides\.zoneSections\s*=/);
expect(gateIdx).toBeGreaterThan(-1);
expect(assignIdx).toBeGreaterThan(gateIdx);
});
it("handleGenerate filters forwarded zone_sections to valid zone_ids only (cross-layout safety)", () => {
const block = sliceHandler(HOME_TSX, "handleGenerate");
// A stale persisted layout could carry zone_ids that do not exist in
// the current sourcePlan (e.g. horizontal-2 `top`/`bottom` while the
// current layout is vertical-2 `left`/`right`). Those foreign keys
// must be dropped before reaching the backend `--override-section-
// assignment` so they cannot trigger PARTIAL_COVERAGE.
expect(block).toMatch(/validZoneIds\s*=\s*new Set\(\s*sourcePlan\.zones\.map\(\(z\)\s*=>\s*z\.zone_id\)/);
expect(block).toMatch(/if\s*\(!validZoneIds\.has\(zoneId\)\)\s*continue/);
});
it("handleGenerate no longer contains the IMP-08 B-3 self-compare anti-pattern", () => {
// Strip comments — the u7 docblock intentionally references the removed
// identifiers (`defaultByZone` / `sameAsDefault` / `zoneSectionsDiff`)
// in prose to explain the Stage 1 root cause for future readers; the
// regression we guard against is the LIVE code re-emerging.
const block = stripComments(sliceHandler(HOME_TSX, "handleGenerate"));
// The Stage 1 root cause: these identifiers compared user input against
// itself (sourcePlan === effectiveSlidePlan → zones === pendingZones,
// both derived from the same overrides.zone_sections). u7 deleted the
// entire block.
expect(block).not.toMatch(/\bdefaultByZone\b/);
expect(block).not.toMatch(/\bsameAsDefault\b/);
expect(block).not.toMatch(/\bzoneSectionsDiff\b/);
});
it("co-PUT payload contract: marker=true + zone_sections land in a single PUT body", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
// Shape produced by handleSectionDrop after the u6 marker flip.
void saveUserOverrides("03_demo", {
zone_sections: { left: ["03-2"], right: ["03-1"] },
manual_section_assignment: true,
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = lastPutBody() as Record<string, unknown>;
// Both axes in the same PUT body — exact equality, not arrayContaining,
// because any extra axis would mean a foreign mutation leaked through.
expect(Object.keys(body).sort()).toEqual(
["manual_section_assignment", "zone_sections"].sort(),
);
expect(body.manual_section_assignment).toBe(true);
expect(body.zone_sections).toEqual({ left: ["03-2"], right: ["03-1"] });
});
it("co-PUT payload contract: marker=false carries explicitly through saveUserOverrides", async () => {
// u12 will add the apply/cancel explicit `false` PUT; the typed client
// must already propagate the literal `false` through the debounce
// bucket. A truthiness-based coalesce in the bucket merge would drop
// the value and re-open the stale-disk window. This locks the wire
// contract independently of the u12 caller-site write.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { manual_section_assignment: false });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
expect(body.manual_section_assignment).toBe(false);
});
});
// ─── IMP-55 #93 u12 — stale-disk marker reset on apply / cancel ───────────
// u5 resets the in-memory marker on layout apply, and u3's seed via
// `createInitialUserSelection` resets it on cancel. But the disk persists
// independently — a prior drag wrote `true` via u6's co-PUT, so after a
// page reload the u3 restore branch would re-seed `true` and the u7 gate
// would forward auto-carried section assignments → PARTIAL_COVERAGE
// regression. u12 closes that window by writing `manual_section_assignment:
// false` to disk via saveUserOverrides on both apply and cancel paths.
describe("IMP-55 #93 u12 — stale-disk marker reset on layout apply/cancel", () => {
it("handleApplyPendingLayout source contains a marker=false saveUserOverrides PUT", () => {
const block = sliceHandler(HOME_TSX, "handleApplyPendingLayout");
// Stripped-comment source so the u5 docblock prose doesn't satisfy the
// assertion — must be a real call expression.
const code = stripComments(block);
// Uploaded-file gate (mirrors the u6 / other handler pattern — the
// demo-mode initial render path must not PUT to an empty key).
expect(code).toMatch(
/if\s*\(\s*p\.uploadedFile\s*\)[\s\S]*?saveUserOverrides\([\s\S]*?manual_section_assignment:\s*false[\s\S]*?\)/,
);
expect(code).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
});
it("handleCancelPendingLayout source contains a marker=false saveUserOverrides PUT", () => {
const block = sliceHandler(HOME_TSX, "handleCancelPendingLayout");
const code = stripComments(block);
// Cancel handler converts from arrow-body to function-body for the
// disk PUT; the in-memory reset still comes from createInitialUserSelection.
expect(code).toMatch(
/if\s*\(\s*p\.uploadedFile\s*\)[\s\S]*?saveUserOverrides\([\s\S]*?manual_section_assignment:\s*false[\s\S]*?\)/,
);
expect(code).toMatch(/createInitialUserSelection\(p\.slidePlan\)/);
});
it("apply path PUT payload: marker=false carries alone (no auto-carry leakage)", async () => {
// The apply handler issues a dedicated PUT for the marker reset that is
// independent of the (conditional) zone_geometries PUT and of the
// in-memory zone_sections rewrite. The wire contract for this PUT must
// contain only the marker — if zone_sections leaked into the same body
// it would re-arm the u9 backend fallback gate against u12's intent.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { manual_section_assignment: false });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
expect(body.manual_section_assignment).toBe(false);
});
it("apply path PUT is unconditional (does NOT gate on hadPriorGeoms)", () => {
// The u4 zone_geometries PUT inside handleApplyPendingLayout is
// conditional (`p.uploadedFile && hadPriorGeoms`). The u12 marker PUT
// must NOT inherit that gate — a stale disk `true` can exist without
// any prior zone_geometries, so the reset must always fire.
const code = stripComments(sliceHandler(HOME_TSX, "handleApplyPendingLayout"));
// Locate the marker PUT and verify its enclosing `if` clause is just
// `p.uploadedFile`, not the compound `... && hadPriorGeoms` guard.
const markerCallMatch = code.match(
/if\s*\(([^)]*)\)\s*\{[^}]*saveUserOverrides\([^)]*manual_section_assignment:\s*false[^)]*\)/,
);
expect(markerCallMatch).not.toBeNull();
if (markerCallMatch) {
expect(markerCallMatch[1].trim()).toBe("p.uploadedFile");
}
});
});
@@ -0,0 +1,222 @@
// IMP-44 (#73) u3 — vitest coverage for `validateZoneGeometriesAgainstLayout`.
//
// Pairs with the backend [override-warning] guards added in u1 (1-D
// horizontal-2 / vertical-2 branches of `build_layout_css`) and u2 (2-D
// `_override_to_grid_tracks` call site). Same WARN+DROP unknown / KEEP known
// contract; this helper lets handleGenerate (u4) validate against the active
// layout before forwarding so the user sees a toast on dropped keys rather
// than the backend silently even-splitting non-overridden zones with a false
// `computation=user_override_geometry` signal.
//
// Cases (Stage 2 scope-lock):
// 1) horizontal-2 → vertical-2 mismatch (all keys dropped)
// 2) passthrough (all keys recognized)
// 3) partial mix (some kept, some dropped)
// 4) empty input ({} on a known layout)
// 5) unknown-layout fail-safe (preset null / undefined / unknown string)
import { describe, it, expect } from "vitest";
import { validateZoneGeometriesAgainstLayout } from "../src/utils/slidePlanUtils";
const g = (x: number, y: number, w: number, h: number) => ({ x, y, w, h });
describe("validateZoneGeometriesAgainstLayout (IMP-44 u3)", () => {
// ── 1. mismatch ──────────────────────────────────────────────────────────
it("drops horizontal-2 keys when the active layout is vertical-2", () => {
const result = validateZoneGeometriesAgainstLayout(
{ top: g(0, 0, 1, 0.4), bottom: g(0, 0.4, 1, 0.6) },
"vertical-2",
);
expect(result.kept).toEqual({});
expect(result.dropped).toEqual({
top: g(0, 0, 1, 0.4),
bottom: g(0, 0.4, 1, 0.6),
});
expect(result.expectedPositions).toEqual(["left", "right"]);
expect(result.valid).toBe(false);
});
it("drops vertical-2 keys when the active layout is horizontal-2", () => {
const result = validateZoneGeometriesAgainstLayout(
{ left: g(0, 0, 0.5, 1), right: g(0.5, 0, 0.5, 1) },
"horizontal-2",
);
expect(result.kept).toEqual({});
expect(Object.keys(result.dropped).sort()).toEqual(["left", "right"]);
expect(result.expectedPositions).toEqual(["top", "bottom"]);
expect(result.valid).toBe(false);
});
// ── 2. passthrough ───────────────────────────────────────────────────────
it("keeps all keys when every input key is in the active layout positions", () => {
const input = {
top: g(0, 0, 1, 0.4),
bottom: g(0, 0.4, 1, 0.6),
};
const result = validateZoneGeometriesAgainstLayout(input, "horizontal-2");
expect(result.kept).toEqual(input);
expect(result.dropped).toEqual({});
expect(result.expectedPositions).toEqual(["top", "bottom"]);
expect(result.valid).toBe(true);
});
it("passes a single 'primary' key through on the 'single' preset", () => {
const result = validateZoneGeometriesAgainstLayout(
{ primary: g(0, 0, 1, 1) },
"single",
);
expect(result.kept).toEqual({ primary: g(0, 0, 1, 1) });
expect(result.dropped).toEqual({});
expect(result.expectedPositions).toEqual(["primary"]);
expect(result.valid).toBe(true);
});
it("recognizes the 2-D preset positions reported by computeZonePositions (top-1-bottom-2)", () => {
const input = {
top: g(0, 0, 1, 0.5),
"bottom-left": g(0, 0.5, 0.5, 0.5),
"bottom-right": g(0.5, 0.5, 0.5, 0.5),
};
const result = validateZoneGeometriesAgainstLayout(input, "top-1-bottom-2");
expect(result.kept).toEqual(input);
expect(result.dropped).toEqual({});
expect(result.expectedPositions).toEqual([
"top",
"bottom-left",
"bottom-right",
]);
expect(result.valid).toBe(true);
});
// ── 3. partial mix ───────────────────────────────────────────────────────
it("keeps known keys and drops unknown keys on a partial-mix input", () => {
const result = validateZoneGeometriesAgainstLayout(
{ top: g(0, 0, 1, 0.4), foo: g(0, 0, 1, 1) },
"horizontal-2",
);
expect(result.kept).toEqual({ top: g(0, 0, 1, 0.4) });
expect(result.dropped).toEqual({ foo: g(0, 0, 1, 1) });
expect(result.expectedPositions).toEqual(["top", "bottom"]);
expect(result.valid).toBe(false);
});
it("on a 2-D preset, keeps known 2-D track keys and drops legacy 1-D keys", () => {
// Simulates the user resizing under top-1-bottom-2, then flipping to
// grid-2x2 — legacy `bottom-left` stays valid; `top` (no longer a 2x2
// position) gets dropped.
const result = validateZoneGeometriesAgainstLayout(
{
top: g(0, 0, 1, 0.5),
"bottom-left": g(0, 0.5, 0.5, 0.5),
"top-left": g(0, 0, 0.5, 0.5),
},
"grid-2x2",
);
expect(result.kept).toEqual({
"bottom-left": g(0, 0.5, 0.5, 0.5),
"top-left": g(0, 0, 0.5, 0.5),
});
expect(result.dropped).toEqual({ top: g(0, 0, 1, 0.5) });
expect(result.expectedPositions).toEqual([
"top-left",
"top-right",
"bottom-left",
"bottom-right",
]);
expect(result.valid).toBe(false);
});
// ── 4. empty input ───────────────────────────────────────────────────────
it("returns empty kept/dropped and valid=true on an empty {} input", () => {
const result = validateZoneGeometriesAgainstLayout({}, "horizontal-2");
expect(result.kept).toEqual({});
expect(result.dropped).toEqual({});
expect(result.expectedPositions).toEqual(["top", "bottom"]);
expect(result.valid).toBe(true);
});
it("treats null / undefined geoms as empty input (no throw, valid=true on a known layout)", () => {
const nullResult = validateZoneGeometriesAgainstLayout(null, "vertical-2");
expect(nullResult.kept).toEqual({});
expect(nullResult.dropped).toEqual({});
expect(nullResult.expectedPositions).toEqual(["left", "right"]);
expect(nullResult.valid).toBe(true);
const undefResult = validateZoneGeometriesAgainstLayout(
undefined,
"vertical-2",
);
expect(undefResult.kept).toEqual({});
expect(undefResult.dropped).toEqual({});
expect(undefResult.expectedPositions).toEqual(["left", "right"]);
expect(undefResult.valid).toBe(true);
});
it("ignores array payloads (defensive against hand-edited persisted files)", () => {
const result = validateZoneGeometriesAgainstLayout(
[] as unknown as Record<string, { x: number; y: number; w: number; h: number }>,
"horizontal-2",
);
expect(result.kept).toEqual({});
expect(result.dropped).toEqual({});
expect(result.expectedPositions).toEqual(["top", "bottom"]);
expect(result.valid).toBe(true);
});
// ── 5. unknown-layout fail-safe ──────────────────────────────────────────
it("drops every input key when layout is null (fail-safe)", () => {
const result = validateZoneGeometriesAgainstLayout(
{ top: g(0, 0, 1, 0.4), bottom: g(0, 0.4, 1, 0.6) },
null,
);
expect(result.kept).toEqual({});
expect(result.dropped).toEqual({
top: g(0, 0, 1, 0.4),
bottom: g(0, 0.4, 1, 0.6),
});
expect(result.expectedPositions).toEqual([]);
expect(result.valid).toBe(false);
});
it("drops every input key when layout is undefined (fail-safe)", () => {
const result = validateZoneGeometriesAgainstLayout(
{ primary: g(0, 0, 1, 1) },
undefined,
);
expect(result.kept).toEqual({});
expect(result.dropped).toEqual({ primary: g(0, 0, 1, 1) });
expect(result.expectedPositions).toEqual([]);
expect(result.valid).toBe(false);
});
it("drops every input key when layout is an unknown preset string (fail-safe)", () => {
const result = validateZoneGeometriesAgainstLayout(
{ top: g(0, 0, 1, 0.4) },
"rogue-preset" as unknown as string,
);
expect(result.kept).toEqual({});
expect(result.dropped).toEqual({ top: g(0, 0, 1, 0.4) });
expect(result.expectedPositions).toEqual([]);
expect(result.valid).toBe(false);
});
it("returns empty kept/dropped/expectedPositions when layout is unknown AND geoms is empty", () => {
const result = validateZoneGeometriesAgainstLayout({}, null);
expect(result.kept).toEqual({});
expect(result.dropped).toEqual({});
expect(result.expectedPositions).toEqual([]);
// No keys to drop ⇒ vacuously valid; handleGenerate (u4) gates the toast
// on `Object.keys(dropped).length > 0`, not `valid`, so this is safe.
expect(result.valid).toBe(true);
});
// ── purity / mutation safety ─────────────────────────────────────────────
it("does not mutate the input geometries object", () => {
const input = { top: g(0, 0, 1, 0.4), foo: g(0, 0, 1, 1) };
const inputKeysBefore = Object.keys(input).sort();
validateZoneGeometriesAgainstLayout(input, "horizontal-2");
expect(Object.keys(input).sort()).toEqual(inputKeysBefore);
// Sample value still pristine.
expect(input.top).toEqual(g(0, 0, 1, 0.4));
});
});
+274 -6
View File
@@ -219,19 +219,36 @@ function vitePluginStorageProxy(): Plugin {
export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/; 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 // The nine in-scope axes — full mirror of KNOWN_AXES in
// src/user_overrides_io.py. Any payload key outside this allowlist is // src/user_overrides_io.py. Order matches the Python tuple verbatim so
// silently dropped by the PUT handler (u4) so the on-disk schema cannot // a side-by-side audit reads as a no-op. Any payload key outside this
// drift from the backend pipeline (u2) contract. Foreign top-level keys // allowlist is silently dropped by the PUT handler (u4) so the on-disk
// already on disk are preserved verbatim (see mergeUserOverrides). // 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} // IMP-51 (#79) u2: added `image_overrides` (image_id → {x,y,w,h}
// percent-of-slide coordinates). // percent-of-slide coordinates).
// IMP-55 (#93) u1: added `manual_section_assignment` (bool intent marker
// — drag-drop sets true, layout apply/cancel sets false).
// IMP-56 (#90) u3: allowlist sync — closes the prior `slide_css` gap
// (IMP-45 #74; the Step-22 slide CSS edit path will write it from the
// frontend) and pre-wires `text_overrides` (IMP-56 #90 u1, keyed by
// {zone_id: {text_path: value}}) + `structure_overrides` (IMP-56 #90 u2,
// keyed by {zone_id: {slot_order, hidden_slots}} — scope LOCKED to slot
// reorder + hide; frame swap stays on the existing `frames` axis to
// preserve Phase Z's no-AI-HTML-structure invariant) so the Step-22
// capture path (u10~u17) can PUT either axis without a follow-on
// allowlist edit.
export const KNOWN_USER_OVERRIDES_AXES = [ export const KNOWN_USER_OVERRIDES_AXES = [
"layout", "layout",
"zone_geometries", "zone_geometries",
"zone_sections", "zone_sections",
"frames", "frames",
"image_overrides", "image_overrides",
"slide_css",
"manual_section_assignment",
"text_overrides",
"structure_overrides",
] as const; ] as const;
export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number]; export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number];
@@ -500,6 +517,211 @@ export function handlePutUserOverrides(
return true; return true;
} }
// =============================================================================
// IMP-56 (#90) u18 — POST /api/connect : cel astro dev mirror copy.
//
// Body: {"run_id": "<id>", "slug": "<mdx-stem>"}.
// • Copies <DESIGN_AGENT_ROOT>/data/runs/<run_id>/phase_z2/final.html →
// <CEL_PROJECT_ROOT>/public/slides/<slug>.html (overwrite).
// • If <run_dir>/phase_z2/assets/ exists, mirrors its contents into
// <CEL_PROJECT_ROOT>/public/slides/assets/ (overwrite copy, recursive).
// • run_id and slug are validated through the existing
// isValidUserOverridesKey gate so path-traversal payloads are rejected.
// =============================================================================
export function mirrorDirRecursive(srcDir: string, dstDir: string): number {
if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) return 0;
if (!fs.existsSync(dstDir)) fs.mkdirSync(dstDir, { recursive: true });
let count = 0;
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
const srcPath = path.join(srcDir, entry.name);
const dstPath = path.join(dstDir, entry.name);
if (entry.isDirectory()) {
count += mirrorDirRecursive(srcPath, dstPath);
} else if (entry.isFile()) {
fs.copyFileSync(srcPath, dstPath);
count += 1;
}
}
return count;
}
export function handleConnectMirror(
req: PutReqLike,
res: ResLike,
designAgentRoot: string,
celRoot: string,
): boolean {
if (req.method !== "POST") return false;
let body = "";
req.on("data", (chunk: Buffer | string) => {
body += typeof chunk === "string" ? chunk : chunk.toString();
});
req.on("end", () => {
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 { run_id, slug } = parsed as { run_id?: unknown; slug?: unknown };
if (typeof run_id !== "string" || typeof slug !== "string") {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "missing run_id or slug" }));
return;
}
if (!isValidUserOverridesKey(run_id) || !isValidUserOverridesKey(slug)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid run_id or slug" }));
return;
}
const runDir = path.join(designAgentRoot, "data", "runs", run_id, "phase_z2");
const srcHtml = path.join(runDir, "final.html");
if (!fs.existsSync(srcHtml)) {
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "final.html not found" }));
return;
}
const dstSlidesDir = path.join(celRoot, "public", "slides");
if (!fs.existsSync(dstSlidesDir)) fs.mkdirSync(dstSlidesDir, { recursive: true });
const dstHtml = path.join(dstSlidesDir, `${slug}.html`);
try {
fs.copyFileSync(srcHtml, dstHtml);
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: `copy failed: ${String(err)}` }));
return;
}
const assetsCopied = mirrorDirRecursive(
path.join(runDir, "assets"),
path.join(dstSlidesDir, "assets"),
);
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ success: true, run_id, slug, html_target: dstHtml, assets_copied: assetsCopied }));
});
req.on("error", () => {
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "request error" }));
});
return true;
}
// =============================================================================
// IMP-56 (#90) u19 — POST /api/export : standalone HTML download.
//
// Body: {"run_id": "<id>"}.
// • Reads <DESIGN_AGENT_ROOT>/data/runs/<run_id>/phase_z2/final.html.
// • Inlines every `url(assets/<frame>/<file>)` reference (the only
// external dep emitted by the Phase Z2 render path — verified by grep
// against templates/phase_z2/slide_base.html and a representative run)
// as a base64 data URL so the emitted HTML is portable (file:// open
// or any external host, no co-located assets/ dir required). Mirrors
// u18 validation: isValidUserOverridesKey gate for path-traversal
// rejection; final.html missing → 404.
// • Response: 200 text/html with Content-Disposition: attachment so the
// browser triggers a download with `<run_id>.html` filename. Raw HTML
// body (NOT JSON-wrapped) — the BottomActions wiring (u20) will pipe
// the response body straight into a Blob → a[download] click chain
// mirroring the existing serializeSlidePlan JSON download flow.
// =============================================================================
export function inlineAssetsAsDataUrls(html: string, assetsRoot: string): string {
// Match `url(assets/<rel-path>)` (with optional single/double quotes,
// optional surrounding whitespace). The Phase Z2 render path emits
// `url(assets/<frame>/<file>.png)` verbatim into inline `style="..."`
// custom-property declarations (see slide_base.html `--card-frame-bg`
// etc.) — there is no `<link rel="stylesheet">` or `<img src>` external
// ref to handle. Keeping the matcher narrow avoids accidentally
// rewriting `data:` / `http(s):` / sibling-path URLs that the render
// path does not produce.
const URL_RE = /url\(\s*(['"]?)assets\/([^)'"]+)\1\s*\)/g;
return html.replace(URL_RE, (match, _quote: string, rel: string) => {
const filePath = path.join(assetsRoot, rel);
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return match;
const ext = path.extname(filePath).toLowerCase().slice(1);
const mime =
ext === "png" ? "image/png" :
ext === "jpg" || ext === "jpeg" ? "image/jpeg" :
ext === "svg" ? "image/svg+xml" :
ext === "webp" ? "image/webp" :
ext === "gif" ? "image/gif" :
"application/octet-stream";
const buf = fs.readFileSync(filePath);
return `url("data:${mime};base64,${buf.toString("base64")}")`;
});
}
export function handleExportStandalone(
req: PutReqLike,
res: ResLike,
designAgentRoot: string,
): boolean {
if (req.method !== "POST") return false;
let body = "";
req.on("data", (chunk: Buffer | string) => {
body += typeof chunk === "string" ? chunk : chunk.toString();
});
req.on("end", () => {
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 { run_id } = parsed as { run_id?: unknown };
if (typeof run_id !== "string") {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "missing run_id" }));
return;
}
if (!isValidUserOverridesKey(run_id)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid run_id" }));
return;
}
const runDir = path.join(designAgentRoot, "data", "runs", run_id, "phase_z2");
const srcHtml = path.join(runDir, "final.html");
if (!fs.existsSync(srcHtml)) {
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "final.html not found" }));
return;
}
let html: string;
try {
html = fs.readFileSync(srcHtml, "utf-8");
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: `read failed: ${String(err)}` }));
return;
}
const inlined = inlineAssetsAsDataUrls(html, path.join(runDir, "assets"));
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Content-Disposition": `attachment; filename="${run_id}.html"`,
});
res.end(inlined);
});
req.on("error", () => {
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "request error" }));
});
return true;
}
// ============================================================================= // =============================================================================
// Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출 // Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출
// //
@@ -508,14 +730,19 @@ export function handlePutUserOverrides(
// GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path} // 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) // GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3)
// PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4) // PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4)
// POST /api/connect → cel mirror (IMP-56 #90 u18)
// POST /api/export → standalone HTML download (IMP-56 #90 u19)
// //
// 환경 변수 (선택) : // 환경 변수 (선택) :
// DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent // DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent
// CEL_PROJECT_ROOT cel astro dev repo root. default = D:/ad-hoc/cel
// ============================================================================= // =============================================================================
function vitePluginPhaseZApi(): Plugin { function vitePluginPhaseZApi(): Plugin {
const DESIGN_AGENT_ROOT = const DESIGN_AGENT_ROOT =
process.env.DESIGN_AGENT_ROOT || "D:\\ad-hoc\\kei\\design_agent"; process.env.DESIGN_AGENT_ROOT || "D:\\ad-hoc\\kei\\design_agent";
const CEL_PROJECT_ROOT =
process.env.CEL_PROJECT_ROOT || "D:\\ad-hoc\\cel";
const UPLOADS_DIR = path.join(DESIGN_AGENT_ROOT, "samples", "uploads"); const UPLOADS_DIR = path.join(DESIGN_AGENT_ROOT, "samples", "uploads");
const RUNS_DIR = path.join(DESIGN_AGENT_ROOT, "data", "runs"); const RUNS_DIR = path.join(DESIGN_AGENT_ROOT, "data", "runs");
@@ -543,6 +770,13 @@ function vitePluginPhaseZApi(): Plugin {
// (e.g., "top": ["03-1-sub-1"]). Forwarded as --override-section-assignment. // (e.g., "top": ["03-1-sub-1"]). Forwarded as --override-section-assignment.
zoneSections?: Record<string, string[]>; zoneSections?: Record<string, string[]>;
}; };
// IMP-43 (#72) u6 — optional PREV_RUN_ID to reuse Step 0/1/2/5/6
// artifacts from a prior run and resume execution at Step 7.
// Lives at the payload root (NOT under `overrides`) because the
// backend u1 post-merge guard rejects most override axes when
// --reuse-from is supplied. Absent / empty = full pipeline
// (byte-identical to pre-u6 spawn).
reuseFromRunId?: string;
}; };
try { try {
payload = JSON.parse(body); payload = JSON.parse(body);
@@ -554,7 +788,7 @@ function vitePluginPhaseZApi(): Plugin {
return; return;
} }
const { filename, content, overrides } = payload; const { filename, content, overrides, reuseFromRunId } = payload;
if (!filename || typeof content !== "string") { if (!filename || typeof content !== "string") {
res.writeHead(400, { "Content-Type": "application/json" }); res.writeHead(400, { "Content-Type": "application/json" });
res.end( res.end(
@@ -638,6 +872,19 @@ function vitePluginPhaseZApi(): Plugin {
); );
} }
} }
// IMP-43 (#72) u6 — --reuse-from <PREV_RUN_ID> forward. Backend
// (u1) parses this flag, validates the snapshot, copies Step
// 0/1/2/5/6 artifacts from data/runs/<PREV_RUN_ID>/phase_z2 into
// the new run_dir, and resumes execution at Step 7. The post-merge
// guard at the same site rejects --override-layout /
// --override-zone-geometry / --override-section-assignment /
// --override-image with axis-named fail-closed exit; only
// --override-frame (above) is preserved. Truthy check excludes
// empty string + undefined so an invalid argument never reaches
// argparse.
if (reuseFromRunId && typeof reuseFromRunId === "string") {
cliArgs.push("--reuse-from", reuseFromRunId);
}
console.log( console.log(
`[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}` `[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}`
); );
@@ -775,6 +1022,27 @@ function vitePluginPhaseZApi(): Plugin {
next(); next();
}); });
// ── POST /api/connect → cel astro public/slides mirror ──
// IMP-56 (#90) u18 — see handleConnectMirror docblock for body shape +
// copy semantics. Logic lives in the pure helper so vitest can drive
// it without booting vite.
server.middlewares.use("/api/connect", (req, res, next) => {
if (handleConnectMirror(req, res, DESIGN_AGENT_ROOT, CEL_PROJECT_ROOT)) return;
next();
});
// ── POST /api/export → standalone HTML download ──
// IMP-56 (#90) u19 — see handleExportStandalone docblock for body
// shape + inline-asset semantics. Logic lives in the pure helper
// (handleExportStandalone + inlineAssetsAsDataUrls) so vitest can
// drive it without booting vite. The response is raw text/html
// (Content-Disposition: attachment); the u20 BottomActions wiring
// will turn the response body into a Blob → a[download] click.
server.middlewares.use("/api/export", (req, res, next) => {
if (handleExportStandalone(req, res, DESIGN_AGENT_ROOT)) return;
next();
});
// ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ── // ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ──
server.middlewares.use("/data/runs", (req, res, next) => { server.middlewares.use("/data/runs", (req, res, next) => {
if (req.method !== "GET") return next(); if (req.method !== "GET") return next();
@@ -167,6 +167,42 @@ Step 0 (사전 준비) 의 Figma → HTML 변환은 *precondition phase 의 작
--- ---
## 7. Multi-MDX regression markers (IMP-91)
> CI workflow `.github/workflows/multi-mdx-regression.yml` rewrites these via `scripts/update_status_board.py` after each push / PR. Initial value `?` = not yet observed. `PASS` / `FAIL` / `ERR` / `SKIP` = last CI run outcome per axis × mdx. Untouched markers remain `?` so collection failures are loud, not silent.
| axis | mdx 01 | mdx 02 | mdx 03 | mdx 04 | mdx 05 |
|---|---|---|---|---|---|
| F0 normalize | <!-- IMP-91:F0:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:05 -->?<!-- /IMP-91 --> |
| F1 V4 ranking | <!-- IMP-91:F1:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:05 -->?<!-- /IMP-91 --> |
| F2 slot_payload | <!-- IMP-91:F2:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:05 -->?<!-- /IMP-91 --> |
| F3 classifier-only AI | <!-- IMP-91:F3:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:05 -->?<!-- /IMP-91 --> |
| F4 layout | <!-- IMP-91:F4:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:05 -->?<!-- /IMP-91 --> |
| F5 final.html | <!-- IMP-91:F5:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:05 -->?<!-- /IMP-91 --> |
---
## 8. IMP-43 (#72) `--reuse-from` measured savings
> Stage 2 §u8 binding contract: the issue-body 50–70% / 10–20s → 3–8s claim is **unverified** and is **not** mirrored here. Numbers below come from `scripts/measure_reuse_savings.py` on the project reference host; until that script is run and the values committed, every cell stays `TBD`.
| axis | value |
|---|---|
| measurement script | `scripts/measure_reuse_savings.py` |
| reuse boundary (Stage 1 lock) | Step 0 / 1 / 2 / 5 / 6 only; Step 7+ re-executes |
| full rerun seconds (p50) | TBD |
| full rerun seconds (p95) | TBD |
| reuse seconds (p50) | TBD |
| reuse seconds (p95) | TBD |
| reuse / full ratio (p50) | TBD |
| last measured | TBD (date / host / mdx / iterations) |
Run protocol (per iteration): `(A)` seed → `(B)` full rerun with one self-discovered `--override-frame` pin → `(C)` `--reuse-from <seed>` with the same pin. The `(A)` seed time is reported separately and **not** included in the B-vs-C comparison — the reuse path's whole point is that the seed already exists from a prior interactive run.
Invocation: `python -m scripts.measure_reuse_savings samples/mdx_batch/02.mdx --iterations 5` (mdx is argv-driven; the script does not pin a sample internally).
---
## 사용 방법 ## 사용 방법
- 새 작업 들어오면 → 본 board 의 *어느 step* 의 status 를 바꾸는 작업인지 식별 - 새 작업 들어오면 → 본 board 의 *어느 step* 의 status 를 바꾸는 작업인지 식별
+132 -4
View File
@@ -1009,6 +1009,9 @@ def build_context_pack(n, title, body, sid, agent, rnd, start_cnt, compact=None)
# 검증 실패 보고서 (rewind 시 이전 실패 맥락 전달). # 검증 실패 보고서 (rewind 시 이전 실패 맥락 전달).
# 2026-05-16 — issue state 의 failure_report_path 를 source-of-truth 로. # 2026-05-16 — issue state 의 failure_report_path 를 source-of-truth 로.
# 모든 stage NO (test-verify/final-close 뿐 아니라 code-edit 등) 의 from_stage 캐치. # 모든 stage NO (test-verify/final-close 뿐 아니라 code-edit 등) 의 from_stage 캐치.
# P7 (2026-05-26) — banned approaches injection (Codex CLI helper consensus).
# failure_report 본문에서 known anti-pattern keyword 추출 → BANNED_APPROACHES block 생성
# → 다음 round prompt 에 strong-marker 로 inject. 동일 방식 재제안 방지 (#84 round loop).
failure_ctx = "" failure_ctx = ""
ist_fc = get_issue_state(n) ist_fc = get_issue_state(n)
fr_path_str = ist_fc.get("failure_report_path") fr_path_str = ist_fc.get("failure_report_path")
@@ -1016,9 +1019,42 @@ def build_context_pack(n, title, body, sid, agent, rnd, start_cnt, compact=None)
fail_path = Path(fr_path_str) fail_path = Path(fr_path_str)
if fail_path.exists(): if fail_path.exists():
from_sid = ist_fc.get("failure_from_stage", "?") from_sid = ist_fc.get("failure_from_stage", "?")
fail_body = fail_path.read_text(encoding='utf-8')
# P7 — extract banned approach signals (deterministic keyword scan).
# 각 entry: (regex, label, why). escape_hatch 는 future patch 의 JSON 구조 에서 형식화.
# 현재 단계 = prompt-injection 만 (Codex 단계화 안의 "즉시 patch" layer).
banned_signals = [
(r"tests:\s*\[\s*\]",
"tests: [] empty test list per implementation unit",
"Orchestrator strict rule — 1 unit = impl + test inseparable. NOT allowed to defer tests to later units."),
(r"@testing-library|jsdom|render\s*\(|screen\.",
"DOM mount-based vitest (render() / screen / @testing-library)",
"Front/package.json devDependencies has no jsdom / @testing-library/react. Mount-based tests cannot run."),
(r"toast\.error\s*\(\s*formatAiRepairHumanReviewMessage",
"Home.tsx formatAiRepairHumanReviewMessage toast.error removal",
"Post-#92 commit 896f273 rewrote the formatter to operational-only channel. Removing toast call = operational alert regression."),
(r"git\s+add\s+(-A|--all|\.)\b",
"git add -A / git add . / git add --all",
"Untracked artifact pollution risk. Stage 5 must add only files in unit's declared `files:` list explicitly."),
]
hits = []
for pat, label, why in banned_signals:
if re.search(pat, fail_body, re.IGNORECASE):
hits.append((label, why))
banned_block = ""
if hits:
banned_block = "\n=== BANNED APPROACHES (previously rejected — DO NOT REUSE) ===\n"
for i, (label, why) in enumerate(hits, 1):
banned_block += f"{i}. {label}\n reason: {why}\n"
banned_block += (
"BINDING: re-proposing any banned approach above = automatic FINAL_CONSENSUS: NO. "
"If environment/preconditions changed (e.g., new package install), state the EVIDENCE "
"of the change BEFORE re-proposal.\n"
)
failure_ctx = ( failure_ctx = (
f"\n\n=== REWIND: FAILURE REPORT (from {from_sid}) ===\n" f"\n\n=== REWIND: FAILURE REPORT (from {from_sid}) ===\n"
f"{fail_path.read_text(encoding='utf-8')[:1500]}\n" f"{fail_body[:1500]}\n"
f"{banned_block}"
f"Fix the issues above before re-attempting.\n" f"Fix the issues above before re-attempting.\n"
) )
@@ -1447,10 +1483,22 @@ def run_stage(n, title, body, sid):
return (False, "unit with `tests: []` (forbidden — implementation + tests = same unit)") return (False, "unit with `tests: []` (forbidden — implementation + tests = same unit)")
return (True, "") return (True, "")
ok, reason = _iu_valid(last) ok, reason = _iu_valid(last)
if not ok: # P7 (2026-05-26) — fallback skip when last YES body itself is invalid.
# current stage 의 comments 만 검색 (start_cnt 이후) # 이전: last invalid → comments[start_cnt:] 에서 valid block 찾아 구제 →
# orchestrator 자기 supplement comment 의 Example block 이 valid 로 통과 (#84 round 5 슬립).
# 변경: last 가 진짜 invalid 면 fallback 자체 skip. 단 last 의 _iu_valid 실패가
# "block missing" 인 경우만 (Codex 가 YAML block 을 안 echo 한 경우) 이전 round 의
# Claude plan 으로 fallback — 단 orchestrator-authored supplement 는 제외.
if not ok and reason == "block missing":
for c in comments[start_cnt:]: for c in comments[start_cnt:]:
ok2, _ = _iu_valid(c.get("body", "")) body = c.get("body", "") or ""
# exclude orchestrator-authored supplement comments (own example block trap)
ls = body.lstrip()
if ls.startswith("⚠️ **[Orchestrator]**") or \
ls.startswith("📌 **[오케스트레이터]**") or \
ls.startswith("ℹ️ **[Orchestrator]**"):
continue
ok2, _ = _iu_valid(body)
if ok2: if ok2:
ok = True; break ok = True; break
if not ok: if not ok:
@@ -1548,6 +1596,45 @@ def run_stage(n, title, body, sid):
except: pass except: pass
# Never `continue` — checker is informational only (Stage 1 guardrail). # Never `continue` — checker is informational only (Stage 1 guardrail).
# P7 (2026-05-26) — final-close YES casual self-contradiction inline guard.
# parse_consensus 는 건드리지 않음 (다른 caller 영향 차단). YES 처리 block 안에서
# sid == "final-close" 인 경우만 casual contradiction 검사.
#
# 설계 의도 분기 (Patch B 와 분담) :
# - explicit `disposition: KEEP_OPEN_*` line 이 있으면 = 의도된 keep-open
# → 이 guard 통과 → Patch B (close PATCH skip) 가 처리.
# - explicit disposition line 없이 "NO close signal" 또는 "DO NOT CLOSE"
# casual 표현 만 있으면 = self-contradiction → supplement + continue.
#
# cf. #83 IMP-83 case = YES + explicit `disposition: KEEP_OPEN_AS_UMBRELLA_ANCHOR`
# → 통과 (Patch B 가 close skip).
if sid == "final-close":
has_explicit_disposition = bool(re.search(
r"^\s*disposition\s*:\s*KEEP_OPEN",
last, re.IGNORECASE | re.MULTILINE))
if not has_explicit_disposition:
casual_contradiction_patterns = [
(r"NO\s+close\s+signal", "NO close signal"),
(r"DO\s*NOT\s*CLOSE", "DO NOT CLOSE"),
]
hit = None
for p, label in casual_contradiction_patterns:
if re.search(p, last, re.IGNORECASE):
hit = label; break
if hit:
log(f"⚠️ Stage 6 YES casual self-contradiction ({hit}) — supplement requested")
try: gitea(f"issues/{n}/comments", "POST", {"body":
f"⚠️ **[Orchestrator]** Stage 6 FINAL_CONSENSUS: YES rejected — casual self-contradiction.\n\n"
f"YES marker 와 동시에 본문에 `{hit}` 등장 — 명시적 `disposition:` line 없음.\n\n"
"Resolution:\n"
" (a) If close intended → remove `{hit}` and re-state YES with close evidence.\n"
" (b) If keep-open intended → add explicit line:\n"
" `disposition: KEEP_OPEN_AS_UMBRELLA_ANCHOR` (or similar)\n"
" then orchestrator will honor keep-open at close PATCH (Patch B).\n"
" (c) Or switch to `FINAL_CONSENSUS: NO` with appropriate rewind_target."})
except: pass
continue
log(f"✅ {si['label']} — YES (evidence verified)") log(f"✅ {si['label']} — YES (evidence verified)")
# stage 완료 = unit counter + remaining tracker 모두 reset # stage 완료 = unit counter + remaining tracker 모두 reset
update_issue_state(n, continue_same_count=0, last_remaining_units=None) update_issue_state(n, continue_same_count=0, last_remaining_units=None)
@@ -1765,6 +1852,47 @@ def run_issue(n, until=None):
continue_same_count=0, last_remaining_units=None) continue_same_count=0, last_remaining_units=None)
if s["id"] == "final-close": if s["id"] == "final-close":
# P7 (2026-05-26) — KEEP_OPEN guard. Stage 6 exit body / last YES body 가 명시적
# keep-open / no-close 신호 내면 close PATCH skip. body-level lock 이 있는 umbrella
# anchor (#83 IMP-83 등) 보호 — Stage 6 성공 = "올바른 disposition 확정" 이며,
# 그 disposition 이 KEEP_OPEN 일 수 있음.
keep_open_patterns = [
r"KEEP_OPEN_AS_UMBRELLA_ANCHOR",
r"DO\s*NOT\s*CLOSE",
r"disposition\s*:\s*KEEP_OPEN",
r"^\s*action\s*:\s*NONE",
r"^\s*state_after\s*:\s*open",
r"NO\s+close\s+signal",
]
keep_open = False
# P7a (2026-05-26) — fetch comments fresh; `comments` is loop-local in stage block
# and not in scope at run_issue post-stage update. NameError fix.
try:
_cs = get_comments(n)
last_body = _cs[-1].get("body", "") if _cs else ""
except: last_body = ""
for p in keep_open_patterns:
if re.search(p, last_body, re.IGNORECASE | re.MULTILINE):
keep_open = True; break
if not keep_open:
exit_path = _erp(n, "final-close")
if exit_path.exists():
try:
exit_body = exit_path.read_text(encoding="utf-8", errors="ignore")
for p in keep_open_patterns:
if re.search(p, exit_body, re.IGNORECASE | re.MULTILINE):
keep_open = True; break
except: pass
if keep_open:
log(f"Stage 6 KEEP_OPEN signal — issue #{n} NOT closed (umbrella/governance anchor honored)")
try: gitea(f"issues/{n}/comments", "POST", {"body":
"ℹ️ **[Orchestrator]** Stage 6 KEEP_OPEN signal honored — issue not closed.\n\n"
"Detected one of: `KEEP_OPEN_AS_UMBRELLA_ANCHOR`, `DO NOT CLOSE`, "
"`disposition: KEEP_OPEN`, `action: NONE`, `state_after: open`, `NO close signal`.\n\n"
"Orchestrator abstains from `PATCH state=closed` per user-decision-first lock. "
"Final-close stage marked done; issue state preserved as `open`."})
except: pass
else:
try: gitea(f"issues/{n}", "PATCH", {"state": "closed"}); log("Closed") try: gitea(f"issues/{n}", "PATCH", {"state": "closed"}); log("Closed")
except: pass except: pass
+2
View File
@@ -22,6 +22,7 @@ dependencies = [
dev = [ dev = [
"pytest>=8.0", "pytest>=8.0",
"pytest-asyncio>=0.24", "pytest-asyncio>=0.24",
"pytest-json-report>=1.5",
"ruff>=0.8", "ruff>=0.8",
] ]
@@ -33,4 +34,5 @@ target-version = "py310"
asyncio_mode = "auto" asyncio_mode = "auto"
markers = [ markers = [
"integration: end-to-end pipeline integration tests (heavy; invoke Selenium)", "integration: end-to-end pipeline integration tests (heavy; invoke Selenium)",
"sweep: opt-in heavyweight sweep tests (IMP-43 u7b: 3 layouts × 3 mdx × frame-pin coverage). Invoke explicitly via `pytest -m sweep`; default CI must use `-m 'not sweep'`.",
] ]
+11
View File
@@ -2,6 +2,17 @@
title: DX 지연 요인 title: DX 지연 요인
sidebar: sidebar:
order: 03 order: 03
slide_overrides:
css: |
.slide-body {
grid-template-rows: 0.38fr 0.60fr !important;
gap: 1.5% !important;
}
.f29b__cell .text-line + .text-line { margin-top: 1px !important; }
.f29b__cell:nth-child(n+3) {
padding-top: 3px !important;
margin-top: 2px !important;
}
--- ---
## 1. DX에 대한 인식 ## 1. DX에 대한 인식
+178
View File
@@ -0,0 +1,178 @@
"""IMP-43 (#72) u8 — measure ``--reuse-from`` wall-clock savings.
Argv-driven measurement helper for the Stage 2 §u8 binding contract:
re-derive a realistic savings target instead of mirroring the
unverified issue-body 50–70% / 10–20s → 3–8s claim.
Per-iteration measurement protocol (mirrors the u7a equivalence
harness, ``tests/test_phase_z2_reuse_from_equivalence_unit.py``):
(A) baseline full run, no overrides — reuse seed
(B) full rerun full run + one --override-frame pin — control path
(C) reuse --reuse-from <seed> + same pin — reuse path
Wall-clock = ``time.perf_counter()`` around the subprocess.run call.
The (A) seed run time is reported separately and NOT included in the
B-vs-C comparison (the reuse path's whole point is that the seed
already exists from a prior interactive run).
For each iteration the frame pin is self-discovered from the seed
run's ``step06_composition_plan.json``: the first unit's
``frame_template_id`` is re-pinned to itself, exercising the
``--override-frame`` CLI surface end-to-end without changing the
semantic frame assignment (same approach the u7a/u7b equivalence
tests already lock).
Output: a JSON document to stdout with per-iteration timings,
B/C p50 + p95, and the ratio C/B. Stderr carries the subprocess
stdout/stderr tails on non-zero exits.
Guardrails (Stage 2):
* argv-driven, no hardcoded mdx — caller picks the sample
* no hardcoded savings target — TBD until measured
* value + path + upstream provenance lives in the printed JSON
* does NOT mutate prev_run_dir; new runs land under fresh run_ids
"""
from __future__ import annotations
import argparse
import json
import statistics
import subprocess
import sys
import time
import uuid
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
RUNS_DIR = REPO_ROOT / "data" / "runs"
def _unique_run_id(prefix: str) -> str:
return f"{prefix}_imp43_u8_{uuid.uuid4().hex[:8]}"
def _spawn(extra_args: list[str], timeout: int) -> tuple[subprocess.CompletedProcess, float]:
start = time.perf_counter()
cp = subprocess.run(
[sys.executable, "-m", "src.phase_z2_pipeline", *extra_args],
capture_output=True,
text=True,
timeout=timeout,
cwd=str(REPO_ROOT),
)
return cp, time.perf_counter() - start
def _assert_ok(label: str, cp: subprocess.CompletedProcess) -> None:
if cp.returncode != 0:
sys.stderr.write(
f"[measure_reuse_savings] {label} failed rc={cp.returncode}\n"
f"--- stderr tail ---\n{cp.stderr[-2000:]}\n"
f"--- stdout tail ---\n{cp.stdout[-2000:]}\n"
)
raise SystemExit(2)
def _discover_first_frame_pin(seed_run_id: str) -> tuple[str, str]:
p = RUNS_DIR / seed_run_id / "phase_z2" / "steps" / "step06_composition_plan.json"
payload = json.loads(p.read_text(encoding="utf-8"))
for u in payload.get("data", {}).get("selected_units") or []:
sids = u.get("source_section_ids") or []
tpl = u.get("frame_template_id")
if isinstance(sids, list) and sids and isinstance(tpl, str) and tpl:
return ("+".join(str(s) for s in sids), tpl)
raise SystemExit(
f"[measure_reuse_savings] seed {seed_run_id} step06 has no pinnable "
f"(unit_id, frame_template_id); path={p}"
)
def _percentile(values: list[float], pct: float) -> float:
if not values:
return float("nan")
if len(values) == 1:
return values[0]
s = sorted(values)
k = (len(s) - 1) * pct
lo = int(k)
hi = min(lo + 1, len(s) - 1)
return s[lo] + (s[hi] - s[lo]) * (k - lo)
def main() -> int:
ap = argparse.ArgumentParser(
prog="python -m scripts.measure_reuse_savings",
description="Measure IMP-43 --reuse-from wall-clock savings.",
)
ap.add_argument("mdx_path", type=Path, help="MDX sample to measure against")
ap.add_argument("--iterations", type=int, default=3, help="trials (default 3)")
ap.add_argument("--timeout", type=int, default=900, help="per-run timeout seconds")
args = ap.parse_args()
if not args.mdx_path.is_file():
sys.stderr.write(f"[measure_reuse_savings] mdx not found: {args.mdx_path}\n")
return 2
iterations: list[dict] = []
for i in range(args.iterations):
seed_id = _unique_run_id(f"seed{i}")
cp_a, t_a = _spawn([str(args.mdx_path), seed_id], args.timeout)
_assert_ok(f"(A) seed iter={i}", cp_a)
unit_id, tpl_id = _discover_first_frame_pin(seed_id)
override = ["--override-frame", f"{unit_id}={tpl_id}"]
full_id = _unique_run_id(f"full{i}")
cp_b, t_b = _spawn([str(args.mdx_path), full_id, *override], args.timeout)
_assert_ok(f"(B) full rerun iter={i}", cp_b)
reuse_id = _unique_run_id(f"reuse{i}")
cp_c, t_c = _spawn(
[str(args.mdx_path), reuse_id, "--reuse-from", seed_id, *override],
args.timeout,
)
_assert_ok(f"(C) reuse iter={i}", cp_c)
iterations.append({
"iter": i,
"seed_run_id": seed_id,
"full_run_id": full_id,
"reuse_run_id": reuse_id,
"override_frame": f"{unit_id}={tpl_id}",
"seed_seconds": t_a,
"full_rerun_seconds": t_b,
"reuse_seconds": t_c,
})
full_times = [it["full_rerun_seconds"] for it in iterations]
reuse_times = [it["reuse_seconds"] for it in iterations]
summary = {
"mdx_path": str(args.mdx_path),
"iterations_count": len(iterations),
"full_rerun_seconds_p50": _percentile(full_times, 0.50),
"full_rerun_seconds_p95": _percentile(full_times, 0.95),
"reuse_seconds_p50": _percentile(reuse_times, 0.50),
"reuse_seconds_p95": _percentile(reuse_times, 0.95),
"reuse_over_full_ratio_p50": (
_percentile(reuse_times, 0.50) / _percentile(full_times, 0.50)
if full_times and statistics.median(full_times) > 0
else float("nan")
),
"iterations": iterations,
"note": (
"IMP-43 (#72) u8 measurement. Issue-body 50–70% / 10–20s → 3–8s "
"claim is NOT honored here — actual numbers depend on host, "
"Selenium cold-start, and AI cache state. Update "
"docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md §8 with the "
"p50/p95 reported here when run on the project's reference host."
),
}
sys.stdout.write(json.dumps(summary, ensure_ascii=False, indent=2))
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+75
View File
@@ -0,0 +1,75 @@
"""IMP-#91 u14 — idempotent status-board marker updater.
Reads a pytest-json-report artifact emitted by the IMP-91 CI workflow and
rewrites paired ``<!-- IMP-91:<axis>:<mdx> -->...<!-- /IMP-91 -->`` markers
inside the Phase Z status board with a single-character outcome symbol.
Pure functions (``parse_outcomes`` / ``update_board_text``) are exposed so
``tests/scripts/test_update_status_board.py`` can exercise the contract
without invoking pytest. The CLI just wires file IO around them so the
GitHub Actions step in u15 can call it deterministically. The updater is
additive: untouched markers stay; missing outcomes render ``?`` so a
collection failure is loud, not silent. [[feedback_auto_pipeline_first]]
[[feedback_artifact_status_naming]]
"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Dict, Mapping, Tuple
AXIS_FROM_TEST = {
"test_normalize_snapshot_matches": "F0",
"test_v4_ranking_snapshot_matches": "F1",
"test_slot_payload_snapshot_matches": "F2",
"test_ai_classifier_snapshot_matches": "F3",
"test_layout_snapshot_matches": "F4",
"test_final_html_snapshot_matches": "F5",
}
SYMBOL = {"passed": "PASS", "failed": "FAIL", "error": "ERR", "skipped": "SKIP"}
NODEID_RE = re.compile(r"::(test_[a-z0-9_]+)\[(\d{2})\]$")
MARKER_RE = re.compile(
r"(<!-- IMP-91:(F[0-5]):(\d{2}) -->)(.*?)(<!-- /IMP-91 -->)", re.DOTALL
)
def parse_outcomes(report: Mapping[str, object]) -> Dict[Tuple[str, str], str]:
out: Dict[Tuple[str, str], str] = {}
for test in report.get("tests", []) or []:
m = NODEID_RE.search(str(test.get("nodeid", "")))
if not m:
continue
axis = AXIS_FROM_TEST.get(m.group(1))
if not axis:
continue
out[(axis, m.group(2))] = SYMBOL.get(str(test.get("outcome")), "?")
return out
def update_board_text(board: str, outcomes: Mapping[Tuple[str, str], str]) -> str:
def repl(match: "re.Match[str]") -> str:
key = (match.group(2), match.group(3))
symbol = outcomes.get(key, "?")
return f"{match.group(1)}{symbol}{match.group(5)}"
return MARKER_RE.sub(repl, board)
def main() -> int:
parser = argparse.ArgumentParser(description="IMP-91 status-board updater")
parser.add_argument("--report", required=True, type=Path)
parser.add_argument("--board", required=True, type=Path)
args = parser.parse_args()
report = json.loads(args.report.read_text(encoding="utf-8"))
outcomes = parse_outcomes(report)
args.board.write_text(
update_board_text(args.board.read_text(encoding="utf-8"), outcomes),
encoding="utf-8",
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+29
View File
@@ -392,6 +392,32 @@ def _clean_text(text: str) -> str:
# 메인 함수 # 메인 함수
# ══════════════════════════════════════ # ══════════════════════════════════════
def _extract_slide_overrides(metadata: dict[str, Any]) -> dict[str, Any]:
"""Surface the nested ``slide_overrides`` mapping from frontmatter.
IMP-45 (#74) u2 — slide-level CSS override axis intake. Returns a
plain ``dict`` so callers (Step 13 injector) can read
``slide_overrides.get("css")`` without re-parsing frontmatter.
Rules:
- Absent or non-mapping → ``{}``.
- Inside the mapping, ``css`` is kept only when it is a ``str``
(non-string values dropped to fail-closed against typo'd YAML
shapes such as ``css: [".x{}"]``).
- Unknown sibling keys (e.g., future ``slide_overrides.js``) are
preserved verbatim — generalization deferred per Stage 2 scope.
"""
raw = metadata.get("slide_overrides")
if not isinstance(raw, dict):
return {}
out: dict[str, Any] = {}
for k, v in raw.items():
if k == "css" and not isinstance(v, str):
continue
out[k] = v
return out
def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]: def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
"""MDX 원본을 4-Layer 파서로 정규화. """MDX 원본을 4-Layer 파서로 정규화.
@@ -405,11 +431,13 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
"popups": [{"title": str, "content": str}], "popups": [{"title": str, "content": str}],
"tables": [{"headers": list, "rows": list}], "tables": [{"headers": list, "rows": list}],
"sections": [{"level": int, "title": str, "content": str}], "sections": [{"level": int, "title": str, "content": str}],
"slide_overrides": {"css": str, ...} | {},
} }
""" """
# ── Layer 1: frontmatter 분리 ── # ── Layer 1: frontmatter 분리 ──
metadata, body = frontmatter.parse(raw_mdx) metadata, body = frontmatter.parse(raw_mdx)
title = metadata.get("title", "") title = metadata.get("title", "")
slide_overrides = _extract_slide_overrides(metadata)
logger.info(f"[Layer 1] title='{title}', metadata keys={list(metadata.keys())}") logger.info(f"[Layer 1] title='{title}', metadata keys={list(metadata.keys())}")
# ── Layer 2: 코드블록 보호 → MDX 패턴 처리 ── # ── Layer 2: 코드블록 보호 → MDX 패턴 처리 ──
@@ -437,6 +465,7 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
"popups": popups, "popups": popups,
"tables": tables, "tables": tables,
"sections": sections, "sections": sections,
"slide_overrides": slide_overrides,
} }
+107 -2
View File
@@ -27,7 +27,9 @@ glue_compression (SPACING_GLUE envelope, frame-scoped)
↓ 그래도 안 되면 ↓ 그래도 안 되면
font_step_compression (FONT_SIZE_STEPS, zone-scoped) font_step_compression (FONT_SIZE_STEPS, zone-scoped)
↓ 그래도 안 되면 ↓ 그래도 안 되면
layout_adjust (zone topology 변경) layout_adjust (zone topology 변경 — 8-preset switch)
↓ 그래도 안 되면
frame_internal_fit_candidate (frame contract envelope 안 internal fit 변형)
↓ 그래도 안 되면 ↓ 그래도 안 되면
frame_reselect (V4 top-k 의 다른 frame) frame_reselect (V4 top-k 의 다른 frame)
↓ 그래도 안 되면 ↓ 그래도 안 되면
@@ -40,6 +42,33 @@ IMP-35 (#64) u2 — cascade terminal landed. `frame_reselect_insufficient`
MISSING here; the actual executor stub + MISSING→IMPLEMENTED flip lives in MISSING here; the actual executor stub + MISSING→IMPLEMENTED flip lives in
`src/phase_z2_router.py` (u3 surface), so this module advertises the cascade `src/phase_z2_router.py` (u3 surface), so this module advertises the cascade
terminal without claiming an implementation it does not own. terminal without claiming an implementation it does not own.
IMP-88 (#88) u2 — Step 17 retry chain extension. Three new failure_type
producers + cascade rows wire the three issue-body axes onto the deterministic
chain WITHOUT activating any AI path or shared-margin shrink:
| failure_type | next_proposed_action |
|---|---|
| layout_adjust_insufficient | frame_internal_fit_candidate |
| frame_internal_fit_candidate_insufficient | frame_reselect |
| image_fit_insufficient | layout_adjust |
`layout_adjust_insufficient` is the cascade extension between
`font_step_insufficient → layout_adjust` (existing) and the legacy
`rerender_still_fails → frame_reselect` rejoin point — closing the open
cascade tail that previously terminated salvage at `layout_adjust` with no
next-step record. `frame_internal_fit_candidate_insufficient` rejoins the
existing `frame_reselect` mid-cascade, so V4 top-k swap remains reachable
after the in-envelope salvage exhausts. `image_fit_insufficient` (Step 17
single-pass entry per u7) escalates onto the main cascade at `layout_adjust`
so an image-driven overflow that cannot be fit inside the frame envelope
benefits from layout topology change instead of any margin shrink
(feedback_phase_z_spacing_direction guardrail).
The three new `next_action` destinations (`layout_adjust`,
`frame_internal_fit_candidate`, `image_fit`) are advertised as MISSING here.
The MISSING → IMPLEMENTED flip lives on the deterministic planner units
(u3/u4/u5) in `src/phase_z2_retry.py`; this module owns mapping only.
""" """
from __future__ import annotations from __future__ import annotations
@@ -85,6 +114,28 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
"'frame_reselect' AND passed=False AND post_salvage_overflow present. " "'frame_reselect' AND passed=False AND post_salvage_overflow present. "
"Routes to details_popup_escalation in u2 (cascade terminal)." "Routes to details_popup_escalation in u2 (cascade terminal)."
), ),
# IMP-88 (#88) u2 — three new salvage failure producers wired onto the
# deterministic cascade. Classifier reuses the salvage_steps[-1] path
# introduced in IMP-12 u2 (SALVAGE_FAILURE_TYPE_BY_ACTION).
"layout_adjust_insufficient": (
"layout_adjust salvage step failed — 8-preset layout switch executed "
"but overflow persists post-rerender. Cascade exits onto "
"frame_internal_fit_candidate (frame envelope internal fit variant) "
"before V4 top-k frame_reselect."
),
"frame_internal_fit_candidate_insufficient": (
"frame_internal_fit_candidate salvage step failed — variant adjustments "
"inside the declared frame contract envelope could not absorb the "
"remaining overflow. Cascade exits onto frame_reselect (V4 top-k "
"alternate frame swap)."
),
"image_fit_insufficient": (
"image_fit salvage step failed — Step 17 single-pass image fit "
"(object-fit + max-w/h scoped to the offending frame) did not resolve "
"image_aspect_mismatch. Escalates onto the main cascade at "
"layout_adjust so a different layout topology can host the image "
"natural ratio (no shared margin shrink — Phase Z spacing direction)."
),
} }
@@ -103,6 +154,17 @@ SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = {
# frame's HTML. classifier reads that entry; u2 adds the NEXT_ACTION row # frame's HTML. classifier reads that entry; u2 adds the NEXT_ACTION row
# that routes this onto details_popup_escalation. # that routes this onto details_popup_escalation.
"frame_reselect": "frame_reselect_insufficient", "frame_reselect": "frame_reselect_insufficient",
# IMP-88 (#88) u2: producers for the three Step 17 retry chain actions
# (layout_adjust / image_fit / frame_internal_fit_candidate). The u6
# dispatcher (src/phase_z2_pipeline.py) appends salvage_steps entries with
# these action names when their planner-driven executor (u3/u4/u5) emits
# passed=False. The classifier path below already inspects
# salvage_steps[-1].action so no classifier change is required; u3 just
# registers the producer rows so the cascade keeps flowing instead of
# falling through to the defensive "not_attempted" fallback.
"layout_adjust": "layout_adjust_insufficient",
"image_fit": "image_fit_insufficient",
"frame_internal_fit_candidate": "frame_internal_fit_candidate_insufficient",
} }
@@ -124,6 +186,15 @@ NEXT_ACTION_BY_FAILURE: dict[str, str] = {
# only. # only.
"frame_reselect_insufficient": "details_popup_escalation", "frame_reselect_insufficient": "details_popup_escalation",
"not_attempted": "none", "not_attempted": "none",
# IMP-88 (#88) u2 — Step 17 retry chain cascade extension. Closes the
# previously open tail at layout_adjust + adds the frame_internal_fit
# mid-cascade rejoin onto frame_reselect. image_fit (single-pass entry,
# u7) escalates onto layout_adjust when its single-pass transform cannot
# resolve image_aspect_mismatch — Phase Z spacing direction guardrail
# routes through layout/frame instead of shrinking shared margins.
"layout_adjust_insufficient": "frame_internal_fit_candidate",
"frame_internal_fit_candidate_insufficient": "frame_reselect",
"image_fit_insufficient": "layout_adjust",
} }
NEXT_ACTION_RATIONALE: dict[str, str] = { NEXT_ACTION_RATIONALE: dict[str, str] = {
@@ -161,6 +232,22 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
"not_attempted": ( "not_attempted": (
"retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요" "retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요"
), ),
# IMP-88 (#88) u2 — Step 17 retry chain cascade rationale entries
"layout_adjust_insufficient": (
"layout_adjust salvage (8-preset switch) 후에도 overflow 잔존 → "
"frame_internal_fit_candidate 로 frame contract envelope 안 internal "
"fit 변형 시도. frame_reselect (V4 top-k 다른 frame) 는 cascade 다음 단계."
),
"frame_internal_fit_candidate_insufficient": (
"frame contract envelope 안 internal fit 변형 (density / line rhythm) "
"도 overflow 못 흡수 → frame_reselect (V4 top-k 다른 frame) 로 escalate. "
"popup 직행은 frame_reselect 까지 소진 후 (cascade terminal)."
),
"image_fit_insufficient": (
"image_fit Step 17 single-pass (object-fit / max-w/h frame-scoped) 가 "
"image_aspect_mismatch 못 해결 → layout_adjust 로 main cascade 진입. "
"공통 image CSS / 공통 spacing 축소 X (Phase Z spacing direction)."
),
} }
# 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태 # 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태
@@ -174,7 +261,13 @@ NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"cross_zone_redistribute": "IMPLEMENTED", # u4 plan_cross_zone_redistribute + apply_cross_zone_redistribute_css "cross_zone_redistribute": "IMPLEMENTED", # u4 plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
"glue_compression": "IMPLEMENTED", # u5 plan_glue_compression + apply_glue_compression_css "glue_compression": "IMPLEMENTED", # u5 plan_glue_compression + apply_glue_compression_css
"font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css "font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css
"layout_adjust": "MISSING", # IMP-88 (#88) u1→u7 (2026-05-24): layout_adjust flips here on the
# failure-router surface alongside the primary router surface. The
# cascade entry chains font_step_insufficient → layout_adjust and
# image_fit_insufficient → layout_adjust both reach this destination,
# which is now wired end-to-end via u3 (plan_layout_adjust) + u6
# (salvage dispatcher branch) + u7 (cascade entry trigger).
"layout_adjust": "IMPLEMENTED",
"frame_reselect": "MISSING", "frame_reselect": "MISSING",
# IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The # IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The
# router executor stub + MISSING→IMPLEMENTED flip lives in # router executor stub + MISSING→IMPLEMENTED flip lives in
@@ -182,6 +275,18 @@ NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
# lands prevents premature "popup ready" claims from the failure-router # lands prevents premature "popup ready" claims from the failure-router
# surface. # surface.
"details_popup_escalation": "MISSING", "details_popup_escalation": "MISSING",
# IMP-88 (#88) u1→u7 (2026-05-24): Step 17 retry chain destinations
# flipped to IMPLEMENTED. frame_internal_fit_candidate is a cascade
# destination (layout_adjust_insufficient → frame_internal_fit_candidate)
# wired via u5 planner + u6 dispatcher branch + u7 cascade entry.
# image_fit is a Step 17 single-pass entry wired via u4 planner +
# u7 _attempt_step17_image_fit_single_pass; it also surfaces here so
# route_retry_failure never returns 'unknown' when image_fit_insufficient
# cascades onto layout_adjust. (Same precedent as IMP-12 u7 cascade
# actions above — planner-surface availability + orchestrator wiring
# together constitute IMPLEMENTED on the deterministic surface.)
"frame_internal_fit_candidate": "IMPLEMENTED",
"image_fit": "IMPLEMENTED",
"none": "n/a", "none": "n/a",
} }
+28 -4
View File
@@ -579,12 +579,23 @@ def _build_compare_table_2col(section, units, contract) -> dict:
builder_options : builder_options :
item_parser : ITEM_PARSERS key (예: `compare_row_2col_item`) item_parser : ITEM_PARSERS key (예: `compare_row_2col_item`)
col_a_label_default : col_a header (MDX 미명시 시 fallback. F1-a fix) col_a_label_default : col_a header literal in catalog.
col_b_label_default : col_b header (MDX 미명시 시 fallback) Semantics depend on col_a_label_default_role.
col_a_label_default_role : "placeholder" | "fallback" (IMP-40 #69).
placeholder = Figma visual placeholder; suppressed
at runtime → col_a_label emitted as "".
fallback = MDX 미명시 시 catalog literal 사용.
absent = legacy contracts default to fallback.
col_b_label_default : col_b header literal (same policy as col_a).
col_b_label_default_role : same role discriminator for col_b (IMP-40 #69).
strip_col_prefix_aliases : list[str] — col_a/col_b 값의 prefix `<alias>:` strip_col_prefix_aliases : list[str] — col_a/col_b 값의 prefix `<alias>:`
를 strip (Codex round 43 §F1-b — narrow alias). 를 strip (Codex round 43 §F1-b — narrow alias).
예 : ["BIM", "DX"]. default [] (no stripping). 예 : ["BIM", "DX"]. default [] (no stripping).
max_rows : N (default 999 — practical 한계). max_rows : N (default 999 — practical 한계).
NOTE: MDX 측 col_a_label / col_b_label inflow 경로 없음
(compare_row_2col_item parser → {label,col_a,col_b}, _resolve_title → title only).
placeholder role 은 col_*_label 을 빈 문자열로 확정 — 정책 결정점은 catalog 한 곳뿐.
""" """
options = contract["payload"]["builder_options"] options = contract["payload"]["builder_options"]
parser_name = options["item_parser"] parser_name = options["item_parser"]
@@ -595,8 +606,21 @@ def _build_compare_table_2col(section, units, contract) -> dict:
f"but ITEM_PARSERS has no such entry." f"but ITEM_PARSERS has no such entry."
) )
col_a_label = options.get("col_a_label_default", "") def _resolve_label_default(col_key: str) -> str:
col_b_label = options.get("col_b_label_default", "") default_key = f"{col_key}_label_default"
role_key = f"{col_key}_label_default_role"
role = options.get(role_key, "fallback")
if role == "placeholder":
return ""
if role == "fallback":
return options.get(default_key, "")
raise ValueError(
f"Contract '{contract['template_id']}' builder_options.{role_key}='{role}' "
f"is invalid; expected 'placeholder' or 'fallback' (IMP-40 #69)."
)
col_a_label = _resolve_label_default("col_a")
col_b_label = _resolve_label_default("col_b")
strip_aliases = options.get("strip_col_prefix_aliases", []) or [] strip_aliases = options.get("strip_col_prefix_aliases", []) or []
max_rows = options.get("max_rows", 999) max_rows = options.get("max_rows", 999)
+1902 -22
View File
File diff suppressed because it is too large Load Diff
+374
View File
@@ -428,3 +428,377 @@ def apply_font_step_compression_css(plan: dict) -> str:
return "" return ""
return (f'[data-zone-position="{zone_position}"] {{\n' return (f'[data-zone-position="{zone_position}"] {{\n'
f" font-size: {float(target_font_px):.1f}px;\n}}") f" font-size: {float(target_font_px):.1f}px;\n}}")
# ──────────────────────────────────────
# IMP-88 u3 : layout_adjust — Step 17 retry chain (8-preset topology swap).
# Honors feedback_phase_z_spacing_direction: no shared margin / gap / slide-body
# shrink. Cascade entry per u2: image_fit_insufficient → layout_adjust;
# downstream per u2: layout_adjust_insufficient → frame_internal_fit_candidate.
# Plan-only — dispatcher (u6) consumes new_layout_preset + new_zones_data and
# rebuilds layout_css via apply_layout_adjust_layout_css(plan, gap_px).
# ──────────────────────────────────────
def _layout_swap_priority(current_topology: str, candidate_topology: str) -> int:
"""Lower = preferred swap target. Honors topology-axis mirroring first."""
pair = frozenset({current_topology, candidate_topology})
if pair == frozenset({"rows", "cols"}):
return 0
if pair == frozenset({"T", "inverted-T"}):
return 1
if pair == frozenset({"side-T-left", "side-T-right"}):
return 2
return 3
def plan_layout_adjust(
*, current_layout_preset: str, zones_data: list[dict],
) -> dict:
"""Layout-preset switch plan (Step 17 retry chain — IMP-88 u3).
Finds a render-ready sibling preset (same candidate_when.unit_count) and
remaps zone positions in catalog order. No common spacing shrink —
feedback_phase_z_spacing_direction lock: escalate via layout topology only.
"""
from src.phase_z2_composition import LAYOUT_PRESETS
base = {
"action": "layout_adjust",
"current_layout_preset": current_layout_preset,
}
current_spec = LAYOUT_PRESETS.get(current_layout_preset)
if current_spec is None:
return {
**base, "feasible": False, "new_layout_preset": None,
"candidates_considered": [],
"failure_reason": (
f"current_layout_preset '{current_layout_preset}' not in "
f"LAYOUT_PRESETS catalog — cannot enumerate same-unit-count siblings."
),
}
current_positions = list(current_spec.get("positions") or [])
if len(zones_data) != len(current_positions):
return {
**base, "feasible": False, "new_layout_preset": None,
"candidates_considered": [],
"failure_reason": (
f"zones_data length {len(zones_data)} != current preset "
f"'{current_layout_preset}' positions {current_positions} — "
f"cannot remap to a sibling preset."
),
}
unit_count = (current_spec.get("candidate_when") or {}).get("unit_count")
candidates = [
pid for pid, spec in LAYOUT_PRESETS.items()
if pid != current_layout_preset
and spec.get("render_ready", False)
and ((spec.get("candidate_when") or {}).get("unit_count") == unit_count)
]
base = {**base, "unit_count": unit_count,
"candidates_considered": list(candidates)}
if not candidates:
return {
**base, "feasible": False, "new_layout_preset": None,
"failure_reason": (
f"no render-ready 8-preset sibling for unit_count {unit_count} "
f"(current='{current_layout_preset}'). single (1) and grid-2x2 (4) "
f"have no swap target by catalog design."
),
}
catalog_order = list(LAYOUT_PRESETS.keys())
current_topo = current_spec.get("topology")
candidates.sort(key=lambda pid: (
_layout_swap_priority(current_topo, LAYOUT_PRESETS[pid].get("topology")),
catalog_order.index(pid),
))
new_preset = candidates[0]
new_positions = list(LAYOUT_PRESETS[new_preset].get("positions") or [])
new_zones_data = [
{**zd, "position": new_positions[i]} for i, zd in enumerate(zones_data)
]
return {
**base,
"feasible": True,
"new_layout_preset": new_preset,
"swap_topology_from": current_topo,
"swap_topology_to": LAYOUT_PRESETS[new_preset].get("topology"),
"position_remap": dict(zip(current_positions, new_positions)),
"new_zones_data": new_zones_data,
}
def apply_layout_adjust_layout_css(plan: dict, gap_px: int) -> Optional[dict]:
"""Build a fresh layout_css dict for the swapped preset.
Returns None when plan is infeasible. Dispatcher (u6) re-renders with
render_slide(zones_data=plan['new_zones_data'], layout_preset=plan
['new_layout_preset'], layout_css=<this return>, gap_px=gap_px).
"""
if not plan.get("feasible"):
return None
new_preset = plan.get("new_layout_preset")
new_zones_data = plan.get("new_zones_data") or []
if not new_preset or not new_zones_data:
return None
from src.phase_z2_pipeline import build_layout_css
new_layout_css = dict(build_layout_css(new_preset, new_zones_data, gap=gap_px))
raw = dict(new_layout_css.get("raw_zone_layout") or {})
raw["layout_adjust_applied"] = True
raw["layout_adjust_from"] = plan.get("current_layout_preset")
raw["layout_adjust_to"] = new_preset
new_layout_css["raw_zone_layout"] = raw
return new_layout_css
# ──────────────────────────────────────
# IMP-88 u4 : image_fit — Step 17 single-pass image-scoped CSS override.
# Consumes overflow_metrics.image_events directly (natural_w/h, rendered_w/h,
# natural_ratio, rendered_ratio, delta). Honors feedback_phase_z_spacing
# _direction — image-scoped CSS only, no shared margin / frame envelope shrink.
# Plan-only — Step 17 entry (u7) is the runtime caller.
# Default delta_tol mirrors src.phase_z2_pipeline.IMAGE_ASPECT_DELTA_TOL = 0.05;
# overridable arg keeps tests free of the pipeline import cycle.
# ──────────────────────────────────────
def plan_image_fit(
*, image_event: dict, delta_tol: float = 0.05,
) -> dict:
"""Image_fit planner (Step 17 retry chain — IMP-88 u4).
Emits frame-scoped object-fit + max-width/height from a single
image_event (overflow_metrics.image_events). Image-scoped only.
"""
base = {
"action": "image_fit",
"src": image_event.get("src"),
"zone_position": image_event.get("zone_position"),
"zone_template_id": image_event.get("zone_template_id"),
}
delta = image_event.get("delta")
if delta is None:
return {
**base, "feasible": False, "css_overrides": None,
"failure_reason": (
"image_event delta is None — image not loaded; no aspect "
"mismatch can be measured."
),
}
if abs(float(delta)) <= float(delta_tol):
return {
**base, "feasible": False, "css_overrides": None,
"delta": float(delta),
"failure_reason": (
f"|delta|={abs(float(delta)):.4f} <= delta_tol={delta_tol} — "
f"no image_aspect_mismatch to correct (planner no-op)."
),
}
rendered_w = image_event.get("rendered_w")
rendered_h = image_event.get("rendered_h")
if not (isinstance(rendered_w, (int, float)) and rendered_w > 0
and isinstance(rendered_h, (int, float)) and rendered_h > 0):
return {
**base, "feasible": False, "css_overrides": None,
"delta": float(delta),
"failure_reason": (
"image_event missing positive rendered_w / rendered_h — "
"cannot bound max-width / max-height for image-scoped CSS."
),
}
return {
**base,
"feasible": True,
"delta": float(delta),
"natural_ratio": image_event.get("natural_ratio"),
"rendered_ratio": image_event.get("rendered_ratio"),
"natural_w": image_event.get("natural_w"),
"natural_h": image_event.get("natural_h"),
"rendered_w": int(rendered_w),
"rendered_h": int(rendered_h),
# delta > 0 ⇒ rendered_ratio > natural_ratio ⇒ rendered too wide ⇒
# width axis correction; delta < 0 ⇒ height axis correction.
"correction_axis": "width" if float(delta) > 0 else "height",
"css_overrides": {
"object_fit": "contain",
"max_width_px": int(rendered_w),
"max_height_px": int(rendered_h),
"width": "auto",
"height": "auto",
},
}
def apply_image_fit_css(plan: dict) -> Optional[str]:
"""Build a frame-scoped CSS snippet from a feasible image_fit plan.
Returns None when plan is infeasible. u7 (Step 17 entry) injects the
snippet into the per-slide style override and re-renders.
"""
if not plan.get("feasible"):
return None
overrides = plan.get("css_overrides") or {}
if not overrides:
return None
src = plan.get("src") or ""
zone_position = plan.get("zone_position") or ""
if src:
selector = (
f".zone[data-zone-position=\"{zone_position}\"] "
f"img[src=\"{src}\"]"
)
else:
selector = f".zone[data-zone-position=\"{zone_position}\"] img"
return (
f"{selector} {{\n"
f" object-fit: {overrides.get('object_fit', 'contain')};\n"
f" max-width: {int(overrides.get('max_width_px') or 0)}px;\n"
f" max-height: {int(overrides.get('max_height_px') or 0)}px;\n"
f" width: {overrides.get('width', 'auto')};\n"
f" height: {overrides.get('height', 'auto')};\n"
f"}}"
)
# ──────────────────────────────────────
# IMP-88 u5 : frame_internal_fit_candidate — Step 17 retry chain.
# Operates ONLY inside the frame contract's declared `internal_envelope`
# (PHASE-Z-PIPELINE-OVERVIEW.md:333 lock). Sub-mechanism names allowed by
# the OVERVIEW: density envelope / line rhythm / internal grid row / text
# block allocation — all unified under the single action label
# `frame_internal_fit_candidate` so common-CSS/padding shrink antipatterns
# stay quarantined ([[feedback_phase_z_spacing_direction]]).
#
# Envelope shape (dormant — catalog adds when contracts declare it):
# frame_contract["internal_envelope"] = {
# "variants": [
# {"name": "<sub_mechanism_name>",
# "excess_budget_px": <int — px of vertical excess this variant can absorb>,
# "css_overrides": {<css-property>: <value>, ...}},
# ...
# ]
# }
# Selection = walk variants in catalog order, pick first whose excess_budget_px
# >= effective excess_y (greedy). Catalog order = catalog author's priority.
#
# No frame contract currently declares `internal_envelope`, so the planner
# returns infeasible(envelope_present=False) for every live frame today.
# Cascade hand-off: NEXT_ACTION_BY_FAILURE['frame_internal_fit_candidate_
# insufficient'] = 'frame_reselect' (set in u2). Plan-only — u6 (salvage
# dispatcher) and u7 (Step 17 entry) own the runtime call site.
#
# frame_contract is an overridable kwarg so tests don't pay the mapper
# catalog cache cost / pipeline import cycle (mirrors u4's delta_tol).
# ──────────────────────────────────────
def plan_frame_internal_fit_candidate(
*, frame_template_id: str,
frame_contract: Optional[dict] = None,
overflow_zone: Optional[dict] = None,
) -> dict:
"""frame_internal_fit_candidate planner (Step 17 retry chain — IMP-88 u5).
Walks `frame_contract['internal_envelope']['variants']` in catalog order
and picks the first variant whose excess_budget_px covers `overflow_zone
['excess_y']`. Returns infeasible when no contract / no envelope / no
variant fits. No common-margin shrink — sub-mechanism CSS is frame-scoped.
"""
base = {
"action": "frame_internal_fit_candidate",
"frame_template_id": frame_template_id,
}
if frame_contract is None:
from src.phase_z2_mapper import get_contract
frame_contract = get_contract(frame_template_id)
if frame_contract is None:
return {
**base, "feasible": False, "envelope_present": False,
"candidates_considered": [], "selected_variant": None,
"css_overrides": None,
"failure_reason": (
f"no frame contract registered for template_id "
f"'{frame_template_id}' — cannot enumerate internal_envelope."
),
}
envelope = frame_contract.get("internal_envelope")
if not isinstance(envelope, dict):
return {
**base, "feasible": False, "envelope_present": False,
"candidates_considered": [], "selected_variant": None,
"css_overrides": None,
"failure_reason": (
f"frame contract '{frame_template_id}' does not declare "
f"internal_envelope — cascade should escalate to frame_reselect."
),
}
variants = list(envelope.get("variants") or [])
candidates_considered = [v.get("name") for v in variants if isinstance(v, dict)]
if not variants:
return {
**base, "feasible": False, "envelope_present": True,
"envelope_keys": sorted(envelope.keys()),
"candidates_considered": candidates_considered,
"selected_variant": None, "css_overrides": None,
"failure_reason": (
f"frame contract '{frame_template_id}' internal_envelope "
f"declares no variants — no sub-mechanism available."
),
}
excess_y = 0
if isinstance(overflow_zone, dict):
ey = overflow_zone.get("excess_y")
if isinstance(ey, (int, float)) and ey > 0:
excess_y = int(math.ceil(float(ey)))
selected: Optional[dict] = None
for variant in variants:
if not isinstance(variant, dict):
continue
budget = variant.get("excess_budget_px")
if not isinstance(budget, (int, float)):
continue
if int(budget) >= excess_y:
selected = variant
break
if selected is None:
return {
**base, "feasible": False, "envelope_present": True,
"envelope_keys": sorted(envelope.keys()),
"candidates_considered": candidates_considered,
"selected_variant": None, "css_overrides": None,
"excess_y": excess_y,
"failure_reason": (
f"all {len(variants)} internal_envelope variant(s) for "
f"'{frame_template_id}' have excess_budget_px below excess_y="
f"{excess_y}px — internal fit cannot absorb overflow."
),
}
overrides = selected.get("css_overrides") or {}
return {
**base, "feasible": True, "envelope_present": True,
"envelope_keys": sorted(envelope.keys()),
"candidates_considered": candidates_considered,
"selected_variant": selected.get("name"),
"selected_variant_budget_px": int(selected.get("excess_budget_px") or 0),
"excess_y": excess_y,
"css_overrides": dict(overrides),
}
def apply_frame_internal_fit_candidate_css(plan: dict) -> Optional[str]:
"""Build a frame-scoped CSS snippet from a feasible frame_internal_fit
plan. Returns None when plan is infeasible. u6 / u7 inject the snippet
into the per-slide style override and re-render.
"""
if not plan.get("feasible"):
return None
overrides = plan.get("css_overrides") or {}
if not overrides:
return None
template_id = plan.get("frame_template_id") or ""
if not template_id:
return None
selector = f".zone[data-template-id=\"{template_id}\"]"
body_lines = [
f" {prop}: {value};" for prop, value in overrides.items()
]
return f"{selector} {{\n" + "\n".join(body_lines) + "\n}"
+301
View File
@@ -0,0 +1,301 @@
"""IMP-43 (#72) u2 — Step 6 reuse snapshot schema (JSON-only).
Stage 2 plan (locked) — ``--reuse-from PREV_RUN_ID`` reuses the
Step 0 / 1 / 2 / 5 / 6 deterministic artifact subset plus the
in-memory state that downstream steps need but that the existing
``step02_normalized.json`` / ``step05_v4_evidence.json`` /
``step06_composition_plan.json`` artifacts do not capture in a
deserialize-ready form (e.g. ``CompositionUnit`` instances,
``comp_debug``, ``v4_fallback_traces`` raw map, pre-override
``layout_preset``). This module owns the schema for the additional
``_reuse_snapshot.json`` sidecar written next to ``step06_composition_plan.json``.
Scope (u2 only, Stage 2 unit split):
* Pure schema + serializers + validator. No file I/O.
* JSON-only — pickle is forbidden per Stage 2 guardrails.
* Provenance per top-level field: ``{value, source_path, upstream_step}``.
* ``mdx_sha256`` integrity key — ``--reuse-from`` must fail closed when
the prev run's MDX bytes don't match the current MDX bytes.
* ``schema_version`` — bumped on any non-additive shape change.
Out of scope (deferred to later units):
* Writing the snapshot into the run_dir (u3).
* Copy / restore on ``--reuse-from`` (u4).
* Fail-closed snapshot/path errors at restore time (u4b).
* Threading ``reuse_from`` through ``run_phase_z2_mvp1`` (u5).
"""
from __future__ import annotations
import json
from typing import Any, Optional
SNAPSHOT_VERSION = 1
SNAPSHOT_FILENAME = "_reuse_snapshot.json"
# Required top-level keys. Bare scalars (no provenance wrapper):
# - schema_version (contract key)
# - mdx_sha256 (integrity key)
# All other keys are wrapped {value, source_path, upstream_step}.
REQUIRED_TOP_LEVEL_KEYS: tuple[str, ...] = (
"schema_version",
"mdx_sha256",
"slide_title",
"slide_footer",
"sections",
"stage0_adapter_diagnostics",
"stage0_normalized_assets",
"v4_evidence",
"layout_preset_pre_override",
"units",
"comp_debug",
"v4_fallback_traces",
"ai_preflight",
)
_BARE_KEYS: frozenset[str] = frozenset({"schema_version", "mdx_sha256"})
def _wrap(value: Any, *, source_path: str, upstream_step: str) -> dict[str, Any]:
return {
"value": value,
"source_path": source_path,
"upstream_step": upstream_step,
}
def serialize_section(section: Any) -> dict[str, Any]:
"""Serialize an ``MdxSection``-shaped object into a JSON-safe dict.
Duck-typed: accepts the production ``MdxSection`` dataclass or any
object exposing the same attribute names. Preserves the subset of
fields needed to reconstruct downstream pipeline behavior on the
reuse path.
"""
return {
"section_id": section.section_id,
"section_num": section.section_num,
"title": section.title,
"raw_content": section.raw_content,
"heading_number": getattr(section, "heading_number", None),
"v4_alias_keys": list(getattr(section, "v4_alias_keys", []) or []),
"sub_sections": list(getattr(section, "sub_sections", []) or []),
}
def serialize_unit(unit: Any) -> dict[str, Any]:
"""Serialize a ``CompositionUnit``-shaped object into a JSON-safe dict.
``v4_candidates`` entries are V4Match-duck-typed per the
CompositionUnit docstring; each is unwrapped to its 6 named
attributes so the snapshot file does not pin V4Match's dataclass
layout. ``v4_rank`` is included so the reuse path's Step 9
application-plan payload (``_build_application_plan_unit``)
remains byte-equivalent to the full-rerun path — full rerun stamps
each candidate's rank via ``_v4_match_from_judgment`` (e.g. 1, 2,
3, …) and Step 9 surfaces it under ``v4_candidates[i].v4_rank``.
Persisting it here lets the rehydrated ``_RehydratedV4Candidate``
expose the same attribute end-to-end and avoids None drift in the
Step 13 equivalence comparison (u7a).
"""
return {
"source_section_ids": list(unit.source_section_ids),
"merge_type": unit.merge_type,
"frame_template_id": unit.frame_template_id,
"frame_id": unit.frame_id,
"frame_number": unit.frame_number,
"confidence": float(unit.confidence),
"label": unit.label,
"phase_z_status": unit.phase_z_status,
"raw_content": unit.raw_content,
"title": unit.title,
"v4_rank": unit.v4_rank,
"selection_path": unit.selection_path,
"fallback_reason": unit.fallback_reason,
"score": float(unit.score),
"rationale": dict(unit.rationale or {}),
"auto_selectable": bool(unit.auto_selectable),
"filter_reasons": list(unit.filter_reasons or []),
"notes": list(unit.notes or []),
"v4_candidates": [
{
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"confidence": float(c.confidence),
"label": c.label,
"v4_rank": getattr(c, "v4_rank", None),
}
for c in (unit.v4_candidates or [])
],
"provisional": bool(getattr(unit, "provisional", False)),
}
def build_snapshot(
*,
mdx_sha256: str,
slide_title: Optional[str],
slide_footer: Optional[str],
sections: list,
stage0_adapter_diagnostics: Optional[dict],
stage0_normalized_assets: Optional[dict],
v4_evidence: list,
layout_preset_pre_override: Optional[str],
units: list,
comp_debug: Optional[dict],
v4_fallback_traces: Optional[dict],
ai_preflight: Optional[dict],
) -> dict[str, Any]:
"""Build a JSON-serializable Step 6 reuse snapshot with provenance.
Each top-level entry — except the two bare contract / integrity
keys (``schema_version``, ``mdx_sha256``) — is wrapped with
``{value, source_path, upstream_step}``.
The function calls ``json.dumps(snapshot)`` at the end to enforce
JSON-safety at build time: any latent non-JSON value (set, Path,
dataclass instance, etc.) raises ``TypeError`` at the call site,
not later at restore.
"""
snapshot: dict[str, Any] = {
"schema_version": SNAPSHOT_VERSION,
"mdx_sha256": mdx_sha256,
"slide_title": _wrap(
slide_title,
source_path="steps/step02_normalized.json#/slide_title",
upstream_step="step02",
),
"slide_footer": _wrap(
slide_footer,
source_path="steps/step02_normalized.json#/slide_footer",
upstream_step="step02",
),
"sections": _wrap(
[serialize_section(s) for s in sections],
source_path="steps/step02_normalized.json#/sections",
upstream_step="step02",
),
"stage0_adapter_diagnostics": _wrap(
dict(stage0_adapter_diagnostics or {}),
source_path="steps/step02_normalized.json#/stage0_adapter_diagnostics",
upstream_step="step02",
),
"stage0_normalized_assets": _wrap(
dict(stage0_normalized_assets or {}),
source_path="steps/step02_normalized.json#/stage0_normalized_assets",
upstream_step="step02",
),
"v4_evidence": _wrap(
list(v4_evidence or []),
source_path="steps/step05_v4_evidence.json#/evidence_per_section",
upstream_step="step05",
),
"layout_preset_pre_override": _wrap(
layout_preset_pre_override,
source_path="steps/step06_composition_plan.json#/layout_preset_decided",
upstream_step="step06",
),
"units": _wrap(
[serialize_unit(u) for u in units],
source_path="steps/step06_composition_plan.json#/selected_units",
upstream_step="step06",
),
"comp_debug": _wrap(
dict(comp_debug or {}),
source_path="steps/step06_composition_plan.json#/*",
upstream_step="step06",
),
"v4_fallback_traces": _wrap(
dict(v4_fallback_traces or {}),
# v4_fallback_traces is assembled inside run_phase_z2_mvp1
# (see phase_z2_pipeline.py around the Step 5/6 boundary) and
# surfaces only partially into step06_composition_plan.json
# via the v4_fallback_summary / imp48_resplit fields. The
# canonical untruncated source is the in-memory dict at end
# of Step 6 — that's what the reuse path needs.
source_path="phase_z2_pipeline.run_phase_z2_mvp1::v4_fallback_traces",
upstream_step="step06",
),
"ai_preflight": _wrap(
dict(ai_preflight or {}),
source_path="steps/step00_preconditions.json#/ai_preflight",
upstream_step="step00",
),
}
json.dumps(snapshot)
return snapshot
class SnapshotValidationError(ValueError):
"""Raised by ``validate_snapshot`` when the snapshot is structurally
unusable or fails the ``mdx_sha256`` integrity check.
Subclass of ``ValueError`` so existing ``except ValueError`` callers
(u4b will add a tighter ``except SnapshotValidationError``) still
catch it without escaping to the outer CLI.
"""
def validate_snapshot(
snapshot: Any,
*,
expected_mdx_sha256: str,
) -> None:
"""Validate a loaded snapshot dict (fail-closed).
Raises ``SnapshotValidationError`` when:
* ``snapshot`` is not a dict
* ``schema_version`` is missing or != ``SNAPSHOT_VERSION``
* ``mdx_sha256`` is missing, non-string, or doesn't match
``expected_mdx_sha256``
* any required top-level key is missing
* a wrapped entry doesn't expose ``{value, source_path, upstream_step}``
Returns ``None`` on success.
Callers (u4b) translate the raised error into an exit-code-2 abort
with the failing axis surfaced as `value + path + upstream`
(factual-verification guardrail).
"""
if not isinstance(snapshot, dict):
raise SnapshotValidationError(
f"snapshot is not a dict (got {type(snapshot).__name__})"
)
version = snapshot.get("schema_version")
if version != SNAPSHOT_VERSION:
raise SnapshotValidationError(
f"schema_version mismatch: expected {SNAPSHOT_VERSION!r}, got {version!r}"
)
actual_sha = snapshot.get("mdx_sha256")
if not isinstance(actual_sha, str) or not actual_sha:
raise SnapshotValidationError(
f"mdx_sha256 missing or non-string: got {actual_sha!r}"
)
if actual_sha != expected_mdx_sha256:
raise SnapshotValidationError(
f"mdx_sha256 mismatch: snapshot={actual_sha!r} "
f"expected={expected_mdx_sha256!r}"
)
missing = [k for k in REQUIRED_TOP_LEVEL_KEYS if k not in snapshot]
if missing:
raise SnapshotValidationError(
f"missing required keys: {missing!r}"
)
for key, entry in snapshot.items():
if key in _BARE_KEYS:
continue
if not isinstance(entry, dict):
raise SnapshotValidationError(
f"key {key!r}: expected wrapper dict, got {type(entry).__name__}"
)
for field_name in ("value", "source_path", "upstream_step"):
if field_name not in entry:
raise SnapshotValidationError(
f"key {key!r}: wrapper missing {field_name!r}"
)
+36 -3
View File
@@ -25,13 +25,27 @@ from typing import Optional
# ─── §4 mapping table (spec PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC §4) ── # ─── §4 mapping table (spec PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC §4) ──
# category → proposed_action (primary) # category → proposed_action (primary)
# IMP-88 (#88) u1 (2026-05-24): two ACTION_BY_CATEGORY edits to align the
# primary router surface with PHASE-Z-PIPELINE-OVERVIEW.md Step 16 + Step 17
# spec (anchor PHASE-Z-PIPELINE-OVERVIEW.md:321):
# 1. NEW row `image_aspect_mismatch → image_fit` — closes the unmapped
# classifier emission (phase_z2_classifier.py:434-447) that previously
# returned proposed_action=None and stalled visual_check on overflow
# runs carrying image_event payloads.
# 2. REMAP `frame_capacity_mismatch → frame_internal_fit_candidate`
# (previously frame_reselect) — OVERVIEW.md Step 17 locks
# frame_internal_fit_candidate as the per-zone first-pass salvage
# *inside* the declared frame envelope; frame_reselect (V4 top-k
# alternate frame swap) stays available downstream via the
# failure_router cascade (rerender_still_fails → frame_reselect).
ACTION_BY_CATEGORY: dict[str, str] = { ACTION_BY_CATEGORY: dict[str, str] = {
"minor_overflow": "zone_ratio_retry", "minor_overflow": "zone_ratio_retry",
"moderate_overflow": "layout_adjust", "moderate_overflow": "layout_adjust",
"structural_minor_overflow": "zone_ratio_retry", "structural_minor_overflow": "zone_ratio_retry",
"structural_major_overflow": "details_popup_escalation", "structural_major_overflow": "details_popup_escalation",
"tabular_overflow": "details_popup_escalation", "tabular_overflow": "details_popup_escalation",
"frame_capacity_mismatch": "frame_reselect", "image_aspect_mismatch": "image_fit",
"frame_capacity_mismatch": "frame_internal_fit_candidate",
"layout_zone_mismatch": "layout_adjust", "layout_zone_mismatch": "layout_adjust",
"hard_visual_fail": "abort", "hard_visual_fail": "abort",
} }
@@ -48,8 +62,13 @@ ACTION_RATIONALE: dict[str, str] = {
"1+ structural unit 완전 잘림 → 의미 손실, popup 으로 escalate", "1+ structural unit 완전 잘림 → 의미 손실, popup 으로 escalate",
"tabular_overflow": "tabular_overflow":
"표는 행 단위로 잘리면 의미 손실 → popup escalate (또는 table-friendly frame reselect)", "표는 행 단위로 잘리면 의미 손실 → popup escalate (또는 table-friendly frame reselect)",
"image_aspect_mismatch":
"image 자연 비율과 렌더 비율 mismatch → frame 내부 image fit (object-fit / "
"max-w/h) 로 envelope 안에서 비율 회복. 공통 image CSS 변경 X (frame-scoped).",
"frame_capacity_mismatch": "frame_capacity_mismatch":
"composition capacity_fit 가 이미 mismatch 신호 → V4 top-k 의 다른 frame 평가", "composition capacity_fit 가 이미 mismatch 신호 → frame contract envelope "
"안 internal fit 변형 (density / line rhythm / row 배치) 우선. "
"frame swap 은 cascade 다음 단계 (rerender_still_fails → frame_reselect).",
"layout_zone_mismatch": "layout_zone_mismatch":
"frame root 자체 overflow → layout preset 변경 또는 zone 키움", "frame root 자체 overflow → layout preset 변경 또는 zone 키움",
"hard_visual_fail": "hard_visual_fail":
@@ -61,7 +80,21 @@ ACTION_RATIONALE: dict[str, str] = {
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용 # A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = { ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration "zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
"layout_adjust": "MISSING", # IMP-88 (#88) u1→u7 (2026-05-24): three Step 17 retry actions registered
# here. u1 added the data-surface rows (initial state MISSING). u3/u4/u5
# landed the deterministic planners in src/phase_z2_retry.py. u6 wired the
# salvage dispatcher (_attempt_salvage_chain), and u7 wired the Step 17
# entry runtime (_attempt_step17_image_fit_single_pass + §11.7.1/§11.7.2).
# Status flips MISSING→IMPLEMENTED land here on u7 completion — once the
# end-to-end path (planner + apply + dispatcher + entry) is wired the
# action is IMPLEMENTED on the deterministic surface. (Same convention as
# IMP-12 u7 cascade rows below: planner-surface availability + orchestrator
# wiring together constitute IMPLEMENTED; route_action's
# implementation_status field reflects surface availability, not whether a
# given pipeline run has invoked the action.)
"layout_adjust": "IMPLEMENTED", # u3 plan_layout_adjust + u6 dispatcher branch + u7 cascade entry
"image_fit": "IMPLEMENTED", # u4 plan_image_fit + u7 _attempt_step17_image_fit_single_pass entry
"frame_internal_fit_candidate": "IMPLEMENTED", # u5 plan_frame_internal_fit_candidate + u6 dispatcher branch + u7 cascade entry
# IMP-35 (#64) u3 — MISSING → IMPLEMENTED on the primary router surface. # IMP-35 (#64) u3 — MISSING → IMPLEMENTED on the primary router surface.
# `plan_details_popup_escalation` (below) provides the deterministic stub # `plan_details_popup_escalation` (below) provides the deterministic stub
# that downstream units consume: u4 binds the AI split-decision contract # that downstream units consume: u4 binds the AI split-decision contract
+137
View File
@@ -0,0 +1,137 @@
"""IMP-94 (#94) u1 — region/content marker stamper for Phase Z final.html.
Annotates each rendered family-partial root ``<div>`` with stable
``data-region-id="..."`` and ``data-content-unit-id="..."`` attributes so
downstream Layer A telemetry (placement_trace ↔ DOM parity, Step 21 self-
report, fit_classifier read targets §6.4) can resolve a rendered zone
back to its PlacementPlan ``slot_assignments[]`` entry.
DOM contract (single point of truth — mirrored verbatim across the axis) ::
<div class="..." data-region-id="{region_id}" data-content-unit-id="{cuid}" ...
data-frame-id="..." data-template-id="...">
The anchor is the uniform root-div emitted by every Phase Z family
partial under ``templates/phase_z2/families/`` (13 partials, evidence
confirmed via ``grep -l data-template-id`` = 13/13). All 13 partials
carry the pattern::
<div class="<fNb>" data-frame-id="..." data-template-id="<family>">
The stamper finds the FIRST such opening tag with a permissive regex
and injects ``data-region-id`` + ``data-content-unit-id`` as new
attributes. Existing attributes (class, data-frame-id, data-template-id,
etc.) are preserved verbatim. The injection is idempotent — a zone that
already carries ``data-region-id`` on its root div is left alone.
Source of marker values : ``PlacementPlan.slot_assignments[].region_id``
and ``.content_unit_id`` (see ``src/phase_z2_placement_planner.py``
L253-258). u3 wires the live B4 path; u4 ensures non-live append paths
default to ``placement_markers=[]`` so this stamper safely no-ops.
Forward-compat / safety :
- Empty / None ``markers`` → passthrough (returns ``zone_html`` unchanged).
- Non-str / empty ``zone_html`` → passthrough.
- Re-stamping (idempotent) preserves the first stamp.
- Only the FIRST data-template-id root div is stamped (one per zone).
- Markers with empty / missing ``region_id`` AND ``content_unit_id`` →
passthrough (no attribute injection).
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u1) :
- AI-isolation : pure deterministic Python; no LLM calls.
- Additive only : never edits / removes existing attributes.
- Idempotent : ``data-region-id`` probe short-circuits before re-inject.
- Disjoint from #96 (``data-frame-slot-id`` is a separate axis / attr).
"""
from __future__ import annotations
import re
from typing import Any, Iterable, Mapping
REGION_ID_ATTR: str = "data-region-id"
CONTENT_UNIT_ID_ATTR: str = "data-content-unit-id"
# Matches the FIRST ``<div ... data-template-id="...">`` opening tag.
# Group 1 captures the inner attribute string verbatim (incl. leading
# whitespace) so the rewriter can re-emit it unchanged after injection.
_ROOT_DIV_TAG_RE = re.compile(
r'<div\b((?=[^>]*\bdata-template-id\s*=\s*"[^"]+")[^>]*?)>',
flags=re.IGNORECASE | re.DOTALL,
)
# Probe for an existing ``data-region-id`` attribute (any value, any
# quote) so re-stamping is idempotent.
_HAS_REGION_ID_RE = re.compile(r"""\bdata-region-id\s*=""", flags=re.IGNORECASE)
def _coerce_marker_value(value: Any) -> str:
"""Return a safe attribute-value string for ``value``.
Non-str / None → ''. Strings are returned verbatim (caller responsible
for not embedding ``"`` since marker ids derive from
PlacementPlan.slot_assignments which are deterministic identifiers).
"""
if value is None:
return ""
if not isinstance(value, str):
return ""
return value
def stamp_zone_html(
zone_html: str,
markers: Iterable[Mapping[str, Any]] | None,
) -> str:
"""Stamp the root family-partial ``<div>`` with region / content-unit ids.
``markers`` is an iterable of mapping objects shaped as ::
{
"region_id": "<region_id>",
"content_unit_id": "<content_unit_id>",
# optional, ignored here — reserved for #96 (89-d):
"frame_slot_id": "<frame_slot_id>",
}
Only ``markers[0]`` is consumed (one root div per zone). Excess
markers are reserved for a future per-slot stamper (#96) and are
silently ignored by this module.
Returns ``zone_html`` unchanged when:
- ``zone_html`` is not a non-empty string,
- ``markers`` is None / empty,
- no ``data-template-id`` root div is found,
- the root div already carries ``data-region-id`` (idempotent),
- the first marker carries neither ``region_id`` nor ``content_unit_id``.
"""
if not isinstance(zone_html, str) or not zone_html:
return zone_html
if markers is None:
return zone_html
marker_list = list(markers)
if not marker_list:
return zone_html
first = marker_list[0]
if not isinstance(first, Mapping):
return zone_html
region_id = _coerce_marker_value(first.get("region_id"))
content_unit_id = _coerce_marker_value(first.get("content_unit_id"))
if not region_id and not content_unit_id:
return zone_html
stamped = {"done": False}
def _replace(match: re.Match[str]) -> str:
if stamped["done"]:
return match.group(0)
attrs = match.group(1) or ""
if _HAS_REGION_ID_RE.search(attrs):
stamped["done"] = True
return match.group(0)
stamped["done"] = True
injected = (
f' {REGION_ID_ATTR}="{region_id}"'
f' {CONTENT_UNIT_ID_ATTR}="{content_unit_id}"'
)
return f"<div{injected}{attrs}>"
return _ROOT_DIV_TAG_RE.sub(_replace, zone_html, count=1)
+87
View File
@@ -0,0 +1,87 @@
"""IMP-45 (#74) u3 — slide-level CSS override injector for Phase Z final.html.
Mirror of :func:`src.image_id_stamper.inject_image_overrides_style` contract
(image_id_stamper.py:226-264) for the new ``slide_css`` override axis
registered by u1 in :data:`src.user_overrides_io.KNOWN_AXES` and surfaced
by u2 in :func:`src.mdx_normalizer.normalize_mdx_content` under the
``slide_overrides.css`` frontmatter key.
Single entry point :
:func:`inject_slide_css` (html, css) -> str
Semantics (identical contract to image_overrides injector) :
- Empty / falsy ``css`` -> ``html`` returned unchanged (no DOM mutation).
- Marker-wrapped ``<style>`` block; re-injection replaces inner CSS in
place (idempotent on identical input; latest-wins on different input).
- Injection precedence : (1) before 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 fragment inputs.
Marker sentinels (distinct from image_overrides markers so the two
injectors can co-exist on the same document without collision; the
literal form is pinned by the Stage 2 binding contract for IMP-45 /
issue #74) :
<!--IMP45-SLIDE-CSS:OPEN-->
<!--IMP45-SLIDE-CSS:CLOSE-->
Both injectors target ``</head>`` first, so call order determines DOM
order. u4 calls ``inject_image_overrides_style`` first (existing Step 13
behavior) and then ``inject_slide_css``, putting slide-level overrides
after image overrides in cascade order so the editor-authored slide CSS
wins ties at the same specificity (intended by IMP-45 scope).
Guardrails :
- No-hardcoding : ``css`` is caller-supplied verbatim. No sample-id or
frame-id branches.
- AI-isolation : 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 re
_IMP45_STYLE_MARKER_OPEN: str = "<!--IMP45-SLIDE-CSS:OPEN-->"
_IMP45_STYLE_MARKER_CLOSE: str = "<!--IMP45-SLIDE-CSS:CLOSE-->"
_IMP45_STYLE_BLOCK_RE = re.compile(
re.escape(_IMP45_STYLE_MARKER_OPEN) + r".*?" + re.escape(_IMP45_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 inject_slide_css(html: str, css: str | None) -> str:
"""Inject a marker-wrapped ``<style>`` block carrying ``css`` into ``html``.
Empty or ``None`` ``css`` -> ``html`` returned unchanged. Re-injection
is idempotent : when a previously-injected marker block is present,
its inner CSS is replaced in place.
Injection precedence : ``</head>`` > ``<body ...>`` > document start.
"""
if not css:
return html
block = (
f"{_IMP45_STYLE_MARKER_OPEN}\n"
f"<style>\n{css}\n</style>\n"
f"{_IMP45_STYLE_MARKER_CLOSE}"
)
if _IMP45_STYLE_MARKER_OPEN in html:
return _IMP45_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
+189
View File
@@ -0,0 +1,189 @@
"""IMP-56 (#90) u6 — structure_override resolver (validator + apply).
Step-22 user structure-edit persist axis. Consumed by Step 12 (u7 wiring)
so a prior render's reorder / hide choices re-apply to the next render
without re-clicking.
Schema (defined verbatim in ``src/user_overrides_io.py:30`` u2) ::
structure_overrides = {
<zone_id>: {
"slot_order": [<slot_key>, ...], # optional, partial reorder
"hidden_slots": [<slot_key>, ...], # optional, hide these slot_keys
},
...
}
SCOPE LOCK (Stage 2 u6 contract, IMP-56 #90 u2 docstring) :
The only allowed inner keys are ``slot_order`` and ``hidden_slots``.
Any other key (e.g., ``frame_id``, ``template_id``, ``unit_id``,
``slot_payload``) is treated as a frame-swap / DOM-rebuild attempt and
is DROPPED at validate time. Frame swap stays on the existing
``frames`` axis so the Phase Z no-AI-HTML-structure invariant remains
intact. There is intentionally NO escape hatch through this axis.
API (deterministic, no AI) :
- ``validate_structure_overrides(overrides)`` → sanitized copy. Per-entry
tolerant (drops malformed rows; never rejects the whole batch — mirrors
``src.text_override_resolver.validate_text_overrides`` u4 contract).
- ``apply_structure_override(zone, override)`` → ``True`` if the slot-payload
mapping was mutated (any hide or any reorder), ``False`` otherwise. The
``zone`` argument is the slot-payload mapping at Step 12 (a mutable
mapping whose keys are slot_keys and whose values are typically
``list[str]`` of lines). Identity-preserving: mutates in-place via
``clear`` + ``update`` so caller references remain valid.
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u6) :
- raw_content preservation is a wiring-layer (u7) responsibility — the
resolver only ever reorders / removes top-level slot_payload entries.
Per-slot ``list[str]`` line content is never inspected or mutated here.
- AI-isolation : 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
from typing import Any, Mapping, MutableMapping
class InvalidStructureOverride(ValueError):
"""Reserved for future strict-mode parse errors.
Currently unused — the resolver follows the u4 per-entry-tolerant
contract and silently drops malformed rows at validate time rather
than raising. Kept as a public surface so u7 wiring (and future
strict-mode callers) can distinguish source-malformation from
stale-DOM misses without an API rev.
"""
_ALLOWED_INNER_KEYS: frozenset[str] = frozenset({"slot_order", "hidden_slots"})
def _sanitize_slot_list(raw: Any) -> list[str]:
"""Return a fresh list of non-empty string slot_keys (drop the rest)."""
if not isinstance(raw, list):
return []
out: list[str] = []
seen: set[str] = set()
for slot in raw:
if not isinstance(slot, str) or not slot:
continue
if slot in seen:
# De-dup defensively — a duplicate slot_key in slot_order would
# be meaningless (dicts can hold each key once); duplicate in
# hidden_slots is redundant. Drop subsequent occurrences.
continue
seen.add(slot)
out.append(slot)
return out
def validate_structure_overrides(
overrides: Any,
) -> dict[str, dict[str, list[str]]]:
"""Return a sanitized copy of ``overrides`` (per-entry tolerant).
Drops:
- non-string or empty zone_ids,
- non-mapping per-zone payloads,
- per-zone inner keys other than ``slot_order`` / ``hidden_slots``
(frame-swap attempts are dropped at this gate — see SCOPE LOCK),
- non-list ``slot_order`` / ``hidden_slots`` values,
- non-string or empty slot_key entries within those lists,
- per-zone payloads that contain neither a non-empty ``slot_order``
nor a non-empty ``hidden_slots`` after sanitization (empty intent
carries no signal).
Returns a fresh ``dict`` AND fresh nested dicts / lists so callers can
use the result as a working buffer without aliasing the persisted
payload from ``user_overrides_io.load``.
"""
if not isinstance(overrides, Mapping):
return {}
out: dict[str, dict[str, list[str]]] = {}
for zone_id, mapping in overrides.items():
if not isinstance(zone_id, str) or not zone_id:
continue
if not isinstance(mapping, Mapping):
continue
zone_out: dict[str, list[str]] = {}
for inner_key, inner_value in mapping.items():
if inner_key not in _ALLOWED_INNER_KEYS:
# Frame-swap attempt or unknown key — drop silently per
# SCOPE LOCK. No mechanism through this axis.
continue
sanitized = _sanitize_slot_list(inner_value)
if sanitized:
zone_out[inner_key] = sanitized
if zone_out:
out[zone_id] = zone_out
return out
def apply_structure_override(
zone: MutableMapping[str, Any],
override: Mapping[str, Any],
) -> bool:
"""Apply ONE structure override to ``zone`` in-place.
``zone`` is the slot-payload mapping at Step 12 — i.e. a mutable
mapping whose keys are slot_keys and whose values are the per-slot
line lists (or other content payload). Mutation is restricted to
top-level key membership + ordering; per-slot values are NEVER
inspected or modified here.
``override`` is the per-zone payload after :func:`validate_structure_overrides`
sanitization — i.e. a mapping with only ``slot_order`` and / or
``hidden_slots`` keys, each holding a list of non-empty str slot_keys.
This function is also defensive: if non-list values leak through, they
are treated as empty (no raise).
Semantics :
1. ``hidden_slots`` are popped first. Entries absent from ``zone``
are silently skipped (stale slot_keys from a prior frame).
2. ``slot_order`` partially reorders the surviving slot_keys:
listed keys (that are present in ``zone``) move to the front in
the given order; remaining keys keep their original relative
order at the tail. Unknown slot_keys are silently skipped.
Returns ``True`` if the zone's slot-payload mapping was mutated (any
hide that removed a key OR any reorder that changed key order),
``False`` otherwise. Identity-preserving: rebuilds via
``clear`` + ``update`` so the caller's reference to ``zone`` remains
valid.
"""
mutated = False
raw_hidden = override.get("hidden_slots") if isinstance(override, Mapping) else None
hidden = _sanitize_slot_list(raw_hidden)
for slot in hidden:
if slot in zone:
del zone[slot]
mutated = True
raw_order = override.get("slot_order") if isinstance(override, Mapping) else None
desired_order_seed = _sanitize_slot_list(raw_order)
current_order = list(zone.keys())
desired_order: list[str] = []
seen: set[str] = set()
for slot in desired_order_seed:
if slot in zone and slot not in seen:
desired_order.append(slot)
seen.add(slot)
for slot in current_order:
if slot not in seen:
desired_order.append(slot)
seen.add(slot)
if desired_order != current_order:
snapshot = {k: zone[k] for k in desired_order}
zone.clear()
zone.update(snapshot)
mutated = True
return mutated
+143
View File
@@ -0,0 +1,143 @@
"""IMP-56 (#90) u4 — text_override resolver (validator + apply).
Step-22 user text-edit persist axis. Consumed by Step 12 (u5 wiring) so a
prior render's text edits re-apply to the next render without re-clicking.
Schema (defined verbatim in ``src/user_overrides_io.py:29`` u1) ::
text_overrides = {
<zone_id>: {<text_path>: <value: str>},
...
}
``text_path`` is the ``{slot_key}.{line_index}`` stamp emitted at Step 13
by the u8 ``text_path_stamper`` (pending unit) and surfaced to the frontend
SlideCanvas (u12) as ``data-text-path`` attributes on editable text nodes.
The ``{slot_key}`` is a frame contract slot identifier (e.g.,
``slot_title``); the ``{line_index}`` is the 0-based ordinal of the line
within that slot's rendered text (typically one bullet / one paragraph).
API (deterministic, no AI) :
- ``parse_text_path(text_path)`` → ``(slot_key, line_index)`` or raises.
- ``validate_text_overrides(overrides)`` → sanitized copy (drops malformed
per-entry; never rejects the whole batch — mirrors the per-entry
tolerance contract of ``src.image_id_stamper.build_image_overrides_style``
IMP-51 #79 u7).
- ``apply_text_override(zone, text_path, value)`` → ``True`` on in-place
mutation; ``False`` if the path is absent / out-of-range. The ``zone``
argument is the slot-lines mapping at Step 12 — i.e. a mutable mapping
where ``zone[slot_key]`` is a ``list[str]`` of line strings. Wiring at
Step 12 (u5) is responsible for extracting that mapping from whatever
composition object holds it; this resolver is decoupled from the wrapper
shape so it can be re-targeted at Stage 5 (Step 12) layer-A or layer-B
composition data without an API rev.
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u4) :
- raw_content preservation is a wiring-layer (u5) responsibility — the
resolver itself only ever mutates the lines mapping it was handed.
- AI-isolation : 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
from typing import Any, Mapping, MutableMapping
class InvalidTextOverride(ValueError):
"""Raised when a ``text_path`` is malformed (parse-time)."""
def parse_text_path(text_path: str) -> tuple[str, int]:
"""Parse ``{slot_key}.{line_index}`` into ``(slot_key, line_index)``.
``slot_key`` may itself contain ``.`` (e.g., compound keys), so the
parse splits on the LAST ``.`` only — ``rpartition`` semantics.
"""
if not isinstance(text_path, str) or not text_path:
raise InvalidTextOverride(
f"text_path must be a non-empty string, got: {text_path!r}"
)
if "." not in text_path:
raise InvalidTextOverride(
f"text_path must contain '.' separator, got: {text_path!r}"
)
slot_key, _, idx_str = text_path.rpartition(".")
if not slot_key or not idx_str:
raise InvalidTextOverride(
f"text_path slot_key and line_index must both be non-empty, "
f"got: {text_path!r}"
)
try:
idx = int(idx_str)
except ValueError as exc:
raise InvalidTextOverride(
f"text_path line_index must be int, got: {text_path!r}"
) from exc
if idx < 0:
raise InvalidTextOverride(
f"text_path line_index must be >= 0, got: {idx} in {text_path!r}"
)
return slot_key, idx
def validate_text_overrides(overrides: Any) -> dict[str, dict[str, str]]:
"""Return a sanitized copy of ``overrides`` (per-entry tolerant).
Drops:
- non-string or empty zone_ids,
- non-mapping per-zone payloads,
- non-string text_path keys, non-string values,
- text_paths that fail :func:`parse_text_path`.
Returns a fresh ``dict`` so callers can mutate without aliasing the
persisted payload from ``user_overrides_io.load``.
"""
if not isinstance(overrides, Mapping):
return {}
out: dict[str, dict[str, str]] = {}
for zone_id, mapping in overrides.items():
if not isinstance(zone_id, str) or not zone_id:
continue
if not isinstance(mapping, Mapping):
continue
zone_out: dict[str, str] = {}
for text_path, value in mapping.items():
if not isinstance(text_path, str) or not isinstance(value, str):
continue
try:
parse_text_path(text_path)
except InvalidTextOverride:
continue
zone_out[text_path] = value
if zone_out:
out[zone_id] = zone_out
return out
def apply_text_override(
zone: MutableMapping[str, Any],
text_path: str,
value: str,
) -> bool:
"""Apply ONE text override to ``zone`` in-place.
``zone`` is the slot-lines mapping at Step 12 — i.e. a mutable mapping
where ``zone[slot_key]`` is a ``list[str]`` of line strings.
Returns ``True`` when the value was replaced. Returns ``False`` (no
mutation) when the ``slot_key`` is absent, the slot is not a list, or
``line_index`` is out of range. Out-of-range / absent paths are NOT an
error — they happen naturally when a prior render's overrides target a
slot the new render no longer emits (frame swap, layout regression).
"""
slot_key, idx = parse_text_path(text_path)
if slot_key not in zone:
return False
lines = zone[slot_key]
if not isinstance(lines, list) or idx >= len(lines):
return False
lines[idx] = value
return True
+155
View File
@@ -0,0 +1,155 @@
"""IMP-56 (#90) u8 — text_path stamper for Phase Z final.html.
Annotates rendered ``text-line`` DOM elements with a stable
``data-text-path="{slot_key}.{line_index}"`` attribute so the frontend
SlideCanvas (u10~u12) can attribute per-line edits back to the
``text_overrides`` axis (u1 schema, u4 resolver, u5 Step-12 apply).
DOM contract (single point of truth — mirrored verbatim across the axis) ::
.text-line[data-text-path="{slot_key}.{line_index}"]
The ``{slot_key}.{line_index}`` grammar matches
:func:`src.text_override_resolver.parse_text_path` verbatim (split on LAST
``.`` — compound slot keys with embedded dots are supported).
The text-line element format is emitted by every Phase Z family / frame
template (e.g. ``templates/phase_z2/families/bim_current_problems_paired.html``
line 143)::
<div class="text-line[ ...modifier classes...]">{{ line.text | safe }}</div>
The stamper finds each ``text-line`` opening tag with a permissive regex
and injects ``data-text-path="..."`` as the FIRST attribute. Existing
attributes (class, etc.) are preserved verbatim. The injection is
idempotent — a previously stamped element is left alone.
Stamping order : the stamper iterates ``slot_payload`` in dict-iteration
order and yields one stamp per ``list`` entry. The DOM walk consumes
stamps in left-to-right order; templates currently emit slot lines in
the same order they appear in ``slot_payload`` so the alignment holds.
If a future template diverges, u9 wiring can pre-build the desired
``(slot_key, line_index)`` sequence and pass it explicitly through the
``stamps`` arg of :func:`stamp_zone_html`.
Forward-compat / safety :
- Scalar (non-list) slot values are silently skipped — they render
outside ``text-line`` divs (frame title, pill labels, etc.) and are
not addressable via the line-index grammar.
- Excess ``text-line`` elements beyond ``sum(len(v) for v in
slot_payload.values() if isinstance(v, list))`` are left unstamped.
- Re-stamping (idempotent) preserves the first stamp.
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u8) :
- AI-isolation : pure deterministic Python; no LLM calls.
- Carve-out (IMP-46 #62) : brand-new module; does not touch the #76
commit ``1186ad8`` cache region.
- Idempotent : ``data-text-path`` probe short-circuits before re-inject.
- u9 wiring (separate unit) is the only consumer; this module emits no
artifacts and reads no global state.
"""
from __future__ import annotations
import re
from typing import Any, Iterable, Iterator, Mapping
TEXT_PATH_ATTR: str = "data-text-path"
# Matches a ``<div ... class="... text-line ..." ...>`` opening tag.
# Group 1 captures the inner attribute string verbatim (incl. leading
# whitespace) so the rewriter can re-emit it unchanged after injection.
_TEXT_LINE_TAG_RE = re.compile(
r'<div\b((?=[^>]*\bclass\s*=\s*"[^"]*\btext-line\b)[^>]*?)>',
flags=re.IGNORECASE | re.DOTALL,
)
# Probe for an existing ``data-text-path`` attribute (any value, any
# quote) so re-stamping is idempotent.
_HAS_TEXT_PATH_RE = re.compile(r"""\bdata-text-path\s*=""", flags=re.IGNORECASE)
def build_text_path(slot_key: str, line_index: int) -> str:
"""Return the canonical ``{slot_key}.{line_index}`` text_path string.
Mirrors the inverse of :func:`src.text_override_resolver.parse_text_path`
(last-dot split). ``slot_key`` may itself contain ``.`` (compound keys).
"""
if not isinstance(slot_key, str) or not slot_key:
raise ValueError(
f"slot_key must be a non-empty string, got: {slot_key!r}"
)
if isinstance(line_index, bool) or not isinstance(line_index, int):
raise ValueError(
f"line_index must be a non-negative int, got: {line_index!r}"
)
if line_index < 0:
raise ValueError(
f"line_index must be a non-negative int, got: {line_index!r}"
)
return f"{slot_key}.{line_index}"
def iter_zone_stamps(
slot_payload: Mapping[str, Any],
) -> Iterator[tuple[str, int]]:
"""Yield ``(slot_key, line_index)`` for every list-valued slot line.
Iteration order matches ``slot_payload`` dict iteration order. Non-
string / empty slot_keys are skipped. Non-list values are skipped
(scalar slots render outside ``text-line`` divs).
"""
if not isinstance(slot_payload, Mapping):
return
for slot_key, value in slot_payload.items():
if not isinstance(slot_key, str) or not slot_key:
continue
if not isinstance(value, list):
continue
for line_index in range(len(value)):
yield slot_key, line_index
def stamp_zone_html(
zone_html: str,
slot_payload_or_stamps: Mapping[str, Any] | Iterable[tuple[str, int]],
) -> str:
"""Stamp ``text-line`` opening tags in ``zone_html`` with ``data-text-path``.
The second arg accepts either:
- a ``slot_payload`` ``Mapping`` (uses :func:`iter_zone_stamps` order), or
- an iterable of pre-built ``(slot_key, line_index)`` tuples.
Stamps are consumed in left-to-right DOM order. A text-line already
carrying ``data-text-path`` is left unchanged (idempotent). Excess
text-line elements beyond the stamp sequence are also left unchanged.
Returns ``zone_html`` unchanged when there are no stamps to apply or
the input is not a non-empty string.
"""
if not isinstance(zone_html, str) or not zone_html:
return zone_html
if isinstance(slot_payload_or_stamps, Mapping):
stamps = list(iter_zone_stamps(slot_payload_or_stamps))
else:
stamps = [
(sk, li)
for (sk, li) in slot_payload_or_stamps
if isinstance(sk, str) and sk and isinstance(li, int)
and not isinstance(li, bool) and li >= 0
]
if not stamps:
return zone_html
counter = {"i": 0}
def _replace(match: re.Match[str]) -> str:
attrs = match.group(1) or ""
if _HAS_TEXT_PATH_RE.search(attrs):
return match.group(0)
i = counter["i"]
if i >= len(stamps):
return match.group(0)
counter["i"] = i + 1
slot_key, line_index = stamps[i]
path = build_text_path(slot_key, line_index)
return f'<div {TEXT_PATH_ATTR}="{path}"{attrs}>'
return _TEXT_LINE_TAG_RE.sub(_replace, zone_html)
+36 -6
View File
@@ -5,14 +5,29 @@ 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 file (stem of the MDX path), NOT ``data/runs/<run_id>/`` which mints a fresh
run_id per ``/api/run`` invocation. run_id per ``/api/run`` invocation.
Schema (5 axes; stable order; IMP-51 #79 u1 added ``image_overrides``): Schema (9 axes; stable order; IMP-51 #79 u1 added ``image_overrides``;
IMP-45 #74 u1 added ``slide_css``; IMP-55 #93 u1 added
``manual_section_assignment`` as a bool intent marker so the backend can
distinguish a user drag-drop from frontend auto-carry zone_sections;
IMP-56 #90 u1 added ``text_overrides`` as a Step-22 text-edit persist axis
keyed by ``{zone_id: {text_path: value}}`` where ``text_path`` is the
``{slot_key}.{line_index}`` stamp emitted by u8; IMP-56 #90 u2 added
``structure_overrides`` as a Step-22 structure-edit persist axis keyed by
``{zone_id: {"slot_order": [<slot_key>, ...], "hidden_slots": [<slot_key>, ...]}}``
— scope is intentionally LOCKED to slot reorder + hide; frame swap stays
on the existing ``frames`` axis to prevent the Phase Z regression of
AI-driven HTML structure mutation):
{ {
"layout": <string|null>, "layout": <string|null>,
"zone_geometries": {<zone_id>: {"x": float, "y": float, "w": float, "h": float}}, "zone_geometries": {<zone_id>: {"x": float, "y": float, "w": float, "h": float}},
"zone_sections": {<zone_id>: [<section_id>, ...]}, "zone_sections": {<zone_id>: [<section_id>, ...]},
"frames": {<unit_id>: <template_id>}, "frames": {<unit_id>: <template_id>},
"image_overrides": {<image_id>: {"x": float, "y": float, "w": float, "h": float}} "image_overrides": {<image_id>: {"x": float, "y": float, "w": float, "h": float}},
"slide_css": <string|null>,
"manual_section_assignment": <bool>,
"text_overrides": {<zone_id>: {<text_path>: <string>}},
"structure_overrides": {<zone_id>: {"slot_order": [<slot_key>, ...], "hidden_slots": [<slot_key>, ...]}}
} }
``image_id`` is the stable identifier emitted by the user-content image ``image_id`` is the stable identifier emitted by the user-content image
@@ -53,16 +68,31 @@ from typing import Any, Optional
_PKG_ROOT = Path(__file__).resolve().parent.parent _PKG_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides" DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
# The five in-scope axes (IMP-51 #79 u1 added ``image_overrides``). Any # The nine in-scope axes (IMP-51 #79 u1 added ``image_overrides``; IMP-45
# other top-level key in the file is preserved but ignored by callers — # #74 u1 added ``slide_css``; IMP-55 #93 u1 added
# keeps the file forward-compatible with future axes (e.g., zone_sizes) # ``manual_section_assignment`` — bool intent marker that gates whether
# without a schema bump here. # persisted ``zone_sections`` are consumed by the backend pipeline; IMP-56
# #90 u1 added ``text_overrides`` — Step-22 text-edit persist axis keyed by
# ``{zone_id: {text_path: value}}`` where ``text_path`` is the
# ``{slot_key}.{line_index}`` stamp emitted by u8 / consumed by u4+u5;
# IMP-56 #90 u2 added ``structure_overrides`` — Step-22 structure-edit
# persist axis keyed by ``{zone_id: {"slot_order": [...], "hidden_slots":
# [...]}}``, scope LOCKED to slot reorder + hide so the resolver (u6) /
# Step-12 apply (u7) cannot mutate frame identity — frame swap stays on
# the existing ``frames`` axis to keep Phase Z's no-AI-HTML-structure
# invariant intact). 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, ...] = ( KNOWN_AXES: tuple[str, ...] = (
"layout", "layout",
"zone_geometries", "zone_geometries",
"zone_sections", "zone_sections",
"frames", "frames",
"image_overrides", "image_overrides",
"slide_css",
"manual_section_assignment",
"text_overrides",
"structure_overrides",
) )
# Key validation — MDX stem must be safe for filesystem use. Allow # Key validation — MDX stem must be safe for filesystem use. Allow
@@ -474,7 +474,9 @@ bim_dx_comparison_table:
builder_options: builder_options:
item_parser: compare_row_2col_item # NEW parser — top_bullet → {label, col_a, col_b} item_parser: compare_row_2col_item # NEW parser — top_bullet → {label, col_a, col_b}
col_a_label_default: "BIM" # F1-a (Codex round 43) — explicit default col_a_label_default: "BIM" # F1-a (Codex round 43) — explicit default
col_a_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
col_b_label_default: "DX" # F1-a — explicit default col_b_label_default: "DX" # F1-a — explicit default
col_b_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
strip_col_prefix_aliases: # F1-b (Codex round 43) — narrow alias 만 strip strip_col_prefix_aliases: # F1-b (Codex round 43) — narrow alias 만 strip
- "BIM" - "BIM"
- "DX" - "DX"
@@ -1785,8 +1787,11 @@ industry_current_status_three_col:
builder_options: builder_options:
item_parser: compare_row_3col_item # NEW parser placeholder — top_bullet → {label, col_a, col_b, col_c}. Peer parity with compare_row_2col_item. item_parser: compare_row_3col_item # NEW parser placeholder — top_bullet → {label, col_a, col_b, col_c}. Peer parity with compare_row_2col_item.
col_a_label_default: "제조업" # F30 source column 1 label (analysis.md three_industries anchor set). col_a_label_default: "제조업" # F30 source column 1 label (analysis.md three_industries anchor set).
col_a_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
col_b_label_default: "건축" # F30 source column 2 label. col_b_label_default: "건축" # F30 source column 2 label.
col_b_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
col_c_label_default: "토목" # F30 source column 3 label (강조 테두리 빨간색 cue — visual styling). col_c_label_default: "토목" # F30 source column 3 label (강조 테두리 빨간색 cue — visual styling).
col_c_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
max_rows: 12 # typical 4-6, overflow 보호 (peer parity with compare_table_2col max_rows 12). max_rows: 12 # typical 4-6, overflow 보호 (peer parity with compare_table_2col max_rows 12).
@@ -1847,6 +1852,9 @@ industry_characteristics_three_col:
builder_options: builder_options:
item_parser: compare_row_3col_item # Shared parser with industry_current_status_three_col (F30) — top_bullet → {label, col_a, col_b, col_c}. Peer parity with compare_row_2col_item. item_parser: compare_row_3col_item # Shared parser with industry_current_status_three_col (F30) — top_bullet → {label, col_a, col_b, col_c}. Peer parity with compare_row_2col_item.
col_a_label_default: "제조업" # F31 source column 1 label (analysis.md three_industries anchor set; shared with F30). col_a_label_default: "제조업" # F31 source column 1 label (analysis.md three_industries anchor set; shared with F30).
col_a_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
col_b_label_default: "건축" # F31 source column 2 label (shared with F30). col_b_label_default: "건축" # F31 source column 2 label (shared with F30).
col_b_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
col_c_label_default: "토목" # F31 source column 3 label (강조 테두리 빨간색 cue — visual styling; shared with F30). col_c_label_default: "토목" # F31 source column 3 label (강조 테두리 빨간색 cue — visual styling; shared with F30).
col_c_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
max_rows: 12 # typical 3-4 (F31 compressed view), overflow 보호 (peer parity with compare_table_2col + F30 compare_table_3col max_rows 12). max_rows: 12 # typical 3-4 (F31 compressed view), overflow 보호 (peer parity with compare_table_2col + F30 compare_table_3col max_rows 12).
@@ -0,0 +1,50 @@
# IMP-39 single-source ranking sort policy — backend ↔ frontend mirror.
#
# 도입 배경 (issue #68):
# Backend `lookup_v4_match_with_fallback` 는 V4 raw confidence-desc 순서로
# first-eligible 선택 (label_priority 무시). Frontend `designAgentApi.ts` 는
# 동일 source 를 (label_priority asc, confidence desc) 로 재정렬 후 slice.
# 결과: 낮은-confidence 높은-priority label 이 raw 상 뒤에 있을 때
# backend "rank 1 selected" ≠ frontend `frame_candidates[0]` divergence.
#
# 정책 결정 (Stage 1~2 LOCK, 4 round 합의):
# - 단일 source 위치 = 본 yaml (catalog hot-reload + frontend mirror 가능)
# - frame_contracts.yaml / v4_fallback_policy.yaml 오염 회피 (분리 파일)
# - 정렬 axes = (label_priority asc, confidence desc, v4_rank asc)
# - tie-break = 원본 v4_rank 보존 (frontend LABEL_PRIORITY 와 1:1)
#
# 적용 path:
# - backend: src/phase_z2_pipeline.py `apply_ranking_sort` (helper, u1)
# + `lookup_v4_match_with_fallback` selector loop (u2)
# + `_build_application_plan_unit` Step 9 payload (u3)
# - frontend: Front/client/src/services/designAgentApi.ts (u4)
# → unit.ranking_sort_policy + unit.sorted_candidate_evidence 우선 read
# → local LABEL_PRIORITY 는 warn-fallback only
policy_type: deterministic_label_priority_then_confidence
# label_priority:
# lower value = higher priority (use_as_is 가 첫 후보)
# sort key = (label_priority asc, confidence desc, v4_rank asc)
label_priority:
use_as_is: 0
light_edit: 1
restructure: 2
reject: 3
# unknown_label_priority:
# label 이 위 매트릭스에 없을 시 부여되는 우선순위 (최하위 push).
# frontend `LABEL_PRIORITY[label] ?? 99` 와 1:1.
unknown_label_priority: 99
# tie_break_axes:
# 동일 label_priority 시 적용 순서 — frontend mirror 와 1:1.
# confidence_desc: 큰 confidence 가 앞
# v4_rank_asc: 동일 confidence 시 raw v4 rank (1, 2, 3 ...) 작은 게 앞
tie_break_axes:
- confidence_desc
- v4_rank_asc
# graceful fallback (yaml 없을 시):
# loader 가 default policy_type=deterministic_label_priority_then_confidence
# + 위 label_priority 매트릭스 로 fall through (backward compat / boot-safe).
@@ -0,0 +1,242 @@
<!-- Phase Z-2 MVP-1.5b frame-derived adapted block. -->
{#
─────────────────────────────────────────────────────────────────────────────
Visual Provenance — figma_to_html_agent/blocks/1171281203/ (frame 23)
─────────────────────────────────────────────────────────────────────────────
Frame 23 = "Application S/W 의 구분".
구조 (figma 1:1) :
① title gradient (Application S/W = orange→brown, 의 구분 = green→black)
② CSS Grid table (label col + col_a + col_b)
③ header row :
- col 1 (label) = teal #589e8d
- col 2 (Package) = teal #589e8d
- col 3 (Solution) = orange #ef7a26
④ N data rows : zebra (odd 흰 0.85 / even peach rgba(253,198,158,0.2))
- 각 row : 좌측 label cell + 좌 data + 우 data
- cell border : 1.5px solid #888
⑤ bullet (•) + accent (.hl orange #a14101, .big 50px 강조)
slots (bim_dx_comparison_table 와 동일 — builder = process_product_pair) :
- title : section.title (zone 중목차)
- col_a_label / col_b_label : h3 title (Package / Solution 또는 mdx h3 title)
- col_a_body.sections[].title : subsection title (= row label)
- col_a_body.sections[].text_lines / transforms
- col_b_body : same
Axis A+B fit — container query (cqh) + jinja line count 산식.
─────────────────────────────────────────────────────────────────────────────
#}
<style>
.f23b {
width: 100%; height: 100%;
display: flex; flex-direction: column;
gap: 4px;
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
container-type: size;
container-name: f23b-root;
}
/* 중목차 (zone title) — gradient 보존, 사용자 룰 (미변경). */
.f23b__title {
font-size: var(--font-zone-title);
font-weight: 700;
line-height: var(--lh-zone-title);
background-image: linear-gradient(180deg, #000 0%, #883700 100%);
-webkit-background-clip: text; background-clip: text;
color: transparent;
flex-shrink: 0;
filter: drop-shadow(0 0 3px rgba(50,44,30,0.4));
}
/* CSS Grid table — 사용자 lock 2026-05-15 : 의미 없는 label col (구분/숫자)
제거, 2 col 로 전체 폭 활용. */
.f23b__tbl {
display: grid;
grid-template-columns: 1fr 1fr;
grid-auto-rows: minmax(0, auto);
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
border-radius: clamp(4px, 1cqh, 8px);
border: 1px solid #888;
}
/* 회전 비활성 — 사용자 lock 2026-05-15 : 03-2 는 2 col table 유지. */
/* header row */
.f23b__th {
padding: clamp(4px, 1.5cqh, 10px) clamp(4px, 1cqw, 8px);
display: flex; align-items: center; justify-content: center;
font-weight: 700;
font-size: var(--font-sub-title);
line-height: 1.15;
color: #fff;
text-align: center;
letter-spacing: -0.03em;
border-right: 1px solid #888;
}
.f23b__th:last-child { border-right: none; }
.f23b__th--label,
.f23b__th--a { background: #589e8d; } /* figma teal */
.f23b__th--b { background: #ef7a26; } /* figma orange */
/* data row cells */
.f23b__td {
padding: clamp(4px, 1.2cqh, 10px) clamp(4px, 1cqw, 10px);
border-top: 1px solid #888;
border-right: 1px solid #888;
color: #000;
font-size: var(--font-body);
line-height: clamp(1.2em, calc(70cqh / var(--max-body-lines, 6)), 1.6em);
font-weight: 500;
overflow: hidden;
min-height: 0;
}
.f23b__td:last-child { border-right: none; }
.f23b__td--label {
font-weight: 700;
text-align: center;
display: flex; align-items: center; justify-content: center;
font-size: var(--font-sub-title);
line-height: 1.2;
}
/* zebra — 첫 번째 data row 가 row N, 두 번째 row 가 N+1 ... */
.f23b__row--odd .f23b__td { background: rgba(255,255,255,0.85); }
.f23b__row--even .f23b__td { background: rgba(253,198,158,0.2); }
/* bullet + accent */
.f23b__td .text-line {
position: relative;
padding-left: clamp(12px, 2cqw, 18px);
}
.f23b__td .text-line::before {
content: "•";
position: absolute;
left: clamp(2px, 0.5cqw, 6px); top: 0;
color: #555;
font-weight: 700;
line-height: inherit;
}
.f23b__td .text-line strong { font-weight: 700; color: #a14101; } /* figma .hl */
.f23b__td .accent { font-weight: 700; color: #a14101; }
.f23b__td .text-line--sub { padding-left: clamp(20px, 4cqw, 28px); color: #444; }
.f23b__td .text-line--sub::before {
content: "\25B8"; /* ▸ */
left: clamp(8px, 1.5cqw, 14px);
color: #888;
font-size: 0.85em;
}
/* AS-IS/TO-BE mini transforms (콘텐츠 무손실 + 내용 기반 폭). 사용자 lock
2026-05-15 : 표 안 내용이 좌측정렬 환경이라 mini table 도 좌측 정렬.
cell 안 텍스트는 그대로 center align. */
.f23b__transforms {
display: table;
margin-left: 0;
margin-right: auto;
border-collapse: separate;
border-spacing: 6px 1px;
font-size: 0.95em;
}
.f23b__transforms-head,
.f23b__transforms-row { display: table-row; }
.f23b__transforms-head > * {
display: table-cell;
text-align: center;
font-weight: 900;
font-size: 0.85em;
color: #6b4423;
padding: 0 4px 1px;
border-bottom: 1px solid rgba(107,68,35,0.3);
}
.f23b__transforms-cell,
.f23b__transforms-arrow {
display: table-cell;
text-align: center;
padding: 1px 6px;
vertical-align: middle;
}
.f23b__transforms-cell { color: #1a1a1a; font-weight: 500; }
.f23b__transforms-cell--to { color: #a14101; font-weight: 700; }
.f23b__transforms-arrow { font-weight: 700; color: #9c6f3f; }
/* row title (subsection label) — figma 의 좌측 td-label 같은 row-bound label */
.f23b__item-title {
font-weight: 900;
color: #6b4423;
margin-bottom: 2px;
font-size: 0.95em;
}
</style>
<div class="f23b" data-frame-id="1171281203" data-template-id="app_sw_package_vs_solution">
<div class="f23b__title">{{ slot_payload.title }}</div>
<div class="f23b__tbl">
{# header row — 2 col (label col 제거, 사용자 lock 2026-05-15) #}
<div class="f23b__th f23b__th--a">{{ slot_payload.col_a_label | safe }}</div>
<div class="f23b__th f23b__th--b">{{ slot_payload.col_b_label | safe }}</div>
{# data rows — col_a_body.sections 와 col_b_body.sections 를 row 별 페어 #}
{% set a_secs = slot_payload.col_a_body.sections or [] %}
{% set b_secs = slot_payload.col_b_body.sections or [] %}
{% set row_count = [a_secs | length, b_secs | length] | max %}
{% for i in range(row_count) %}
{% set a = a_secs[i] if i < (a_secs | length) else none %}
{% set b = b_secs[i] if i < (b_secs | length) else none %}
{% set row_class = 'f23b__row--' ~ ('odd' if loop.index0 % 2 == 0 else 'even') %}
{# col_a cell #}
<div class="f23b__td {{ row_class }}" style="--max-body-lines: {{ (a.text_lines | length if a and a.text_lines else 1) + (a.transforms | length if a and a.transforms else 0) }};">
{% if a %}
{% if a.title %}<div class="f23b__item-title">{{ a.title | safe }}</div>{% endif %}
{% if a.transforms %}
<div class="f23b__transforms">
<div class="f23b__transforms-head">
<span class="f23b__transforms-from">AS-IS</span>
<span></span>
<span class="f23b__transforms-to">TO-BE</span>
</div>
{% for t in a.transforms %}
<div class="f23b__transforms-row">
<div class="f23b__transforms-cell f23b__transforms-cell--from">{{ t.from | safe }}</div>
<div class="f23b__transforms-arrow">➜</div>
<div class="f23b__transforms-cell f23b__transforms-cell--to">{{ t.to | safe }}</div>
</div>
{% endfor %}
</div>
{% elif a.text_lines %}
{% for line in a.text_lines %}<div class="text-line{% if line.indent and line.indent > 0 %} text-line--sub{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
{% endif %}
</div>
{# col_b cell #}
<div class="f23b__td {{ row_class }}" style="--max-body-lines: {{ (b.text_lines | length if b and b.text_lines else 1) + (b.transforms | length if b and b.transforms else 0) }};">
{% if b %}
{% if b.title %}<div class="f23b__item-title">{{ b.title | safe }}</div>{% endif %}
{% if b.transforms %}
<div class="f23b__transforms">
<div class="f23b__transforms-head">
<span class="f23b__transforms-from">AS-IS</span>
<span></span>
<span class="f23b__transforms-to">TO-BE</span>
</div>
{% for t in b.transforms %}
<div class="f23b__transforms-row">
<div class="f23b__transforms-cell f23b__transforms-cell--from">{{ t.from | safe }}</div>
<div class="f23b__transforms-arrow">➜</div>
<div class="f23b__transforms-cell f23b__transforms-cell--to">{{ t.to | safe }}</div>
</div>
{% endfor %}
</div>
{% elif b.text_lines %}
{% for line in b.text_lines %}<div class="text-line{% if line.indent and line.indent > 0 %} text-line--sub{% endif %}">{{ line.text | safe }}</div>{% endfor %}
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
</div>
@@ -0,0 +1,194 @@
<!-- Phase Z-2 신규 frame partial — Frame 9 (1171281180) pre_construction_model_info_stacked.
2026-05-14 — V4 04-1 top rank-1 매칭 (의미 conf=0.722) + structure cardinality 5 일치.
사용자 룰 부합 : Figma visual (계단 pill / 5 색상 / vertical label) 유지 + 콘텐츠 cardinality
에 맞춰 보완 (pill 안 1 라인 → multi-line: pill_N_label + pill_N_body). -->
{#
─────────────────────────────────────────────────────────────────────────────
Visual Provenance — figma_to_html_agent/blocks/1171281180/index.html
─────────────────────────────────────────────────────────────────────────────
PROMOTED (Figma 1:1):
- title-bar background : #fbd5b9 (line 43) + box-shadow
- title text gradient : #cc5200 (line 128) 강조부
- vertical label color : #144838 (line 68) + text-shadow drop
- pill background : rgba(255,255,255,0.5) (line 88)
- pill border-radius : 30px (line 89)
- pill box-shadow : 2px 4px 5px rgba(0,0,0,0.5) (line 90)
- 5 pill border-bottom 색상 (line 141, 147, 153, 159, 165):
1: #fb5915, 2: #e79000, 3: #e9a804, 4: #919f00, 5: #0d6361
ADAPTED (zone flex 재구성):
- 1153×592 absolute → flex column layout
- pill height 70px hardcode → dynamic (콘텐츠 양에 따라 auto)
- 계단 배치 (width/margin-left hardcode) → 가운데 정렬 + width 100%
(계단은 1 라인 짧은 텍스트 전제, multi-line 콘텐츠엔 부적합)
- 1 pill 1 라인 → pill_N_label (title) + pill_N_body (text_lines)
(사용자 룰 = frame 구조를 콘텐츠에 맞춰 보완)
NOT PROMOTED:
- 좌측 arc-deco SVG (장식 — 공간 절약 위해 단순화)
- arrow PNG (CSS triangle 대체 가능, 일단 ▶ 문자 사용)
slots : title + pill_N_label / pill_N_body (N = 1.._slot_count) — dynamic.
#}
<style>
.f9b {
width: 100%; height: 100%;
display: flex; flex-direction: column;
gap: 6px;
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
word-break: keep-all;
position: relative;
}
/* 2026-05-14 — title 스타일 통일 (frame 29 process_product_two_way 와 동일 패턴) :
배경 box + box-shadow 제거, gradient 텍스트 만 유지. 사용자 룰 Q1. */
.f9b__title {
font-size: var(--font-zone-title);
font-weight: 700;
line-height: var(--lh-zone-title);
background-image: linear-gradient(180deg, #000 0%, #883700 100%);
-webkit-background-clip: text; background-clip: text;
color: transparent;
flex-shrink: 0;
padding-left: 4px;
}
.f9b__body {
flex: 1;
display: flex;
flex-direction: row;
gap: 8px;
min-height: 0;
}
/* 좌측 vertical label — 콘텐츠 있을 때만 표시 */
.f9b__vlabel {
flex: 0 0 auto;
width: 56px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: var(--font-sub-title);
line-height: 1.2;
color: #144838;
text-align: center;
writing-mode: vertical-rl;
text-orientation: upright;
text-shadow: 0 2px 2px rgba(0,0,0,0.15);
}
/* 2026-05-14 — pill 배치 vertical → horizontal (grid). 5 pills 를 가로로 나열 →
우측 공란 활용 + 세로 공간 절약. auto-fit + minmax 으로 N 동적.
gap 넓찍 (사용자 lock 2026-05-14). */
.f9b__pill-rows {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
min-height: 0;
}
/* 2026-05-14 — bullet 줄간격 1/3 으로 압축 + 다른 간격 (padding, label↔body) 살짝 늘림. */
.f9b__pill {
min-height: 0;
background: rgba(255,255,255,0.7);
border-radius: 10px;
border-bottom: 3px solid;
box-shadow: 1px 2px 4px rgba(0,0,0,0.15);
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 5px;
overflow: hidden;
align-self: start;
}
/* Figma frame 9 의 5 색상 — nth-child cycle. N>5 면 default border-bottom 색 (회색). */
.f9b__pill:nth-child(5n+1) { border-bottom-color: #fb5915; }
.f9b__pill:nth-child(5n+2) { border-bottom-color: #e79000; }
.f9b__pill:nth-child(5n+3) { border-bottom-color: #e9a804; }
.f9b__pill:nth-child(5n+4) { border-bottom-color: #919f00; }
.f9b__pill:nth-child(5n) { border-bottom-color: #0d6361; }
.f9b__pill-label {
font-size: var(--font-sub-title);
font-weight: 900;
line-height: 1.2;
color: #144838;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
}
/* arrow indicator — Figma frame 9 의 좌측 화살표 (▶) approximation */
.f9b__pill-label::before {
content: "▶";
flex-shrink: 0;
color: currentColor;
font-size: 0.85em;
}
/* 2026-05-14 — bullet 줄간격 1/3 으로 압축 : line-height 1.3 → 1.05 (약 1/3 감소).
bullet 간 gap 2 → 1 로 더 압축. 가독성 임계 (한 bullet 안 line wrap 시 라인 겹침 직전). */
.f9b__pill-body {
flex: 1 1 auto;
overflow: hidden;
min-height: 0;
color: #0c271e;
font-size: var(--font-body);
line-height: 1.05;
display: flex;
flex-direction: column;
gap: 1px;
}
/* 첫 line = 인용구 (italic + 무 마커) — 04-1 의 따옴표 인용구 패턴 */
.f9b__pill-body .text-line:first-child {
font-style: italic;
color: #5C3714;
padding-left: 0;
}
/* 이후 lines = 각 이슈 bullet (▪ 마커로 구분 강화) */
.f9b__pill-body .text-line:not(:first-child) {
position: relative;
padding-left: 12px;
}
.f9b__pill-body .text-line:not(:first-child)::before {
content: "▪";
position: absolute;
left: 0;
color: #919f00;
font-size: 0.9em;
}
</style>
<div class="f9b" data-frame-id="1171281180" data-template-id="pre_construction_model_info_stacked">
<div class="f9b__title">{{ slot_payload.title }}</div>
<div class="f9b__body">
{# vertical label — slot_payload.vlabel 있을 때만 표시 (현재 v0 미사용) #}
{% if slot_payload.vlabel %}
<div class="f9b__vlabel">{{ slot_payload.vlabel | safe }}</div>
{% endif %}
<div class="f9b__pill-rows">
{% set slot_count = slot_payload._slot_count or 5 %}
{% for n in range(1, slot_count + 1) %}
{% set label = slot_payload['pill_' ~ n ~ '_label'] %}
{% set body = slot_payload['pill_' ~ n ~ '_body'] %}
{% if label or body %}
<div class="f9b__pill" data-frame-slot-id="pill_dynamic" data-pill-n="{{ n }}">
{% if label %}
<div class="f9b__pill-label">{{ label | safe }}</div>
{% endif %}
{% if body %}
<div class="f9b__pill-body">
{% for line in body %}<div class="text-line{% if line.indent and line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
+54 -38
View File
@@ -114,42 +114,10 @@
min-height: 0; min-height: 0;
} }
/* ── IMP-30 u5 : provisional zone marker (first-render invariant) ── /* IMP-84: provisional zone visual treatment removed (silent-automation
When V4 rank-1 candidate falls outside MVP1_ALLOWED_STATUSES (chain_exhausted) policy). `data-provisional="1"` attribute is still emitted on the
the pipeline still renders the rank-1 frame so the first-render invariant zone div as silent telemetry for downstream selectors / inspection;
holds, but the zone is tagged `provisional` so the user/AI can adapt later no user-visible outline, wash, or badge. */
(IMP-31). Visual contract:
- dashed amber border + striped wash → "needs adaptation" at a glance
- inline badge top-right → text label for non-color-perceiving readers
MDX content is preserved as-is; no shrink, no rewrite. */
.zone--provisional {
outline: 2px dashed #b8860b;
outline-offset: -2px;
background-image: repeating-linear-gradient(
45deg,
rgba(184, 134, 11, 0.04) 0,
rgba(184, 134, 11, 0.04) 8px,
transparent 8px,
transparent 16px
);
}
.zone--provisional .zone__needs-adaptation-badge {
position: absolute;
top: 4px;
right: 4px;
z-index: 10;
padding: 2px 6px;
background: #b8860b;
color: #fff;
font-size: 9px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.04em;
border-radius: 2px;
text-transform: uppercase;
pointer-events: none;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
/* ── Frame-family text layout contract (shared, reusable) ── /* ── Frame-family text layout contract (shared, reusable) ──
feedback-1 (mvp1.5b_test7): visible improvement 강화. feedback-1 (mvp1.5b_test7): visible improvement 강화.
@@ -355,6 +323,38 @@
line-height: 1.5; line-height: 1.5;
color: #1e293b; color: #1e293b;
} }
/* ── IMP-90 u17 : print mode (Step 22 user-edit + Export).
Companion JS at body end opens popups so FULL raw_content prints. */
@media print {
@page { size: 1280px 720px; margin: 0; }
html, body {
background: #fff !important;
padding: 0 !important;
margin: 0 !important;
min-height: 0 !important;
display: block !important;
}
.slide {
box-shadow: none !important;
page-break-inside: avoid;
break-inside: avoid;
}
.zone__popup-summary { display: none !important; }
.zone__popup-details,
.zone__popup-details[open] { position: static !important; }
.zone__popup-body {
position: static !important;
top: auto !important;
right: auto !important;
max-height: none !important;
overflow: visible !important;
box-shadow: none !important;
border: none !important;
width: auto !important;
padding: 6px 0 0 0 !important;
}
}
</style> </style>
</head> </head>
<body> <body>
@@ -366,8 +366,7 @@
<div class="slide-body"> <div class="slide-body">
<div class="layout-{{ layout_preset }}"> <div class="layout-{{ layout_preset }}">
{% for zone in zones %} {% for zone in zones %}
<div class="zone{% if zone.provisional %} zone--provisional{% endif %}" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}"{% if zone.provisional %} data-provisional="1"{% endif %}{% if zone.has_popup %} data-has-popup="1"{% endif %} style="grid-area: {{ zone.position }};"> <div class="zone" 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 }} {{ zone.partial_html | safe }}
{% if zone.has_popup %} {% if zone.has_popup %}
{% set _popup_trigger = (zone.popup_binding.detail_trigger if zone.popup_binding else None) or {} %} {% set _popup_trigger = (zone.popup_binding.detail_trigger if zone.popup_binding else None) or {} %}
@@ -389,5 +388,22 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<script>
// IMP-90 u17 — beforeprint popup auto-expand (CLAUDE.md 자세히보기 contract).
// Body-level handler (outside any per-zone popup block) so the popup-render
// JS-free invariant (IMP-35 u8) is preserved on the per-zone path.
window.addEventListener('beforeprint', function () {
document.querySelectorAll('details').forEach(function (d) {
d.dataset.imp90PrintRestore = d.open ? '1' : '0';
d.open = true;
});
});
window.addEventListener('afterprint', function () {
document.querySelectorAll('details').forEach(function (d) {
if (d.dataset.imp90PrintRestore === '0') d.open = false;
delete d.dataset.imp90PrintRestore;
});
});
</script>
</body> </body>
</html> </html>
View File
@@ -0,0 +1,73 @@
{
"_doc": "IMP-91 u9 — F3 classifier-only AI axis. Pin observed step12 per-unit classifier label / route_hint / AI-isolation flags + coverage_invariant + step15 fit_classification + step16 router_active + step18 failure_type. Default-OFF AI invariant ([[feedback_ai_isolation_contract]]): ai_called MUST be False for every unit unless AI_FALLBACK_ENABLED is flipped via .env (not via pipeline default). If any unit flips ai_called=True silently, this snapshot fails loudly per [[feedback_demo_env_toggle_policy]].",
"01": {
"units": [
{"source_section_ids": ["01-2"], "label": "use_as_is", "route_hint": "direct_render", "provisional": false, "ai_called": false, "skip_reason": "not_provisional", "apply_status": "no_proposal"},
{"source_section_ids": ["01-1"], "label": "use_as_is", "route_hint": "direct_render", "provisional": false, "ai_called": false, "skip_reason": "not_provisional", "apply_status": "no_proposal"}
],
"coverage_invariant_status": "ok",
"fit_visual_check_passed": true,
"fit_classifications_count": 0,
"fit_categories_seen": [],
"router_active": false,
"router_routed_count": 0,
"router_v4_fallback_used_count": 0,
"failure_type": "not_attempted"
},
"02": {
"units": [
{"source_section_ids": ["02-1"], "label": "use_as_is", "route_hint": "direct_render", "provisional": false, "ai_called": false, "skip_reason": "not_provisional", "apply_status": "no_proposal"},
{"source_section_ids": ["02-2-sub-1", "02-2-sub-2"], "label": "use_as_is", "route_hint": "direct_render", "provisional": true, "ai_called": false, "skip_reason": "route_not_ai_adaptation:direct_render", "apply_status": "no_proposal"}
],
"coverage_invariant_status": "ok",
"fit_visual_check_passed": true,
"fit_classifications_count": 0,
"fit_categories_seen": [],
"router_active": false,
"router_routed_count": 0,
"router_v4_fallback_used_count": 0,
"failure_type": "not_attempted"
},
"03": {
"units": [
{"source_section_ids": ["03-1"], "label": "use_as_is", "route_hint": "direct_render", "provisional": false, "ai_called": false, "skip_reason": "not_provisional", "apply_status": "no_proposal"},
{"source_section_ids": ["03-2"], "label": "use_as_is", "route_hint": "direct_render", "provisional": false, "ai_called": false, "skip_reason": "not_provisional", "apply_status": "no_proposal"}
],
"coverage_invariant_status": "ok",
"fit_visual_check_passed": true,
"fit_classifications_count": 0,
"fit_categories_seen": [],
"router_active": false,
"router_routed_count": 0,
"router_v4_fallback_used_count": 0,
"failure_type": "not_attempted"
},
"04": {
"units": [
{"source_section_ids": ["04-2-sub-2"], "label": "light_edit", "route_hint": "deterministic_minor_adjustment", "provisional": false, "ai_called": false, "skip_reason": "not_provisional", "apply_status": "no_proposal"},
{"source_section_ids": ["04-2-sub-1"], "label": "restructure", "route_hint": "ai_adaptation_required", "provisional": true, "ai_called": false, "skip_reason": "router_short_circuit", "apply_status": "no_proposal"},
{"source_section_ids": ["04-1"], "label": "reject", "route_hint": "ai_adaptation_required", "provisional": true, "ai_called": false, "skip_reason": "router_short_circuit", "apply_status": "no_proposal"}
],
"coverage_invariant_status": "ok",
"fit_visual_check_passed": true,
"fit_classifications_count": 0,
"fit_categories_seen": [],
"router_active": false,
"router_routed_count": 0,
"router_v4_fallback_used_count": 0,
"failure_type": "not_attempted"
},
"05": {
"units": [
{"source_section_ids": ["05-1", "05-2-sub-1", "05-2-sub-2"], "label": "empty_shell", "route_hint": null, "provisional": true, "ai_called": false, "skip_reason": "route_not_ai_adaptation:None", "apply_status": "no_proposal"}
],
"coverage_invariant_status": "ok",
"fit_visual_check_passed": true,
"fit_classifications_count": 0,
"fit_categories_seen": [],
"router_active": false,
"router_routed_count": 0,
"router_v4_fallback_used_count": 0,
"failure_type": "not_attempted"
}
}
@@ -0,0 +1,43 @@
{
"_doc": "IMP-#91 u5 — full_mdx_coverage / aligned_section_ids / covered_section_ids / filtered_section_ids snapshot pinned from observed step20_slide_status.json across MDX_SET (mdx 01-05). Drift = real change in coverage outcome; re-baseline only with conscious explanation in commit body.",
"01": {
"full_mdx_coverage": true,
"rendered": true,
"visual_check_passed": true,
"aligned_section_ids": ["01-1", "01-2"],
"covered_section_ids": ["01-1", "01-2"],
"filtered_section_ids": []
},
"02": {
"full_mdx_coverage": true,
"rendered": true,
"visual_check_passed": true,
"aligned_section_ids": ["02-1", "02-2-sub-1", "02-2-sub-2"],
"covered_section_ids": ["02-1", "02-2-sub-1", "02-2-sub-2"],
"filtered_section_ids": []
},
"03": {
"full_mdx_coverage": true,
"rendered": true,
"visual_check_passed": true,
"aligned_section_ids": ["03-1", "03-2"],
"covered_section_ids": ["03-1", "03-2"],
"filtered_section_ids": []
},
"04": {
"full_mdx_coverage": true,
"rendered": true,
"visual_check_passed": true,
"aligned_section_ids": ["04-1", "04-2-sub-1", "04-2-sub-2"],
"covered_section_ids": ["04-1", "04-2-sub-1", "04-2-sub-2"],
"filtered_section_ids": []
},
"05": {
"full_mdx_coverage": false,
"rendered": true,
"visual_check_passed": true,
"aligned_section_ids": ["05-1", "05-2-sub-1", "05-2-sub-2"],
"covered_section_ids": ["05-1", "05-2-sub-1", "05-2-sub-2"],
"filtered_section_ids": ["05-1", "05-2-sub-1", "05-2-sub-2"]
}
}
@@ -0,0 +1,88 @@
{
"_doc": "IMP-91 u11 — F5 final.html extraction axis. Pin step13_render.json metadata (step_status / pipeline_path_connected / render_inputs.zones_count / render_inputs.layout_preset / slide_title|footer non-empty / final_html_size_bytes) AND structural markers extracted from the on-disk final.html (HTML <title>, slide root count, slide-footer presence, data-zone-position/data-template-id topology). The HTML-extracted zone topology MUST match the step12 slot_payload (position, template_id) sequence already pinned in slot_payload.json (u8) — Jinja2 renders from step12, not step09, so step12 is the correct upstream parity source (step09 selection vs step12 __empty__ collapse is intentional per IMP-87 honesty gate and surfaces in u8). Drift between final.html and slot_payload = render pipeline disconnect. on-disk final.html size_bytes MUST equal step13's reported final_html_size_bytes (byte parity = no truncation / no double-write race).",
"01": {
"step13_status": "done",
"step13_pipeline_path_connected": true,
"render_inputs_zones_count": 2,
"render_inputs_layout_preset": "horizontal-2",
"render_inputs_slide_title_nonempty": true,
"render_inputs_slide_footer_nonempty": true,
"html_title_matches_render_input": true,
"html_slide_root_count": 1,
"html_slide_footer_present": true,
"html_zone_count": 2,
"html_zone_topology": [
{"position": "top", "template_id": "bim_dx_comparison_table"},
{"position": "bottom", "template_id": "construction_bim_three_usage"}
],
"final_html_size_matches_step13_reported": true
},
"02": {
"step13_status": "done",
"step13_pipeline_path_connected": true,
"render_inputs_zones_count": 2,
"render_inputs_layout_preset": "horizontal-2",
"render_inputs_slide_title_nonempty": true,
"render_inputs_slide_footer_nonempty": true,
"html_title_matches_render_input": true,
"html_slide_root_count": 1,
"html_slide_footer_present": true,
"html_zone_count": 2,
"html_zone_topology": [
{"position": "top", "template_id": "construction_goals_three_circle_intersection"},
{"position": "bottom", "template_id": "__empty__"}
],
"final_html_size_matches_step13_reported": true
},
"03": {
"step13_status": "done",
"step13_pipeline_path_connected": true,
"render_inputs_zones_count": 2,
"render_inputs_layout_preset": "vertical-2",
"render_inputs_slide_title_nonempty": true,
"render_inputs_slide_footer_nonempty": true,
"html_title_matches_render_input": true,
"html_slide_root_count": 1,
"html_slide_footer_present": true,
"html_zone_count": 2,
"html_zone_topology": [
{"position": "left", "template_id": "three_parallel_requirements"},
{"position": "right", "template_id": "process_product_two_way"}
],
"final_html_size_matches_step13_reported": true
},
"04": {
"step13_status": "done",
"step13_pipeline_path_connected": true,
"render_inputs_zones_count": 3,
"render_inputs_layout_preset": "top-1-bottom-2",
"render_inputs_slide_title_nonempty": true,
"render_inputs_slide_footer_nonempty": true,
"html_title_matches_render_input": true,
"html_slide_root_count": 1,
"html_slide_footer_present": true,
"html_zone_count": 3,
"html_zone_topology": [
{"position": "top", "template_id": "bim_issues_quadrant_four"},
{"position": "bottom-left", "template_id": "__empty__"},
{"position": "bottom-right", "template_id": "__empty__"}
],
"final_html_size_matches_step13_reported": true
},
"05": {
"step13_status": "done",
"step13_pipeline_path_connected": true,
"render_inputs_zones_count": 1,
"render_inputs_layout_preset": "single",
"render_inputs_slide_title_nonempty": true,
"render_inputs_slide_footer_nonempty": true,
"html_title_matches_render_input": true,
"html_slide_root_count": 1,
"html_slide_footer_present": true,
"html_zone_count": 1,
"html_zone_topology": [
{"position": "primary", "template_id": "__empty__"}
],
"final_html_size_matches_step13_reported": true
}
}
+133
View File
@@ -0,0 +1,133 @@
{
"_doc": "IMP-91 u10 — F4 layout snapshot (step07 + step08). Pins observed layout decision axes (preset / candidates / override / computation / dynamic flags) + planning geometry (heights_px / widths_px / ratios / col_ratios) + per-zone planning shape (position / min_height_px / frame_cardinality_strict / sub_zones_count / region_layout_candidates). step_status='partial' = schema-lock marker per Step 7/8 note (region-level ratio + count-based v0 marker stays a marker, never silently flipped). layout_override_applied=True ONLY for mdx 03 (project_mdx03_frame_lock 2026-05-15 user lock — axis A vertical-2 override). Source: src/phase_z2_pipeline.py step07/step08 emit; auto_layout_preset=None for mdx 05 single-preset path. drift in heights_px/ratios = content_weight_distribution shift; drift in computation = decision-path swap (regression signal axis distinct from preset).",
"01": {
"step7_step_status": "partial",
"step7_pipeline_path_connected": true,
"layout_preset": "horizontal-2",
"auto_layout_preset": "horizontal-2",
"layout_override_applied": false,
"zones_count": 2,
"unit_count": 2,
"layout_candidates": ["horizontal-2", "vertical-2"],
"computation": "min_height_first + content_weight_distribution",
"dynamic_rows": true,
"dynamic_cols": false,
"heights_px": [299, 272],
"widths_px": [1180],
"ratios": [0.511, 0.465],
"width_ratios": [1.0],
"step8_step_status": "partial",
"step8_pipeline_path_connected": true,
"zone_heights_px_planned": [299, 272],
"zone_widths_px_planned": [1180],
"zone_col_ratios_planned": [1.0],
"per_zone_layout_shape": [
{"position": "top", "min_height_px": 350, "frame_cardinality_strict": 2, "sub_zones_count": 3, "region_layout_candidates": ["region-single"]},
{"position": "bottom", "min_height_px": 320, "frame_cardinality_strict": 3, "sub_zones_count": 3, "region_layout_candidates": ["region-single"]}
]
},
"02": {
"step7_step_status": "partial",
"step7_pipeline_path_connected": true,
"layout_preset": "horizontal-2",
"auto_layout_preset": "horizontal-2",
"layout_override_applied": false,
"zones_count": 2,
"unit_count": 2,
"layout_candidates": ["horizontal-2", "vertical-2"],
"computation": "min_height_first + content_weight_distribution",
"dynamic_rows": true,
"dynamic_cols": false,
"heights_px": [273, 298],
"widths_px": [1180],
"ratios": [0.467, 0.509],
"width_ratios": [1.0],
"step8_step_status": "partial",
"step8_pipeline_path_connected": true,
"zone_heights_px_planned": [273, 298],
"zone_widths_px_planned": [1180],
"zone_col_ratios_planned": [1.0],
"per_zone_layout_shape": [
{"position": "top", "min_height_px": 320, "frame_cardinality_strict": 3, "sub_zones_count": 4, "region_layout_candidates": ["region-single"]},
{"position": "bottom", "min_height_px": 350, "frame_cardinality_strict": 3, "sub_zones_count": 3, "region_layout_candidates": ["region-single"]}
]
},
"03": {
"step7_step_status": "partial",
"step7_pipeline_path_connected": true,
"layout_preset": "vertical-2",
"auto_layout_preset": "horizontal-2",
"layout_override_applied": true,
"zones_count": 2,
"unit_count": 2,
"layout_candidates": ["horizontal-2", "vertical-2"],
"computation": "user_override_geometry",
"dynamic_rows": false,
"dynamic_cols": true,
"heights_px": [585],
"widths_px": [408, 758],
"ratios": [1.0],
"width_ratios": [0.35, 0.65],
"step8_step_status": "partial",
"step8_pipeline_path_connected": true,
"zone_heights_px_planned": [585],
"zone_widths_px_planned": [408, 758],
"zone_col_ratios_planned": [0.35, 0.65],
"per_zone_layout_shape": [
{"position": "left", "min_height_px": 230, "frame_cardinality_strict": 3, "sub_zones_count": 3, "region_layout_candidates": ["region-single"]},
{"position": "right", "min_height_px": 345, "frame_cardinality_strict": 2, "sub_zones_count": 2, "region_layout_candidates": ["region-single"]}
]
},
"04": {
"step7_step_status": "partial",
"step7_pipeline_path_connected": true,
"layout_preset": "top-1-bottom-2",
"auto_layout_preset": "top-1-bottom-2",
"layout_override_applied": false,
"zones_count": 3,
"unit_count": 3,
"layout_candidates": ["top-1-bottom-2", "top-2-bottom-1", "left-1-right-2", "left-2-right-1"],
"computation": "2d_dynamic_aggregated",
"dynamic_rows": true,
"dynamic_cols": true,
"heights_px": [221, 350],
"widths_px": [583, 583],
"ratios": [0.378, 0.598],
"width_ratios": [0.494, 0.494],
"step8_step_status": "partial",
"step8_pipeline_path_connected": true,
"zone_heights_px_planned": [221, 350],
"zone_widths_px_planned": [583, 583],
"zone_col_ratios_planned": [0.494, 0.494],
"per_zone_layout_shape": [
{"position": "top", "min_height_px": null, "frame_cardinality_strict": null, "sub_zones_count": 4, "region_layout_candidates": ["region-single"]},
{"position": "bottom-left", "min_height_px": 350, "frame_cardinality_strict": 4, "sub_zones_count": 5, "region_layout_candidates": ["region-single"]},
{"position": "bottom-right", "min_height_px": 350, "frame_cardinality_strict": null, "sub_zones_count": 1, "region_layout_candidates": ["region-single"]}
]
},
"05": {
"step7_step_status": "partial",
"step7_pipeline_path_connected": true,
"layout_preset": "single",
"auto_layout_preset": null,
"layout_override_applied": false,
"zones_count": 1,
"unit_count": 1,
"layout_candidates": ["single"],
"computation": "fr_default_from_preset",
"dynamic_rows": false,
"dynamic_cols": false,
"heights_px": [585],
"widths_px": [1180],
"ratios": [1.0],
"width_ratios": [1.0],
"step8_step_status": "partial",
"step8_pipeline_path_connected": true,
"zone_heights_px_planned": [585],
"zone_widths_px_planned": [1180],
"zone_col_ratios_planned": [1.0],
"per_zone_layout_shape": [
{"position": "primary", "min_height_px": null, "frame_cardinality_strict": null, "sub_zones_count": 0, "region_layout_candidates": ["region-single"]}
]
}
}
@@ -0,0 +1,83 @@
{
"_doc": "IMP-91 u6 — F0 normalize axis snapshot (step02_normalized.json). Pins observed current state per [[feedback_validation_first_for_closed_issues]] / Stage 1 'do not invent a new expectation'. step_status='partial' is the schema-lock marker for IMP-02/03 (orphans + details detection unimplemented). adapter_enabled/used=false reflects default-OFF canary (chained adapter trace OFF). asset counts are step02 collection state (popups/images/tables list aggregation in stage0_normalized_assets); they may grow when IMP-03 detection lands and the snapshot will drift loudly.",
"01": {
"step_num": 2,
"step_status": "partial",
"pipeline_path_connected": true,
"sections_count": 2,
"section_ids": ["01-1", "01-2"],
"orphans_count": 0,
"details_count": 0,
"adapter_enabled": false,
"adapter_used": false,
"assets_popups_count": 0,
"assets_images_count": 0,
"assets_tables_count": 0,
"slide_title_nonempty": true,
"slide_footer_nonempty": true
},
"02": {
"step_num": 2,
"step_status": "partial",
"pipeline_path_connected": true,
"sections_count": 2,
"section_ids": ["02-1", "02-2"],
"orphans_count": 0,
"details_count": 0,
"adapter_enabled": false,
"adapter_used": false,
"assets_popups_count": 0,
"assets_images_count": 0,
"assets_tables_count": 0,
"slide_title_nonempty": true,
"slide_footer_nonempty": true
},
"03": {
"step_num": 2,
"step_status": "partial",
"pipeline_path_connected": true,
"sections_count": 2,
"section_ids": ["03-1", "03-2"],
"orphans_count": 0,
"details_count": 0,
"adapter_enabled": false,
"adapter_used": false,
"assets_popups_count": 0,
"assets_images_count": 0,
"assets_tables_count": 0,
"slide_title_nonempty": true,
"slide_footer_nonempty": true
},
"04": {
"step_num": 2,
"step_status": "partial",
"pipeline_path_connected": true,
"sections_count": 2,
"section_ids": ["04-1", "04-2"],
"orphans_count": 0,
"details_count": 0,
"adapter_enabled": false,
"adapter_used": false,
"assets_popups_count": 0,
"assets_images_count": 0,
"assets_tables_count": 0,
"slide_title_nonempty": true,
"slide_footer_nonempty": true
},
"05": {
"step_num": 2,
"step_status": "partial",
"pipeline_path_connected": true,
"sections_count": 2,
"section_ids": ["05-1", "05-2"],
"orphans_count": 0,
"details_count": 0,
"adapter_enabled": false,
"adapter_used": false,
"assets_popups_count": 0,
"assets_images_count": 0,
"assets_tables_count": 0,
"slide_title_nonempty": true,
"slide_footer_nonempty": true
}
}
@@ -0,0 +1,103 @@
{
"_doc": "IMP-#91 u8 — F2 slot_payload axis. Pins step12_slot_payload.json per_zone structural shape (position / template_id / builder / slot_names / list_slot_counts / dict_slot_sub_counts / string_slot_nonempty) for mdx 01-05. Pins SHAPE not literal content — text edits in MDX won't drift this snapshot, but builder swap / slot rename / missing slot / list-cardinality drift will. __empty__ zones have builder=null and zero slots.",
"01": [
{
"position": "top",
"template_id": "bim_dx_comparison_table",
"builder": "compare_table_2col",
"slot_names": ["col_a_label", "col_b_label", "rows", "title"],
"list_slot_counts": {"rows": 2},
"dict_slot_sub_counts": {},
"string_slot_nonempty": {"col_a_label": false, "col_b_label": false, "title": true}
},
{
"position": "bottom",
"template_id": "construction_bim_three_usage",
"builder": "quadrant_flat_slots",
"slot_names": ["category_1_body", "category_1_label", "category_2_body", "category_2_label", "category_3_body", "category_3_label", "title"],
"list_slot_counts": {"category_1_body": 2, "category_2_body": 2, "category_3_body": 2},
"dict_slot_sub_counts": {},
"string_slot_nonempty": {"category_1_label": true, "category_2_label": true, "category_3_label": true, "title": true}
}
],
"02": [
{
"position": "top",
"template_id": "construction_goals_three_circle_intersection",
"builder": "cycle_intersect_3",
"slot_names": ["circle_1_label", "circle_2_label", "circle_3_label", "intersection", "title"],
"list_slot_counts": {},
"dict_slot_sub_counts": {},
"string_slot_nonempty": {"circle_1_label": true, "circle_2_label": true, "circle_3_label": true, "intersection": false, "title": true}
},
{
"position": "bottom",
"template_id": "__empty__",
"builder": null,
"slot_names": [],
"list_slot_counts": {},
"dict_slot_sub_counts": {},
"string_slot_nonempty": {}
}
],
"03": [
{
"position": "left",
"template_id": "three_parallel_requirements",
"builder": "items_with_role",
"slot_names": ["pillars", "title"],
"list_slot_counts": {"pillars": 3},
"dict_slot_sub_counts": {},
"string_slot_nonempty": {"title": true}
},
{
"position": "right",
"template_id": "process_product_two_way",
"builder": "process_product_pair",
"slot_names": ["banner_left", "banner_right", "process", "product", "title"],
"list_slot_counts": {},
"dict_slot_sub_counts": {"process": {"sections": 3}, "product": {"sections": 3}},
"string_slot_nonempty": {"banner_left": true, "banner_right": true, "title": true}
}
],
"04": [
{
"position": "top",
"template_id": "bim_issues_quadrant_four",
"builder": "quadrant_flat_slots",
"slot_names": ["quadrant_1_body", "quadrant_1_label", "quadrant_2_body", "quadrant_2_label", "quadrant_3_body", "quadrant_3_label", "quadrant_4_body", "quadrant_4_label", "title"],
"list_slot_counts": {"quadrant_1_body": 2, "quadrant_2_body": 2, "quadrant_3_body": 2, "quadrant_4_body": 2},
"dict_slot_sub_counts": {},
"string_slot_nonempty": {"quadrant_1_label": true, "quadrant_2_label": true, "quadrant_3_label": true, "quadrant_4_label": true, "title": true}
},
{
"position": "bottom-left",
"template_id": "__empty__",
"builder": null,
"slot_names": [],
"list_slot_counts": {},
"dict_slot_sub_counts": {},
"string_slot_nonempty": {}
},
{
"position": "bottom-right",
"template_id": "__empty__",
"builder": null,
"slot_names": [],
"list_slot_counts": {},
"dict_slot_sub_counts": {},
"string_slot_nonempty": {}
}
],
"05": [
{
"position": "primary",
"template_id": "__empty__",
"builder": null,
"slot_names": [],
"list_slot_counts": {},
"dict_slot_sub_counts": {},
"string_slot_nonempty": {}
}
]
}
@@ -0,0 +1,43 @@
{
"_doc": "IMP-#91 u3 structural snapshot — pins observed step20 overall + step09 per-zone selected_template_id per mdx in the 01-05 acceptance set. Each entry is fresh-run evidence (not aspirational). Update only when an intentional pipeline change moves the observed value; treat unexplained drift as regression. [[feedback_validation_first_for_closed_issues]] [[feedback_artifact_status_naming]]",
"01": {
"overall": "PASS",
"zone_count": 2,
"zones": [
{"position": "top", "selected_template_id": "bim_dx_comparison_table"},
{"position": "bottom", "selected_template_id": "construction_bim_three_usage"}
]
},
"02": {
"overall": "PASS",
"zone_count": 2,
"zones": [
{"position": "top", "selected_template_id": "construction_goals_three_circle_intersection"},
{"position": "bottom", "selected_template_id": "three_persona_benefits"}
]
},
"03": {
"overall": "PASS",
"zone_count": 2,
"zones": [
{"position": "left", "selected_template_id": "three_parallel_requirements"},
{"position": "right", "selected_template_id": "process_product_two_way"}
]
},
"04": {
"overall": "PASS",
"zone_count": 3,
"zones": [
{"position": "top", "selected_template_id": "bim_issues_quadrant_four"},
{"position": "bottom-left", "selected_template_id": "sw_dependency_four_problems"},
{"position": "bottom-right", "selected_template_id": "pre_construction_model_info_stacked"}
]
},
"05": {
"overall": "EMPTY_SHELL_NO_CONTENT",
"zone_count": 1,
"zones": [
{"position": "primary", "selected_template_id": "__empty__"}
]
}
}
@@ -0,0 +1,112 @@
{
"_doc": "IMP-91 u7 — F1 V4 ranking observed snapshot (step05_v4_evidence). Pins v4_source (POSIX-normalized), aligned_section_ids, and per-section {section_id, candidate_status, candidates: [{template_id, label, confidence}]}. confidence kept at current 4-decimal rounding. Sections appear in pipeline-emitted order.",
"01": {
"v4_source": "tests/matching/v4_full32_result.yaml",
"aligned_section_ids": ["01-1", "01-2"],
"sections": [
{
"section_id": "01-1",
"candidate_status": "ok",
"candidates": [
{"template_id": "construction_bim_three_usage", "label": "use_as_is", "confidence": 0.9101},
{"template_id": "construction_goals_three_circle_intersection", "label": "light_edit", "confidence": 0.8261},
{"template_id": "dx_sw_necessity_three_perspectives", "label": "light_edit", "confidence": 0.8168}
]
},
{
"section_id": "01-2",
"candidate_status": "ok",
"candidates": [
{"template_id": "bim_dx_comparison_table", "label": "use_as_is", "confidence": 0.9459},
{"template_id": "app_sw_package_vs_solution", "label": "restructure", "confidence": 0.6813}
]
}
]
},
"02": {
"v4_source": "tests/matching/v4_full32_result.yaml",
"aligned_section_ids": ["02-1", "02-2-sub-1", "02-2-sub-2"],
"sections": [
{
"section_id": "02-1",
"candidate_status": "ok",
"candidates": [
{"template_id": "construction_goals_three_circle_intersection", "label": "use_as_is", "confidence": 0.914}
]
},
{
"section_id": "02-2-sub-1",
"candidate_status": "no_non_reject_v4_candidate",
"candidates": []
},
{
"section_id": "02-2-sub-2",
"candidate_status": "no_non_reject_v4_candidate",
"candidates": []
}
]
},
"03": {
"v4_source": "tests/matching/v4_full32_result.yaml",
"aligned_section_ids": ["03-1", "03-2"],
"sections": [
{
"section_id": "03-1",
"candidate_status": "ok",
"candidates": [
{"template_id": "three_parallel_requirements", "label": "use_as_is", "confidence": 0.9268},
{"template_id": "dx_sw_necessity_three_perspectives", "label": "light_edit", "confidence": 0.8413}
]
},
{
"section_id": "03-2",
"candidate_status": "ok",
"candidates": [
{"template_id": "process_product_two_way", "label": "use_as_is", "confidence": 0.9198}
]
}
]
},
"04": {
"v4_source": "tests/matching/v4_full32_result.yaml",
"aligned_section_ids": ["04-1", "04-2-sub-1", "04-2-sub-2"],
"sections": [
{
"section_id": "04-1",
"candidate_status": "no_non_reject_v4_candidate",
"candidates": []
},
{
"section_id": "04-2-sub-1",
"candidate_status": "no_non_reject_v4_candidate",
"candidates": []
},
{
"section_id": "04-2-sub-2",
"candidate_status": "no_non_reject_v4_candidate",
"candidates": []
}
]
},
"05": {
"v4_source": "tests/matching/v4_full32_result.yaml",
"aligned_section_ids": ["05-1", "05-2-sub-1", "05-2-sub-2"],
"sections": [
{
"section_id": "05-1",
"candidate_status": "no_non_reject_v4_candidate",
"candidates": []
},
{
"section_id": "05-2-sub-1",
"candidate_status": "no_non_reject_v4_candidate",
"candidates": []
},
{
"section_id": "05-2-sub-2",
"candidate_status": "no_non_reject_v4_candidate",
"candidates": []
}
]
}
}
@@ -0,0 +1,48 @@
{
"_doc": "u4 — pin observed step14_visual_check overflow/clip per mdx 01-05. Fresh subprocess observation per [[feedback_validation_first_for_closed_issues]]; drift surfaces visual regression (overflow / clip) loudly per [[feedback_artifact_status_naming]] 3-axis honesty. Snapshot pinned to current-state, not to invented expectation (Stage 1 scope-lock).",
"01": {
"slide_overflowed": false,
"slide_body_overflowed": false,
"passed": true,
"zones": [
{"position": "top", "template_id": "bim_dx_comparison_table", "overflowed": false, "clipped_inner_count": 0},
{"position": "bottom", "template_id": "construction_bim_three_usage", "overflowed": false, "clipped_inner_count": 0}
]
},
"02": {
"slide_overflowed": false,
"slide_body_overflowed": false,
"passed": true,
"zones": [
{"position": "top", "template_id": "construction_goals_three_circle_intersection", "overflowed": false, "clipped_inner_count": 0},
{"position": "bottom", "template_id": "__empty__", "overflowed": false, "clipped_inner_count": 0}
]
},
"03": {
"slide_overflowed": false,
"slide_body_overflowed": false,
"passed": true,
"zones": [
{"position": "left", "template_id": "three_parallel_requirements", "overflowed": false, "clipped_inner_count": 0},
{"position": "right", "template_id": "process_product_two_way", "overflowed": false, "clipped_inner_count": 0}
]
},
"04": {
"slide_overflowed": false,
"slide_body_overflowed": false,
"passed": true,
"zones": [
{"position": "top", "template_id": "bim_issues_quadrant_four", "overflowed": false, "clipped_inner_count": 0},
{"position": "bottom-left", "template_id": "__empty__", "overflowed": false, "clipped_inner_count": 0},
{"position": "bottom-right", "template_id": "__empty__", "overflowed": false, "clipped_inner_count": 0}
]
},
"05": {
"slide_overflowed": false,
"slide_body_overflowed": false,
"passed": true,
"zones": [
{"position": "primary", "template_id": "__empty__", "overflowed": false, "clipped_inner_count": 0}
]
}
}
@@ -0,0 +1,573 @@
"""IMP-#91 u2 — multi-mdx regression CI scaffold (mdx 01-05 acceptance set).
Session-scoped subprocess cache that runs each MDX acceptance fixture
exactly once. u3-u11 extend this module with per-axis assertions
(structural / visual / coverage / F0-F5). u2 alone pins the cache
contract: each mdx in ``MDX_SET`` produces a run directory under
``data/runs/<run_id>/phase_z2/`` containing the step JSONs and
``final.html`` that downstream parametrized tests will read.
[[feedback_validation_first_for_closed_issues]] — fresh subprocess per
session, no frozen artifacts. [[feedback_artifact_status_naming]] — the
overall status (PASS / RENDERED_WITH_VISUAL_REGRESSION /
PARTIAL_COVERAGE / EMPTY_SHELL_NO_CONTENT) is asserted in u3-u5; u2
only pins the artifact-production contract.
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
import uuid
from pathlib import Path
from typing import Dict, List, NamedTuple
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SAMPLES_DIR = REPO_ROOT / "samples" / "mdx_batch"
RUNS_DIR = REPO_ROOT / "data" / "runs"
SNAPSHOTS_DIR = Path(__file__).resolve().parent / "__snapshots__"
MDX_SET = ("01", "02", "03", "04", "05")
class PipelineRun(NamedTuple):
mdx_id: str
run_id: str
returncode: int
stdout: str
stderr: str
run_dir: Path
@pytest.fixture(scope="session")
def multi_mdx_runs() -> Dict[str, PipelineRun]:
"""Run the Phase Z pipeline once per mdx in ``MDX_SET`` (session-cached)."""
cache: Dict[str, PipelineRun] = {}
for mdx_id in MDX_SET:
run_id = f"imp91_{mdx_id}_{uuid.uuid4().hex[:8]}"
cp = subprocess.run(
[
sys.executable,
"-m",
"src.phase_z2_pipeline",
str(SAMPLES_DIR / f"{mdx_id}.mdx"),
run_id,
],
capture_output=True,
text=True,
timeout=360,
cwd=str(REPO_ROOT),
)
cache[mdx_id] = PipelineRun(
mdx_id=mdx_id,
run_id=run_id,
returncode=cp.returncode,
stdout=cp.stdout,
stderr=cp.stderr,
run_dir=RUNS_DIR / run_id / "phase_z2",
)
return cache
@pytest.mark.integration
@pytest.mark.parametrize("mdx_id", MDX_SET)
def test_pipeline_run_produces_step20_status(
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
) -> None:
"""Cache contract: every mdx subprocess produces step20_slide_status.json."""
run = multi_mdx_runs[mdx_id]
status_path = run.run_dir / "steps" / "step20_slide_status.json"
assert status_path.is_file(), (
f"{mdx_id}.mdx run {run.run_id} did not produce {status_path} "
f"(returncode={run.returncode}); stderr tail: {run.stderr[-800:]}"
)
@pytest.mark.integration
@pytest.mark.parametrize("mdx_id", MDX_SET)
def test_structural_snapshot_matches(
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
) -> None:
"""u3 — pin observed overall + per-zone selected_template_id against snapshot."""
snapshot = json.loads((SNAPSHOTS_DIR / "structural.json").read_text(encoding="utf-8"))
expected = snapshot[mdx_id]
run = multi_mdx_runs[mdx_id]
status = json.loads(
(run.run_dir / "steps" / "step20_slide_status.json").read_text(encoding="utf-8")
)["data"]
frame_sel = json.loads(
(run.run_dir / "steps" / "step09_frame_selection.json").read_text(encoding="utf-8")
)["data"]
zones = frame_sel.get("per_zone", [])
actual_zones = [
{"position": z.get("position"), "selected_template_id": z.get("selected_template_id")}
for z in zones
]
assert status.get("overall") == expected["overall"], (
f"{mdx_id}.mdx overall drift: expected {expected['overall']!r}, "
f"got {status.get('overall')!r}"
)
assert len(actual_zones) == expected["zone_count"], (
f"{mdx_id}.mdx zone_count drift: expected {expected['zone_count']}, "
f"got {len(actual_zones)} (zones={actual_zones})"
)
assert actual_zones == expected["zones"], (
f"{mdx_id}.mdx zone topology drift: expected {expected['zones']}, "
f"got {actual_zones}"
)
@pytest.mark.integration
@pytest.mark.parametrize("mdx_id", MDX_SET)
def test_visual_snapshot_matches(
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
) -> None:
"""u4 — pin observed step14 visual_check overflow/clip against snapshot."""
snapshot = json.loads((SNAPSHOTS_DIR / "visual.json").read_text(encoding="utf-8"))
expected = snapshot[mdx_id]
run = multi_mdx_runs[mdx_id]
visual = json.loads(
(run.run_dir / "steps" / "step14_visual_check.json").read_text(encoding="utf-8")
)["data"]
slide_overflowed = visual.get("slide", {}).get("overflowed")
slide_body_overflowed = visual.get("slide_body", {}).get("overflowed")
visual_passed = visual.get("passed")
actual_zones = [
{
"position": z.get("position"),
"template_id": z.get("template_id"),
"overflowed": z.get("overflowed"),
"clipped_inner_count": len(z.get("clipped_inner") or []),
}
for z in visual.get("zones", [])
]
assert slide_overflowed == expected["slide_overflowed"], (
f"{mdx_id}.mdx slide.overflowed drift: expected {expected['slide_overflowed']}, "
f"got {slide_overflowed}"
)
assert slide_body_overflowed == expected["slide_body_overflowed"], (
f"{mdx_id}.mdx slide_body.overflowed drift: expected {expected['slide_body_overflowed']}, "
f"got {slide_body_overflowed}"
)
assert visual_passed == expected["passed"], (
f"{mdx_id}.mdx visual_check.passed drift: expected {expected['passed']}, "
f"got {visual_passed}"
)
assert actual_zones == expected["zones"], (
f"{mdx_id}.mdx zone visual drift: expected {expected['zones']}, "
f"got {actual_zones}"
)
@pytest.mark.integration
@pytest.mark.parametrize("mdx_id", MDX_SET)
def test_coverage_snapshot_matches(
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
) -> None:
"""u5 — pin observed full_mdx_coverage + section_id parity against snapshot."""
snapshot = json.loads((SNAPSHOTS_DIR / "coverage.json").read_text(encoding="utf-8"))
expected = snapshot[mdx_id]
run = multi_mdx_runs[mdx_id]
status = json.loads(
(run.run_dir / "steps" / "step20_slide_status.json").read_text(encoding="utf-8")
)["data"]
assert status.get("rendered") == expected["rendered"], (
f"{mdx_id}.mdx rendered drift: expected {expected['rendered']}, "
f"got {status.get('rendered')}"
)
assert status.get("visual_check_passed") == expected["visual_check_passed"], (
f"{mdx_id}.mdx visual_check_passed drift: expected {expected['visual_check_passed']}, "
f"got {status.get('visual_check_passed')}"
)
assert status.get("full_mdx_coverage") == expected["full_mdx_coverage"], (
f"{mdx_id}.mdx full_mdx_coverage drift: expected {expected['full_mdx_coverage']}, "
f"got {status.get('full_mdx_coverage')}"
)
assert sorted(status.get("aligned_section_ids") or []) == sorted(expected["aligned_section_ids"]), (
f"{mdx_id}.mdx aligned_section_ids drift: expected {expected['aligned_section_ids']}, "
f"got {status.get('aligned_section_ids')}"
)
assert sorted(status.get("covered_section_ids") or []) == sorted(expected["covered_section_ids"]), (
f"{mdx_id}.mdx covered_section_ids drift: expected {expected['covered_section_ids']}, "
f"got {status.get('covered_section_ids')}"
)
assert sorted(status.get("filtered_section_ids") or []) == sorted(expected["filtered_section_ids"]), (
f"{mdx_id}.mdx filtered_section_ids drift: expected {expected['filtered_section_ids']}, "
f"got {status.get('filtered_section_ids')}"
)
@pytest.mark.integration
@pytest.mark.parametrize("mdx_id", MDX_SET)
def test_normalize_snapshot_matches(
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
) -> None:
"""u6 — F0 normalize: pin observed step02_normalized shape per mdx."""
snapshot = json.loads((SNAPSHOTS_DIR / "normalize.json").read_text(encoding="utf-8"))
expected = snapshot[mdx_id]
run = multi_mdx_runs[mdx_id]
raw = json.loads(
(run.run_dir / "steps" / "step02_normalized.json").read_text(encoding="utf-8")
)
d = raw["data"]
diag = d.get("stage0_adapter_diagnostics", {}) or {}
assets = d.get("stage0_normalized_assets", {}) or {}
actual = {
"step_num": raw.get("step_num"),
"step_status": raw.get("step_status"),
"pipeline_path_connected": raw.get("pipeline_path_connected"),
"sections_count": d.get("sections_count"),
"section_ids": [s.get("section_id") for s in d.get("sections", [])],
"orphans_count": len(d.get("orphans") or []),
"details_count": len(d.get("details") or []),
"adapter_enabled": diag.get("enabled"),
"adapter_used": diag.get("used"),
"assets_popups_count": len(assets.get("popups") or []),
"assets_images_count": len(assets.get("images") or []),
"assets_tables_count": len(assets.get("tables") or []),
"slide_title_nonempty": bool(d.get("slide_title")),
"slide_footer_nonempty": bool(d.get("slide_footer")),
}
for key, want in expected.items():
got = actual[key]
assert got == want, (
f"{mdx_id}.mdx normalize.{key} drift: expected {want!r}, got {got!r}"
)
assert len(d.get("sections", [])) == expected["sections_count"], (
f"{mdx_id}.mdx sections list length mismatch with sections_count: "
f"sections_count={expected['sections_count']}, got len(sections)={len(d.get('sections', []))}"
)
for sect in d.get("sections", []):
assert (sect.get("raw_content_length") or 0) > 0, (
f"{mdx_id}.mdx section {sect.get('section_id')!r} has empty raw_content "
f"(length={sect.get('raw_content_length')!r}) — normalize lost content"
)
@pytest.mark.integration
@pytest.mark.parametrize("mdx_id", MDX_SET)
def test_v4_ranking_snapshot_matches(
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
) -> None:
"""u7 — F1 V4 ranking: pin observed step05_v4_evidence per mdx.
Pins ``v4_source`` (POSIX-normalized for cross-platform stability),
``aligned_section_ids``, and per-section
``{section_id, candidate_status, candidates: [{template_id, label, confidence}]}``
in pipeline-emitted order. Confidence stays at the current 4-decimal
rounding emitted by the V4 yaml; drift any axis fails loudly so a
re-baseline is a conscious commit, not a silent shift.
"""
snapshot = json.loads((SNAPSHOTS_DIR / "v4_ranking.json").read_text(encoding="utf-8"))
expected = snapshot[mdx_id]
run = multi_mdx_runs[mdx_id]
raw = json.loads(
(run.run_dir / "steps" / "step05_v4_evidence.json").read_text(encoding="utf-8")
)
data = raw["data"]
actual_v4_source = str(data.get("v4_source") or "").replace("\\", "/")
actual_sections = [
{
"section_id": ev.get("section_id"),
"candidate_status": ev.get("candidate_status"),
"candidates": [
{
"template_id": c.get("template_id"),
"label": c.get("label"),
"confidence": c.get("confidence"),
}
for c in (ev.get("v4_candidates") or [])
],
}
for ev in (data.get("evidence_per_section") or [])
]
assert actual_v4_source == expected["v4_source"], (
f"{mdx_id}.mdx v4_source drift: expected {expected['v4_source']!r}, "
f"got {actual_v4_source!r}"
)
assert data.get("aligned_section_ids") == expected["aligned_section_ids"], (
f"{mdx_id}.mdx aligned_section_ids drift: expected {expected['aligned_section_ids']}, "
f"got {data.get('aligned_section_ids')}"
)
assert actual_sections == expected["sections"], (
f"{mdx_id}.mdx V4 ranking drift: expected {expected['sections']}, "
f"got {actual_sections}"
)
def _slot_payload_zone_shape(zone: dict) -> dict:
"""Reduce a step12 per_zone entry to a content-agnostic structural shape.
Pins builder + slot names + per-slot list cardinality + dict sub-list
counts + string non-empty flags. MDX text edits don't drift this; a
builder swap, slot rename, missing slot, or list-cardinality change
does. Sub-dict shape pins ``sections`` length only — deeper field
pinning would require a fresh u8'-axis snapshot.
"""
sp = zone.get("slot_payload") or {}
slot_names = sorted(sp.keys())
list_slot_counts: dict = {}
dict_slot_sub_counts: dict = {}
string_slot_nonempty: dict = {}
for name in slot_names:
value = sp[name]
if isinstance(value, list):
list_slot_counts[name] = len(value)
elif isinstance(value, dict):
sub: dict = {}
for sub_key, sub_val in value.items():
if isinstance(sub_val, list):
sub[sub_key] = len(sub_val)
dict_slot_sub_counts[name] = sub
elif isinstance(value, str):
string_slot_nonempty[name] = bool(value.strip())
return {
"position": zone.get("position"),
"template_id": zone.get("template_id"),
"builder": zone.get("builder"),
"slot_names": slot_names,
"list_slot_counts": list_slot_counts,
"dict_slot_sub_counts": dict_slot_sub_counts,
"string_slot_nonempty": string_slot_nonempty,
}
_AI_UNIT_KEYS = (
"source_section_ids", "label", "route_hint", "provisional",
"ai_called", "skip_reason", "apply_status",
)
@pytest.mark.integration
@pytest.mark.parametrize("mdx_id", MDX_SET)
def test_ai_classifier_snapshot_matches(
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
) -> None:
"""u9 — F3 classifier-only AI: pin step12/15/16/18 classifier signals.
[[feedback_ai_isolation_contract]] / [[feedback_demo_env_toggle_policy]]
central invariant: ``ai_called`` MUST stay False per unit by default;
activation requires explicit .env toggle, never pipeline default.
"""
snapshot = json.loads((SNAPSHOTS_DIR / "ai_classifier.json").read_text(encoding="utf-8"))
expected = snapshot[mdx_id]
steps = multi_mdx_runs[mdx_id].run_dir / "steps"
ai = json.loads((steps / "step12_ai_repair.json").read_text(encoding="utf-8"))["data"]
fit = json.loads((steps / "step15_fit_classification.json").read_text(encoding="utf-8"))["data"]
router = json.loads((steps / "step16_router_decision.json").read_text(encoding="utf-8"))["data"]
failure = json.loads((steps / "step18_failure_classification.json").read_text(encoding="utf-8"))["data"]
units = [{k: u.get(k) for k in _AI_UNIT_KEYS} for u in (ai.get("per_unit") or [])]
actual = {
"units": units,
"coverage_invariant_status": (ai.get("coverage_invariant") or {}).get("status"),
"fit_visual_check_passed": fit.get("visual_check_passed"),
"fit_classifications_count": len(fit.get("classifications") or []),
"fit_categories_seen": fit.get("categories_seen") or [],
"router_active": router.get("router_active"),
"router_routed_count": router.get("routed_count"),
"router_v4_fallback_used_count": (router.get("v4_fallback_summary") or {}).get("fallback_used_count"),
"failure_type": failure.get("failure_type"),
}
for key, want in expected.items():
assert actual[key] == want, (
f"{mdx_id}.mdx ai_classifier.{key} drift: expected {want!r}, got {actual[key]!r}"
)
breaches = [u for u in units if u["ai_called"] is not False]
assert not breaches, (
f"{mdx_id}.mdx F3 AI-isolation breach (ai_called must be False by default): {breaches}"
)
def _layout_zone_shape(zone: dict) -> dict:
"""Reduce a step08 per_zone_plan entry to a content-agnostic F4 layout shape."""
sub_zones = zone.get("sub_zones_planned") or []
return {
"position": zone.get("position"),
"min_height_px": zone.get("min_height_px"),
"frame_cardinality_strict": zone.get("frame_cardinality_strict"),
"sub_zones_count": len(sub_zones),
"region_layout_candidates": zone.get("region_layout_candidates") or [],
}
@pytest.mark.integration
@pytest.mark.parametrize("mdx_id", MDX_SET)
def test_layout_snapshot_matches(
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
) -> None:
"""u10 — F4 layout: pin step07_layout + step08_zone_region_ratios per mdx.
Pins the layout decision path (``layout_preset`` /
``auto_layout_preset`` / ``layout_override_applied`` /
``layout_candidates`` / ``computation``) + planning geometry
(``heights_px`` / ``widths_px`` / ``ratios`` / ``width_ratios``) +
per-zone planning shape (``position`` / ``min_height_px`` /
``frame_cardinality_strict`` / ``sub_zones_count`` /
``region_layout_candidates``). ``step_status='partial'`` is the
Step 7/8 schema-lock marker (region-level ratio + count-based v0).
mdx 03 is the only ``layout_override_applied=True`` case (vertical-2
user override per project_mdx03_frame_lock 2026-05-15 lock); drift
here flips F4 layer-A axis. mdx 04 ``top`` zone pins ``None`` for
min_height_px + frame_cardinality_strict (no frame cardinality on
the top zone — observed current state, not invented).
"""
snapshot = json.loads((SNAPSHOTS_DIR / "layout.json").read_text(encoding="utf-8"))
expected = snapshot[mdx_id]
run = multi_mdx_runs[mdx_id]
s7 = json.loads(
(run.run_dir / "steps" / "step07_layout.json").read_text(encoding="utf-8")
)
s8 = json.loads(
(run.run_dir / "steps" / "step08_zone_region_ratios.json").read_text(encoding="utf-8")
)
d7 = s7.get("data") or {}
d8 = s8.get("data") or {}
css = d7.get("layout_css") or {}
actual = {
"step7_step_status": s7.get("step_status"),
"step7_pipeline_path_connected": s7.get("pipeline_path_connected"),
"layout_preset": d7.get("layout_preset"),
"auto_layout_preset": d7.get("auto_layout_preset"),
"layout_override_applied": d7.get("layout_override_applied"),
"zones_count": d7.get("zones_count"),
"unit_count": d7.get("unit_count"),
"layout_candidates": d7.get("layout_candidates") or [],
"computation": css.get("computation"),
"dynamic_rows": css.get("dynamic_rows"),
"dynamic_cols": css.get("dynamic_cols"),
"heights_px": css.get("heights_px"),
"widths_px": css.get("widths_px"),
"ratios": css.get("ratios"),
"width_ratios": css.get("width_ratios"),
"step8_step_status": s8.get("step_status"),
"step8_pipeline_path_connected": s8.get("pipeline_path_connected"),
"zone_heights_px_planned": d8.get("zone_heights_px_planned"),
"zone_widths_px_planned": d8.get("zone_widths_px_planned"),
"zone_col_ratios_planned": d8.get("zone_col_ratios_planned"),
"per_zone_layout_shape": [
_layout_zone_shape(z) for z in (d8.get("per_zone_plan") or [])
],
}
for key, want in expected.items():
got = actual[key]
assert got == want, (
f"{mdx_id}.mdx layout.{key} drift: expected {want!r}, got {got!r}"
)
@pytest.mark.integration
@pytest.mark.parametrize("mdx_id", MDX_SET)
def test_slot_payload_snapshot_matches(
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
) -> None:
"""u8 — F2 slot_payload: pin observed step12_slot_payload per_zone shape per mdx.
Snapshot pins content-agnostic structural shape (builder + slot
names + list cardinality + dict sub-list counts + string non-empty
flags), not literal payload text. MDX wording tweaks won't drift
this; builder swap, slot rename, slot count drift, or __empty__
transitions will. Empty zones must have ``builder is None`` and no
slots — this is the IMP-87 empty_shell honesty contract surface for
F2.
"""
snapshot = json.loads((SNAPSHOTS_DIR / "slot_payload.json").read_text(encoding="utf-8"))
expected = snapshot[mdx_id]
run = multi_mdx_runs[mdx_id]
raw = json.loads(
(run.run_dir / "steps" / "step12_slot_payload.json").read_text(encoding="utf-8")
)
per_zone = raw["data"].get("per_zone") or []
actual = [_slot_payload_zone_shape(z) for z in per_zone]
assert len(actual) == len(expected), (
f"{mdx_id}.mdx step12 zone_count drift: expected {len(expected)}, "
f"got {len(actual)} (positions={[z.get('position') for z in actual]})"
)
for idx, (act, exp) in enumerate(zip(actual, expected)):
assert act == exp, (
f"{mdx_id}.mdx step12 zone[{idx}] ({exp.get('position')!r}) shape drift: "
f"expected {exp}, got {act}"
)
_ZONE_TAG_RE = re.compile(
r'<div[^>]*\sdata-zone-position="([^"]+)"[^>]*\sdata-template-id="([^"]+)"',
re.IGNORECASE,
)
_SLIDE_ROOT_RE = re.compile(r'<div\s+class="slide"\s+data-page="1"')
_TITLE_RE = re.compile(r'<title>([^<]*)</title>', re.IGNORECASE)
def _extract_html_zone_topology(html: str) -> List[dict]:
"""Extract (position, template_id) pairs in document order from final.html."""
return [
{"position": m.group(1), "template_id": m.group(2)}
for m in _ZONE_TAG_RE.finditer(html)
]
@pytest.mark.integration
@pytest.mark.parametrize("mdx_id", MDX_SET)
def test_final_html_snapshot_matches(
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
) -> None:
"""u11 — F5 final.html extraction: pin step13_render metadata + on-disk HTML structure.
Cross-snapshot parity gate: ``html_zone_topology`` (extracted from
final.html via ``data-zone-position`` / ``data-template-id`` markers)
MUST equal step12 slot_payload (u8) ``(position, template_id)``
sequence — Jinja2 renders from step12, not step09, so this is the
correct upstream parity (step09 selection vs step12 ``__empty__``
collapse is intentional per IMP-87 honesty gate and surfaces in u8).
Drift between final.html and slot_payload = render pipeline
disconnect. ``final.html`` on-disk size also MUST equal step13's
reported ``final_html_size_bytes`` — byte parity proves no
truncation / no double-write race.
"""
snapshot = json.loads((SNAPSHOTS_DIR / "final_html.json").read_text(encoding="utf-8"))
expected = snapshot[mdx_id]
run = multi_mdx_runs[mdx_id]
raw13 = json.loads(
(run.run_dir / "steps" / "step13_render.json").read_text(encoding="utf-8")
)
d13 = raw13.get("data") or {}
ri = d13.get("render_inputs") or {}
final_path = run.run_dir / "final.html"
assert final_path.is_file(), f"{mdx_id}.mdx final.html missing at {final_path}"
html = final_path.read_text(encoding="utf-8")
title_match = _TITLE_RE.search(html)
html_title = title_match.group(1).strip() if title_match else ""
html_topology = _extract_html_zone_topology(html)
actual = {
"step13_status": raw13.get("step_status"),
"step13_pipeline_path_connected": raw13.get("pipeline_path_connected"),
"render_inputs_zones_count": ri.get("zones_count"),
"render_inputs_layout_preset": ri.get("layout_preset"),
"render_inputs_slide_title_nonempty": bool((ri.get("slide_title") or "").strip()),
"render_inputs_slide_footer_nonempty": bool((ri.get("slide_footer") or "").strip()),
"html_title_matches_render_input": html_title == (ri.get("slide_title") or "").strip(),
"html_slide_root_count": len(_SLIDE_ROOT_RE.findall(html)),
"html_slide_footer_present": '<div class="slide-footer">' in html,
"html_zone_count": len(html_topology),
"html_zone_topology": html_topology,
"final_html_size_matches_step13_reported": (
final_path.stat().st_size == d13.get("final_html_size_bytes")
),
}
for key, want in expected.items():
assert actual[key] == want, (
f"{mdx_id}.mdx final_html.{key} drift: expected {want!r}, got {actual[key]!r}"
)
slot_payload = json.loads(
(SNAPSHOTS_DIR / "slot_payload.json").read_text(encoding="utf-8")
)[mdx_id]
slot_topology = [
{"position": z["position"], "template_id": z["template_id"]}
for z in slot_payload
]
assert html_topology == slot_topology, (
f"{mdx_id}.mdx render pipeline disconnect: final.html zone topology "
f"{html_topology} does not match step12 slot_payload topology "
f"{slot_topology} (pinned in slot_payload.json u8)"
)
@@ -0,0 +1,56 @@
fixture_id: synthetic_divergence
purpose: |
Backend - frontend "rank 1" divergence regression - IMP-39 (#68).
Captures the Stage 1 root-cause scenario where the legacy backend
(raw V4 confidence-desc order) selects a high-confidence
lower-priority label, while the frontend (LABEL_PRIORITY asc +
confidence desc) selects the lower-confidence higher-priority
label. The single-source ranking policy
(templates/phase_z2/catalog/ranking_sort_policy.yaml, u1) resolves
the divergence so that both sides agree on "rank 1".
source: synthetic
sample_agnostic: true
notes:
- No real frame_id / template_id / MDX section is referenced.
- Only the four sort keys matter: label, confidence, v4_full_rank.
- The `tag` field is a fixture-local identifier for assertions.
- Field name `v4_full_rank` mirrors v4_full32_result.yaml shape so
fixture and corpus audit (u8) share the same key contract.
raw_judgments:
# confidence is strictly descending so v4_full_rank == raw V4
# confidence-desc rank (same axis as v4_full32_result.yaml).
- tag: synth_restructure_high
label: restructure
confidence: 0.92
v4_full_rank: 1
- tag: synth_light_edit_mid
label: light_edit
confidence: 0.70
v4_full_rank: 2
- tag: synth_use_as_is_low
label: use_as_is
confidence: 0.41
v4_full_rank: 3
- tag: synth_reject_low
label: reject
confidence: 0.30
v4_full_rank: 4
expected_legacy_raw_order:
- synth_restructure_high
- synth_light_edit_mid
- synth_use_as_is_low
- synth_reject_low
expected_policy_sorted_order:
- synth_use_as_is_low
- synth_light_edit_mid
- synth_restructure_high
- synth_reject_low
divergence_axis:
pre_policy_rank_1_tag: synth_restructure_high
post_policy_rank_1_tag: synth_use_as_is_low
frontend_candidate_0_tag: synth_use_as_is_low
+106
View File
@@ -156,3 +156,109 @@ def test_top_1_bottom_2_dynamic_2d_populates_geometry():
assert result["dynamic_cols"] is True assert result["dynamic_cols"] is True
assert len(result["heights_px"]) == 2 # R rows assert len(result["heights_px"]) == 2 # R rows
assert len(result["widths_px"]) == 2 # C cols assert len(result["widths_px"]) == 2 # C cols
# ────────────────────── IMP-44 u5 regression ──────────────────────
# Regression coverage for the layout-override unknown-key guard
# (Stage 1 root-cause #73). Asserts that foreign-preset keys are
# dropped, structured [override-warning] is emitted, and
# computation=user_override_geometry is NEVER reported when the
# kept-key set is empty (no false override_applied=true).
def test_imp44_h2_with_v2_keys_emits_warning_and_falls_through(capsys):
"""horizontal-2 receiving vertical-2 keys (left/right) → all-unknown:
drop both, emit warning, fall through to dynamic dispatch.
computation must NOT be user_override_geometry."""
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
override = {
"left": {"x": 0, "y": 0, "w": 0.5, "h": 1.0},
"right": {"x": 0.5, "y": 0, "w": 0.5, "h": 1.0},
}
result = build_layout_css(
"horizontal-2", zones, override_zone_geometries=override
)
captured = capsys.readouterr()
assert "[override-warning]" in captured.err
assert "layout_preset=horizontal-2" in captured.err
assert "unknown_keys=['left', 'right']" in captured.err
assert "expected_positions=['top', 'bottom']" in captured.err
# All-unknown → no override applied (no silent fallback).
assert result["computation"] != "user_override_geometry"
raw = result.get("raw_zone_layout") or {}
if isinstance(raw, dict):
assert raw.get("override_applied") is not True
def test_imp44_v2_with_h2_keys_emits_warning_and_falls_through(capsys):
"""vertical-2 receiving horizontal-2 keys (top/bottom) → all-unknown:
drop both, emit warning, fall through to dynamic dispatch.
computation must NOT be user_override_geometry."""
zones = [_zone("left", 0.5), _zone("right", 0.5)]
override = {
"top": {"x": 0, "y": 0, "w": 1.0, "h": 0.3},
"bottom": {"x": 0, "y": 0.3, "w": 1.0, "h": 0.7},
}
result = build_layout_css(
"vertical-2", zones, override_zone_geometries=override
)
captured = capsys.readouterr()
assert "[override-warning]" in captured.err
assert "layout_preset=vertical-2" in captured.err
assert "unknown_keys=['bottom', 'top']" in captured.err
assert "expected_positions=['left', 'right']" in captured.err
assert result["computation"] != "user_override_geometry"
raw = result.get("raw_zone_layout") or {}
if isinstance(raw, dict):
assert raw.get("override_applied") is not True
def test_imp44_partial_mix_keeps_known_drops_unknown(capsys):
"""horizontal-2 receiving {top (known), left (unknown)}: keep top,
drop left, emit warning naming only 'left'. override_applied=True
must hold and the source must contain only the kept key."""
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
override = {
"top": {"x": 0, "y": 0, "w": 1.0, "h": 0.3},
"left": {"x": 0, "y": 0, "w": 0.5, "h": 1.0},
}
result = build_layout_css(
"horizontal-2", zones, override_zone_geometries=override
)
captured = capsys.readouterr()
assert "[override-warning]" in captured.err
assert "unknown_keys=['left']" in captured.err
# Known key applied → user_override_geometry computation.
assert result["computation"] == "user_override_geometry"
raw = result["raw_zone_layout"]
assert raw["override_applied"] is True
assert set(raw["source"].keys()) == {"top"}
# Sanity: top ratio (0.3) drives heights_px[0] < heights_px[1].
assert result["heights_px"][0] < result["heights_px"][1]
def test_imp44_2d_preset_with_h2_keys_emits_warning_and_falls_through(capsys):
"""2-D preset (top-1-bottom-2) receiving horizontal-2 keys
(top/bottom): all-unknown vs T positions
{top, bottom-left, bottom-right} → drop all, emit warning,
fall through to 2-D dynamic dispatch."""
zones = [
_zone("top", 0.5),
_zone("bottom-left", 0.25),
_zone("bottom-right", 0.25),
]
override = {
"bottom": {"x": 0, "y": 0.3, "w": 1.0, "h": 0.7},
}
result = build_layout_css(
"top-1-bottom-2", zones, override_zone_geometries=override
)
captured = capsys.readouterr()
assert "[override-warning]" in captured.err
assert "layout_preset=top-1-bottom-2" in captured.err
assert "unknown_keys=['bottom']" in captured.err
# All-unknown → 2-D dynamic fallback (not user_override_geometry).
assert result["computation"] == "2d_dynamic_aggregated"
raw = result.get("raw_zone_layout") or {}
if isinstance(raw, dict):
assert raw.get("override_applied") is not True
@@ -0,0 +1,299 @@
"""IMP-88 (#88) u2 — failure_router cascade extension tests.
Stage 2 binding contract (unit u2): the failure_router data-surface is
extended so the three Step 17 retry chain actions (layout_adjust, image_fit,
frame_internal_fit_candidate) participate in the deterministic cascade
WITHOUT activating any AI path or shrinking shared margins.
Producer surface (`SALVAGE_FAILURE_TYPE_BY_ACTION`):
- layout_adjust → layout_adjust_insufficient
- image_fit → image_fit_insufficient
- frame_internal_fit_candidate → frame_internal_fit_candidate_insufficient
Cascade extension (`NEXT_ACTION_BY_FAILURE`):
- layout_adjust_insufficient → frame_internal_fit_candidate
- frame_internal_fit_candidate_insufficient → frame_reselect
- image_fit_insufficient → layout_adjust
Implementation status surface (`NEXT_ACTION_IMPLEMENTATION_STATUS`):
- layout_adjust = IMPLEMENTED (u3 planner + u6 dispatcher + u7 entry)
- image_fit = IMPLEMENTED (u4 planner + u7 Step 17 single-pass entry)
- frame_internal_fit_candidate = IMPLEMENTED (u5 planner + u6 dispatcher + u7 entry)
- frame_reselect = MISSING (separate axis, out of IMP-88 scope)
- details_popup_escalation = MISSING here; flipped on the router surface
(src/phase_z2_router.py) by IMP-35 u3.
Existing rows from IMP-12 / IMP-35 (#62 / #64) are guarded against regression.
Post u7 completion (2026-05-24): status assertions in this file reflect the
IMPLEMENTED end-state. The `_registered_as_missing` test name is renamed to
`_registered_as_implemented_after_u7` so the surface contract is honest
about the post-u7 state.
"""
from __future__ import annotations
from src.phase_z2_failure_router import (
FAILURE_TYPE_DESCRIPTIONS,
NEXT_ACTION_BY_FAILURE,
NEXT_ACTION_IMPLEMENTATION_STATUS,
NEXT_ACTION_RATIONALE,
SALVAGE_FAILURE_TYPE_BY_ACTION,
classify_retry_failure,
enrich_retry_trace_with_failure_classification,
route_retry_failure,
)
# ─── FAILURE_TYPE_DESCRIPTIONS registry ──────────────────────────
def test_imp88_three_new_failure_type_descriptions_registered():
"""u2 registers three new failure_type descriptions for the Step 17
retry chain actions. Each entry is non-empty so trace consumers can
surface a human-readable failure reason."""
for ftype in (
"layout_adjust_insufficient",
"image_fit_insufficient",
"frame_internal_fit_candidate_insufficient",
):
assert ftype in FAILURE_TYPE_DESCRIPTIONS, (
f"u2 must register {ftype} in FAILURE_TYPE_DESCRIPTIONS"
)
assert FAILURE_TYPE_DESCRIPTIONS[ftype].strip(), (
f"FAILURE_TYPE_DESCRIPTIONS[{ftype!r}] must be non-empty"
)
# ─── SALVAGE_FAILURE_TYPE_BY_ACTION producers ─────────────────────
def test_imp88_three_new_salvage_failure_producers_registered():
"""u2 wires three new producers so when the u6 dispatcher emits a
salvage_steps[-1] entry whose action is one of the IMP-88 actions, the
classifier route lands on the correct failure_type instead of falling
through to the defensive not_attempted fallback."""
assert SALVAGE_FAILURE_TYPE_BY_ACTION["layout_adjust"] == (
"layout_adjust_insufficient"
)
assert SALVAGE_FAILURE_TYPE_BY_ACTION["image_fit"] == "image_fit_insufficient"
assert SALVAGE_FAILURE_TYPE_BY_ACTION["frame_internal_fit_candidate"] == (
"frame_internal_fit_candidate_insufficient"
)
def test_imp88_existing_salvage_producers_preserved():
"""Regression guard — IMP-12 / IMP-35 producers stay intact after u2."""
assert SALVAGE_FAILURE_TYPE_BY_ACTION["cross_zone_redistribute"] == (
"cross_zone_redistribute_insufficient"
)
assert SALVAGE_FAILURE_TYPE_BY_ACTION["glue_compression"] == (
"glue_absorption_insufficient"
)
assert SALVAGE_FAILURE_TYPE_BY_ACTION["font_step_compression"] == (
"font_step_insufficient"
)
assert SALVAGE_FAILURE_TYPE_BY_ACTION["frame_reselect"] == (
"frame_reselect_insufficient"
)
# ─── NEXT_ACTION_BY_FAILURE cascade ───────────────────────────────
def test_imp88_layout_adjust_insufficient_routes_to_frame_internal_fit():
"""Cascade extension: layout_adjust_insufficient → frame_internal_fit_candidate.
Closes the previously open cascade tail at layout_adjust (font_step_insufficient
→ layout_adjust was the last existing row that could land on layout_adjust;
after layout_adjust executed and failed there was no NEXT_ACTION row, so the
dispatcher would terminate). u2 adds the frame envelope internal-fit step
before frame_reselect.
"""
assert NEXT_ACTION_BY_FAILURE["layout_adjust_insufficient"] == (
"frame_internal_fit_candidate"
)
nr = route_retry_failure("layout_adjust_insufficient")
assert nr["next_proposed_action"] == "frame_internal_fit_candidate"
# frame_internal_fit_candidate is IMPLEMENTED after u5 planner + u6
# dispatcher branch + u7 cascade entry.
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
assert "frame_internal_fit_candidate" in (nr["next_action_rationale"] or "")
def test_imp88_frame_internal_fit_insufficient_routes_to_frame_reselect():
"""frame_internal_fit_candidate_insufficient → frame_reselect (V4 top-k swap).
Rejoins the existing rerender_still_fails → frame_reselect path mid-cascade.
"""
assert NEXT_ACTION_BY_FAILURE["frame_internal_fit_candidate_insufficient"] == (
"frame_reselect"
)
nr = route_retry_failure("frame_internal_fit_candidate_insufficient")
assert nr["next_proposed_action"] == "frame_reselect"
# frame_reselect is OUT of IMP-88 scope and stays MISSING (separate axis).
assert nr["next_action_implementation_status"] == "MISSING"
def test_imp88_image_fit_insufficient_routes_to_layout_adjust():
"""image_fit (Step 17 single-pass entry per u7) escalates onto the main
cascade at layout_adjust when the single-pass image fit transform cannot
resolve image_aspect_mismatch. Phase Z spacing direction guardrail —
no shared margin shrink, escalate through layout topology change instead.
"""
assert NEXT_ACTION_BY_FAILURE["image_fit_insufficient"] == "layout_adjust"
nr = route_retry_failure("image_fit_insufficient")
assert nr["next_proposed_action"] == "layout_adjust"
# layout_adjust is IMPLEMENTED after u3 planner + u6 dispatcher branch +
# u7 cascade entry. Cascade now flows end-to-end on the deterministic path.
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
# Rationale must not claim a margin shrink (Phase Z spacing direction
# guardrail anchored at feedback_phase_z_spacing_direction).
rationale = (nr["next_action_rationale"] or "").lower()
assert "shrink" not in rationale
assert "축소 x" in rationale or "축소x" in rationale or "spacing direction" in rationale
def test_imp88_existing_cascade_rows_preserved():
"""Regression guard — the IMP-12 / IMP-35 cascade rows stay intact after u2."""
assert NEXT_ACTION_BY_FAILURE["donor_slack_insufficient"] == "cross_zone_redistribute"
assert NEXT_ACTION_BY_FAILURE["no_donor_candidates"] == "cross_zone_redistribute"
assert NEXT_ACTION_BY_FAILURE["cross_zone_redistribute_insufficient"] == "glue_compression"
assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression"
assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
assert NEXT_ACTION_BY_FAILURE["rerender_still_fails"] == "frame_reselect"
assert NEXT_ACTION_BY_FAILURE["frame_reselect_insufficient"] == "details_popup_escalation"
assert NEXT_ACTION_BY_FAILURE["not_attempted"] == "none"
# ─── NEXT_ACTION_IMPLEMENTATION_STATUS surface ────────────────────
def test_imp88_new_next_action_destinations_registered_as_implemented_after_u7():
"""u2 registered the two new cascade destinations (initial MISSING). After
u7 completion the rows flip to IMPLEMENTED on the failure-router surface:
frame_internal_fit_candidate via u5 planner + u6 dispatcher + u7 cascade
entry; image_fit via u4 planner + u7 Step 17 single-pass entry. (Same
precedent as IMP-12 u7 cascade actions — planner-surface + orchestrator
wiring together constitute IMPLEMENTED on the deterministic surface.)"""
assert NEXT_ACTION_IMPLEMENTATION_STATUS["frame_internal_fit_candidate"] == "IMPLEMENTED"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["image_fit"] == "IMPLEMENTED"
def test_imp88_existing_implementation_status_preserved():
"""Regression guard — IMP-12 u7 + IMP-35 u3 status rows stay intact.
Post u7 completion, layout_adjust on the failure-router surface flips
to IMPLEMENTED alongside the primary router surface (u3 planner + u6
dispatcher + u7 cascade entry)."""
assert NEXT_ACTION_IMPLEMENTATION_STATUS["cross_zone_redistribute"] == "IMPLEMENTED"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "IMPLEMENTED"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["frame_reselect"] == "MISSING"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"] == "MISSING"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["none"] == "n/a"
# ─── End-to-end classifier + router path ──────────────────────────
def test_imp88_three_new_salvage_failures_classifier_path_end_to_end():
"""End-to-end: a salvage_steps[-1] entry with one of the three IMP-88
actions (layout_adjust / image_fit / frame_internal_fit_candidate) and
passed=False routes through classifier → router and lands on the
expected cascade next action. Confirms u2 producer + cascade rows are
wired through `classify_retry_failure` + `route_retry_failure` together.
"""
cases = [
(
"layout_adjust",
"layout_adjust_insufficient",
"frame_internal_fit_candidate",
),
(
"frame_internal_fit_candidate",
"frame_internal_fit_candidate_insufficient",
"frame_reselect",
),
(
"image_fit",
"image_fit_insufficient",
"layout_adjust",
),
]
for action, expected_ftype, expected_next in cases:
trace = {
"retry_attempted": True,
"retry_passed": False,
"salvage_passed": False,
"salvage_steps": [
{
"action": action,
"passed": False,
"failure_reason": f"{action} salvage failed (test)",
}
],
}
fc = classify_retry_failure(trace)
assert fc is not None, f"classifier returned None for action={action}"
assert fc["failure_type"] == expected_ftype, (
f"classifier emitted {fc['failure_type']!r} for action={action}, "
f"expected {expected_ftype!r}"
)
nr = route_retry_failure(fc["failure_type"])
assert nr["next_proposed_action"] == expected_next, (
f"router routed {fc['failure_type']!r} → {nr['next_proposed_action']!r}, "
f"expected {expected_next!r}"
)
def test_imp88_enrichment_composes_layout_adjust_insufficient_proposal():
"""End-to-end via enrich_retry_trace_with_failure_classification — the
deterministic Step 17 dispatcher will read these fields off the trace,
so verify the public wrapper attaches both failure_classification and
next_action_proposal correctly for the layout_adjust salvage path."""
trace = {
"retry_attempted": True,
"retry_passed": False,
"salvage_passed": False,
"salvage_steps": [
{
"action": "layout_adjust",
"passed": False,
"failure_reason": "layout_adjust preset switch did not fit",
}
],
}
enrich_retry_trace_with_failure_classification(trace)
assert trace["failure_classification"]["failure_type"] == (
"layout_adjust_insufficient"
)
assert trace["next_action_proposal"]["next_proposed_action"] == (
"frame_internal_fit_candidate"
)
# frame_internal_fit_candidate is IMPLEMENTED after u5 planner + u6
# dispatcher branch + u7 cascade entry.
assert trace["next_action_proposal"]["next_action_implementation_status"] == (
"IMPLEMENTED"
)
# ─── Rationale registry coverage ──────────────────────────────────
def test_imp88_three_new_failure_rationales_registered():
"""u2 registers a rationale entry for each new failure_type so the
enrichment wrapper emits a non-empty rationale (debug-trace usability)."""
for ftype in (
"layout_adjust_insufficient",
"image_fit_insufficient",
"frame_internal_fit_candidate_insufficient",
):
assert ftype in NEXT_ACTION_RATIONALE, (
f"u2 must register {ftype} in NEXT_ACTION_RATIONALE"
)
assert NEXT_ACTION_RATIONALE[ftype].strip(), (
f"NEXT_ACTION_RATIONALE[{ftype!r}] must be non-empty"
)
+437
View File
@@ -0,0 +1,437 @@
"""IMP-39 u8 (issue #68) - corpus audit over tests/matching/v4_full32_result.yaml.
Mirror-invariance regression on the REAL V4 full-32 judgments corpus
(``tests/matching/v4_full32_result.yaml``). For every MDX section in the
corpus, asserts that:
1. The backend ranking helper ``apply_ranking_sort`` (single-source
policy via ``templates/phase_z2/catalog/ranking_sort_policy.yaml``)
yields the same ordering as a Python mirror of the frontend
candidate sort (``Front/client/src/services/designAgentApi.ts``
warn-fallback path, lines 644-649). i.e. backend selector "rank 1"
== frontend ``frame_candidates[0]`` by construction across the
full corpus, with NO sample-specific carve-out.
2. The tie-break contract (label_priority asc, confidence desc,
v4_rank asc) holds when (label, confidence) ties occur in real
data (e.g. multi-restructure sections like 01-1 where rank=8
restructure rises above rank=5 reject under policy).
3. Real-data DIVERGENCE between raw V4 confidence-desc order and
policy-sorted order EXISTS in the corpus (audit honesty: proves
the policy is non-trivial on real samples, not just synthetic
u6 fixture).
Sample-agnostic axis (RULE 0 / RULE 7):
- The test iterates ``data['mdx_sections']`` keys dynamically; no
section ID (``01-2``, ``03-1``, ``04-2.1``, ...) is hardcoded as
an assertion target. The corpus inventory is treated as a
parametrize source, not a contract.
- The test does NOT assert any specific ``frame_id`` /
``template_id`` / ``frame_number``. Only the ordering contract
is asserted.
- The test does NOT depend on MDX 03/04/05 outcome / answer_map
correctness; it only validates that the policy is applied
uniformly across whatever sections the corpus happens to have.
Scope (u8, Stage 2 plan):
- Real-data sweep of ``tests/matching/v4_full32_result.yaml``
confirming backend / frontend mirror invariance under
``apply_ranking_sort`` + ``LABEL_PRIORITY`` mirror.
- Corpus uses ``v4_full_rank`` as the tie-break key, so calls pass
``v4_rank_key="v4_full_rank"`` (matching u2 selector wiring).
Out of scope (other units):
- u1 policy yaml shape: covered by ``test_ranking_sort_policy.py``.
- u2 selector wiring: integration covered indirectly via u7.
- u3 Step 9 payload forwarding: covered by u7.
- u4 frontend mirror: covered by u7.
- u5 pure permutation tests.
- u6 SYNTHETIC divergence fixture
(``tests/phase_z2/test_label_priority_synthetic.py``).
- u7 mdx04 env-toggle e2e
(``tests/phase_z2/test_imp39_mdx04_env_toggle_e2e.py``).
- V4 matching algorithm correctness (out of #68 scope, owner #5).
- ``MVP1_ALLOWED_STATUSES`` gate semantics (IMP-47B locked area).
- capacity-fit / catalog contract validation (orthogonal to policy).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
import pytest
import yaml
_REPO_ROOT = Path(__file__).resolve().parents[2]
_CORPUS_PATH = _REPO_ROOT / "tests" / "matching" / "v4_full32_result.yaml"
# Frontend LABEL_PRIORITY verbatim mirror — Front/client/src/services/
# designAgentApi.ts:575-580 + warn-fallback sort :644-649. Kept inline (not
# imported from python policy) so this audit catches drift if the frontend
# TS constant ever diverges from the yaml policy. The yaml-shape equality
# is exercised separately in test_ranking_sort_policy.py (u5).
_FRONTEND_LABEL_PRIORITY: Dict[str, int] = {
"use_as_is": 0,
"light_edit": 1,
"restructure": 2,
"reject": 3,
}
_FRONTEND_UNKNOWN_PRIORITY = 99
@pytest.fixture(autouse=True)
def _reset_policy_cache():
"""Mirror peer-test isolation - clear the cached single-source policy."""
import src.phase_z2_pipeline as pipeline
pipeline._RANKING_SORT_POLICY_CACHE = None
yield
pipeline._RANKING_SORT_POLICY_CACHE = None
@pytest.fixture(scope="module")
def corpus() -> Dict[str, Any]:
"""Load v4_full32_result.yaml exactly once per test module run."""
assert _CORPUS_PATH.exists(), (
f"Corpus audit source missing: {_CORPUS_PATH}. u8 requires "
f"tests/matching/v4_full32_result.yaml present in repo."
)
with _CORPUS_PATH.open(encoding="utf-8") as f:
return yaml.safe_load(f)
@pytest.fixture(scope="module")
def section_ids(corpus) -> List[str]:
"""Dynamic section inventory — NOT hardcoded.
Source = ``corpus['mdx_sections'].keys()``. The test asserts the
set is non-empty and each entry has a populated ``judgments_full32``
list. Section IDs themselves are treated as parametrize values, not
assertion targets.
"""
return list(corpus["mdx_sections"].keys())
def _frontend_mirror_sort(
judgments: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Pure-Python mirror of frontend warn-fallback ordering.
Mirrors ``Front/client/src/services/designAgentApi.ts:644-649``:
v4Source.sort((a, b) => {
const lp = (LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99);
if (lp !== 0) return lp;
return (b.confidence ?? 0) - (a.confidence ?? 0);
});
NOTE on tie-break: the frontend warn-fallback path lacks the
explicit v4_rank tie-break the backend policy carries (yaml
``tie_break_axes: [confidence_desc, v4_rank_asc]``). When (label,
confidence) are both equal, the frontend ``Array.prototype.sort``
is now stable (ES2019), so original order is preserved. Backend
``apply_ranking_sort`` also uses Python's stable Timsort and adds
``v4_rank asc`` only as a positive tie-break which agrees with raw
V4 order (v4_rank=1 first, raw V4 ordering is confidence-desc =
same as input). Net effect: identical ordering across both paths
on the real corpus. The audit below verifies this empirically.
"""
return sorted(
judgments,
key=lambda j: (
_FRONTEND_LABEL_PRIORITY.get(j.get("label"), _FRONTEND_UNKNOWN_PRIORITY),
-float(j.get("confidence", 0.0)),
),
)
def _identity_key(judgment: Dict[str, Any]) -> tuple:
"""Stable identity for a corpus judgment row.
``v4_full_rank`` is unique per section (1..32), so it serves as the
section-local identity. Wrapped in a tuple with ``frame_number`` /
``template_id`` for diagnostic richness in assert messages (these
extras are NOT used to derive ordering; only for failure diagnosis).
"""
return (
judgment.get("v4_full_rank"),
judgment.get("frame_number"),
judgment.get("template_id"),
)
# ─── corpus shape sanity ───────────────────────────────────────────────
def test_corpus_file_is_present_and_non_empty(corpus, section_ids):
"""RULE 5 factual: corpus path + section inventory both surface up."""
assert isinstance(corpus, dict)
assert "mdx_sections" in corpus
assert len(section_ids) > 0, (
f"v4_full32_result.yaml has zero mdx_sections — corpus audit "
f"cannot run. Path: {_CORPUS_PATH}"
)
for sec_id in section_ids:
section = corpus["mdx_sections"][sec_id]
judgments = section.get("judgments_full32")
assert isinstance(judgments, list) and len(judgments) > 0, (
f"Section {sec_id}: judgments_full32 missing or empty."
)
# Every judgment must carry the four sort-relevant fields.
for j in judgments:
assert "label" in j, f"{sec_id}: judgment missing 'label'."
assert "confidence" in j, f"{sec_id}: judgment missing 'confidence'."
assert "v4_full_rank" in j, (
f"{sec_id}: judgment missing 'v4_full_rank' (tie-break key)."
)
# ─── backend ↔ frontend mirror invariance ───────────────────────────────
def test_backend_policy_sort_matches_frontend_mirror_per_section(
corpus, section_ids,
):
"""Per-section: backend ``apply_ranking_sort`` == frontend mirror order."""
from src.phase_z2_pipeline import apply_ranking_sort
divergences: List[str] = []
for sec_id in section_ids:
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
backend_sorted = apply_ranking_sort(
judgments,
v4_rank_key="v4_full_rank",
)
frontend_sorted = _frontend_mirror_sort(judgments)
backend_keys = [_identity_key(j) for j in backend_sorted]
frontend_keys = [_identity_key(j) for j in frontend_sorted]
if backend_keys != frontend_keys:
divergences.append(
f"section={sec_id} backend_head={backend_keys[0]} "
f"frontend_head={frontend_keys[0]} "
f"first_divergence_index="
f"{next((i for i, (a, b) in enumerate(zip(backend_keys, frontend_keys)) if a != b), 'tail')}"
)
assert not divergences, (
"backend ↔ frontend mirror divergence on real corpus:\n "
+ "\n ".join(divergences)
)
def test_backend_rank_1_equals_frontend_candidate_0_per_section(
corpus, section_ids,
):
"""Stage 1 root-cause head-of-list invariant on every corpus section."""
from src.phase_z2_pipeline import apply_ranking_sort
head_mismatches: List[str] = []
for sec_id in section_ids:
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
backend_rank_1 = apply_ranking_sort(
judgments,
v4_rank_key="v4_full_rank",
)[0]
frontend_candidate_0 = _frontend_mirror_sort(judgments)[0]
if _identity_key(backend_rank_1) != _identity_key(frontend_candidate_0):
head_mismatches.append(
f"section={sec_id} "
f"backend_rank_1={_identity_key(backend_rank_1)} "
f"frontend_candidate_0={_identity_key(frontend_candidate_0)}"
)
assert not head_mismatches, (
"backend selector 'rank 1' diverges from frontend frame_candidates[0]:\n "
+ "\n ".join(head_mismatches)
)
# ─── tie-break + label-priority contract on real data ──────────────────
def test_policy_ordering_respects_label_priority_per_section(
corpus, section_ids,
):
"""``label_priority`` weakly monotone across the policy-sorted list."""
from src.phase_z2_pipeline import apply_ranking_sort
violations: List[str] = []
for sec_id in section_ids:
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
sorted_judgments = apply_ranking_sort(
judgments,
v4_rank_key="v4_full_rank",
)
priorities = [
_FRONTEND_LABEL_PRIORITY.get(j["label"], _FRONTEND_UNKNOWN_PRIORITY)
for j in sorted_judgments
]
for i in range(len(priorities) - 1):
if priorities[i] > priorities[i + 1]:
violations.append(
f"section={sec_id} idx={i} prio={priorities[i]} > "
f"idx={i + 1} prio={priorities[i + 1]}"
)
break
assert not violations, (
"label_priority must be weakly monotone post-sort:\n "
+ "\n ".join(violations)
)
def test_policy_confidence_desc_within_label_group_per_section(
corpus, section_ids,
):
"""Within same label, confidence must be weakly descending."""
from src.phase_z2_pipeline import apply_ranking_sort
violations: List[str] = []
for sec_id in section_ids:
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
sorted_judgments = apply_ranking_sort(
judgments,
v4_rank_key="v4_full_rank",
)
for i in range(len(sorted_judgments) - 1):
a, b = sorted_judgments[i], sorted_judgments[i + 1]
if a["label"] != b["label"]:
continue
if float(a["confidence"]) < float(b["confidence"]):
violations.append(
f"section={sec_id} idx={i} label={a['label']} "
f"conf={a['confidence']} < idx={i + 1} conf={b['confidence']}"
)
break
assert not violations, (
"confidence must be weakly desc within same-label runs:\n "
+ "\n ".join(violations)
)
def test_policy_v4_full_rank_asc_within_label_confidence_ties(
corpus, section_ids,
):
"""When (label, confidence) tie, smaller v4_full_rank first.
Real-data tie-break check. If no section in the corpus exhibits a
(label, confidence) tie, the test passes vacuously — this is the
correct contract: we only assert the tie-break behaviour where
it can actually be observed in the real data. Pure-permutation
tie-break coverage is owned by u5
(``test_v4_rank_asc_tie_break_on_equal_confidence``).
"""
from src.phase_z2_pipeline import apply_ranking_sort
tie_break_violations: List[str] = []
for sec_id in section_ids:
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
sorted_judgments = apply_ranking_sort(
judgments,
v4_rank_key="v4_full_rank",
)
for i in range(len(sorted_judgments) - 1):
a, b = sorted_judgments[i], sorted_judgments[i + 1]
if a["label"] != b["label"]:
continue
if float(a["confidence"]) != float(b["confidence"]):
continue
if int(a["v4_full_rank"]) > int(b["v4_full_rank"]):
tie_break_violations.append(
f"section={sec_id} idx={i} v4_full_rank={a['v4_full_rank']} "
f"> idx={i + 1} v4_full_rank={b['v4_full_rank']} "
f"(label={a['label']} conf={a['confidence']})"
)
assert not tie_break_violations, (
"v4_full_rank must be weakly asc within (label, conf) ties:\n "
+ "\n ".join(tie_break_violations)
)
# ─── audit honesty: real divergence exists ─────────────────────────────
def test_corpus_exhibits_real_policy_divergence(corpus, section_ids):
"""At least one section MUST show raw-V4-order != policy-order.
Honesty check (RULE 5): the corpus audit is meaningful only if the
policy actually changes some real section's ordering. If every
section already sorts the same way under raw V4 confidence-desc
AND under the policy, then the policy is a no-op on this corpus
and we should know about it — either the corpus needs richer
samples or the divergence axis has shifted.
Currently observed (2026-05-24) raw-vs-policy mid-list divergence:
sections with multi-label diversity where a lower-confidence
higher-priority candidate sits behind a higher-confidence
lower-priority one (e.g. section 01-1 has rank=8 restructure
rising above rank=5/6/7 rejects under policy).
"""
from src.phase_z2_pipeline import apply_ranking_sort
any_divergence = False
for sec_id in section_ids:
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
# Raw V4 order: rows are stored in v4_full_rank asc (= confidence desc).
raw_keys = [_identity_key(j) for j in judgments]
policy_keys = [
_identity_key(j)
for j in apply_ranking_sort(judgments, v4_rank_key="v4_full_rank")
]
if raw_keys != policy_keys:
any_divergence = True
break
assert any_divergence, (
"No corpus section shows raw-V4 vs policy ordering divergence. "
"The policy is a no-op on this corpus — either re-curate the "
"corpus or re-validate the divergence axis."
)
# ─── determinism + non-mutation on real corpus ─────────────────────────
def test_policy_sort_is_deterministic_across_calls_per_section(
corpus, section_ids,
):
"""Two consecutive calls on the same section yield identical ordering."""
from src.phase_z2_pipeline import apply_ranking_sort
for sec_id in section_ids:
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
first = [
_identity_key(j)
for j in apply_ranking_sort(judgments, v4_rank_key="v4_full_rank")
]
second = [
_identity_key(j)
for j in apply_ranking_sort(judgments, v4_rank_key="v4_full_rank")
]
assert first == second, (
f"section={sec_id}: apply_ranking_sort is non-deterministic "
f"across calls."
)
def test_corpus_input_lists_are_not_mutated(corpus, section_ids):
"""Corpus rows survive ``apply_ranking_sort`` unchanged in place."""
from src.phase_z2_pipeline import apply_ranking_sort
for sec_id in section_ids:
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
snapshot = [_identity_key(j) for j in judgments]
apply_ranking_sort(judgments, v4_rank_key="v4_full_rank")
post = [_identity_key(j) for j in judgments]
assert snapshot == post, (
f"section={sec_id}: apply_ranking_sort mutated source list "
f"in place (forbidden — see u5 non-mutation contract)."
)
@@ -0,0 +1,332 @@
"""IMP-39 u7 (issue #68) — mdx04 env-toggle e2e (AI_FALLBACK_ENABLED=off).
Stage 2 u7 axis G:
Run ``python -m src.phase_z2_pipeline samples/mdx_batch/04.mdx <run_id>``
with ``AI_FALLBACK_ENABLED=off`` and assert that the backend selector's
"rank 1" view agrees with the frontend ``frame_candidates[0]`` view —
i.e., the Stage 1 root-cause divergence (Backend src/phase_z2_pipeline.py
raw-confidence-desc iteration vs Frontend Front/client/src/services/
designAgentApi.ts label-priority resort) cannot recur once both sides
consume the single-source ranking_sort_policy.yaml contract (u1) via the
Step 9 payload (u3) and the frontend primary-path mirror (u4).
Out of scope (per Stage 2 lock):
* The IMP-85 mdx04 BuilderMissingError downstream surface — covered by
``tests/test_pipeline_smoke_imp85.py``. This e2e does NOT pin the
subprocess returncode; mdx04 may exit non-zero post-IMP-85 routing
while still emitting ``step09_application_plan.json`` whose unit
payload is what u3/u4 contract on.
* MVP1_ALLOWED_STATUSES gate / v4_fallback_policy max-rank /
capacity-fit / AI restructure / cache carve-out (IMP-46) / Phase Z
spacing semantics — all unchanged by IMP-39.
* Pure-permutation helper coverage (tests/test_ranking_sort_policy.py
u5) and the SYNTHETIC divergence regression
(tests/phase_z2/test_label_priority_synthetic.py u6).
* Corpus audit over v4_full32_result.yaml — u8.
Demo env toggle policy (feedback_demo_env_toggle_policy 2026-05-08):
The subprocess is spawned with an EXPLICIT
``env={..., "AI_FALLBACK_ENABLED": "false"}`` override even though
tests/conftest.py already sets the parent-process default to false.
This keeps the toggle expectation visible at the test level and
matches the .env-only activation policy (the .env file ships with
``AI_FALLBACK_ENABLED=true``; the test isolates the off path).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import uuid
from pathlib import Path
from typing import Any
import pytest
import yaml
from src.phase_z2_pipeline import apply_ranking_sort, load_ranking_sort_policy
_REPO_ROOT = Path(__file__).resolve().parents[2]
_SAMPLE_MDX = _REPO_ROOT / "samples" / "mdx_batch" / "04.mdx"
_RUNS_DIR = _REPO_ROOT / "data" / "runs"
_POLICY_YAML = (
_REPO_ROOT
/ "templates"
/ "phase_z2"
/ "catalog"
/ "ranking_sort_policy.yaml"
)
# Mirrors Front/client/src/services/designAgentApi.ts :567 — frontend slices
# the dedup'd v4Source to this many candidates. The test asserts that the
# frontend frame_candidates[0] mirror still equals sorted_candidate_evidence[0]
# for any TOP_N_FRAMES >= 1, but we honor the precise frontend constant so
# the dedup-then-slice path is exercised verbatim (not paraphrased).
_FRONTEND_TOP_N_FRAMES = 6
def _frontend_frame_candidates(sorted_evidence: list[dict]) -> list[dict]:
"""Pure-Python mirror of Front/client/src/services/designAgentApi.ts
:586-650 primary path:
const candidateMap = new Map<string, any>();
const pushCandidate = (c: any) => {
if (!c) return;
const key = c.template_id ?? c.id ?? c.frame_id;
if (!key) return;
if (!candidateMap.has(key)) candidateMap.set(key, c);
};
sortedCandidateEvidence!.forEach(pushCandidate);
v4Source = Array.from(candidateMap.values());
frameCandidates = v4Source.slice(0, TOP_N_FRAMES);
Same first-occurrence-wins dedup ordering, same slice cap, same key
fallback chain. Kept inline (no shared util) so a TS-side refactor that
diverges the contract is forced to update this mirror explicitly.
"""
seen: dict[Any, dict] = {}
for c in sorted_evidence:
if not isinstance(c, dict):
continue
key = c.get("template_id") or c.get("id") or c.get("frame_id")
if key is None or key == "":
continue
if key not in seen:
seen[key] = c
return list(seen.values())[:_FRONTEND_TOP_N_FRAMES]
@pytest.fixture(scope="module")
def mdx04_env_toggle_run() -> dict:
"""Single subprocess run shared across u7 assertions.
Returns ``{"run_id": ..., "completed_process": ..., "plan_payload": ...}``.
The IMP-85 downstream surface may push returncode != 0 for mdx04 (out of
scope here) — we still expect ``step09_application_plan.json`` to be
emitted, because u3 forwards the payload before any IMP-85 builder-fit
path. The fixture xfails if mdx04 does not even reach step09.
"""
assert _SAMPLE_MDX.exists(), f"sample missing: {_SAMPLE_MDX}"
run_id = f"imp39_u7_mdx04_{uuid.uuid4().hex[:8]}"
env = dict(os.environ)
env["AI_FALLBACK_ENABLED"] = "false"
env["AI_FALLBACK_AUTO_CACHE"] = "false"
cp = subprocess.run(
[
sys.executable,
"-m",
"src.phase_z2_pipeline",
str(_SAMPLE_MDX),
run_id,
],
capture_output=True,
text=True,
timeout=240,
cwd=str(_REPO_ROOT),
env=env,
)
plan_path = (
_RUNS_DIR
/ run_id
/ "phase_z2"
/ "steps"
/ "step09_application_plan.json"
)
if not plan_path.is_file():
pytest.xfail(
"mdx04 subprocess did not emit step09_application_plan.json "
f"(IMP-85 area, out of scope for u7). returncode={cp.returncode}\n"
f"--- stderr tail ---\n{cp.stderr[-1500:]}\n"
f"--- stdout tail ---\n{cp.stdout[-1500:]}"
)
plan_payload = json.loads(plan_path.read_text(encoding="utf-8"))
return {
"run_id": run_id,
"completed_process": cp,
"plan_payload": plan_payload,
}
def _units_with_v4(plan_payload: dict) -> list[dict]:
units = (plan_payload.get("data") or {}).get("units") or []
return [
u
for u in units
if isinstance(u.get("sorted_candidate_evidence"), list)
and u["sorted_candidate_evidence"]
]
def test_mdx04_env_toggle_step9_emits_u3_payload_fields(mdx04_env_toggle_run):
"""Every Step 9 unit in the mdx04 e2e run carries the u3 additive fields
(``ranking_sort_policy`` + ``sorted_candidate_evidence``).
Locks: u3 payload forwarding (src/phase_z2_pipeline.py :4163-4164) is
exercised by the real subprocess path on mdx04, not just an in-process
helper smoke. Without this gate the u4 frontend primary path silently
degrades to the LABEL_PRIORITY warn-fallback and the Stage 1 divergence
can re-surface on legacy data.
"""
plan = mdx04_env_toggle_run["plan_payload"]
units = (plan.get("data") or {}).get("units") or []
assert units, "mdx04 application_plan emitted zero units"
yaml_policy = yaml.safe_load(_POLICY_YAML.read_text(encoding="utf-8"))
expected_policy_type = yaml_policy["policy_type"]
expected_label_priority = yaml_policy["label_priority"]
expected_unknown = yaml_policy["unknown_label_priority"]
expected_tie_break = yaml_policy["tie_break_axes"]
for u in units:
assert "ranking_sort_policy" in u, (
f"unit {u.get('unit_id')!r} missing ranking_sort_policy "
"(u3 payload forwarding regressed)"
)
assert "sorted_candidate_evidence" in u, (
f"unit {u.get('unit_id')!r} missing sorted_candidate_evidence "
"(u3 payload forwarding regressed)"
)
pol = u["ranking_sort_policy"]
assert pol.get("policy_type") == expected_policy_type
assert pol.get("label_priority") == expected_label_priority
assert pol.get("unknown_label_priority") == expected_unknown
assert pol.get("tie_break_axes") == expected_tie_break
def test_mdx04_sorted_candidate_evidence_is_policy_sorted(mdx04_env_toggle_run):
"""``unit.sorted_candidate_evidence`` is already in policy order — i.e.,
``apply_ranking_sort(evidence)`` is a no-op (idempotent).
This pins the u2 selector ordering invariant
(src/phase_z2_pipeline.py :1186-1196 sorts ``judgments`` BEFORE the
selector loop appends candidate_trace entries) against the real mdx04
pipeline path. Any future change that re-sorts the trace post-iteration
or appends out-of-order would fail this assertion.
"""
plan = mdx04_env_toggle_run["plan_payload"]
units_with_v4 = _units_with_v4(plan)
assert units_with_v4, (
"mdx04 application_plan units have no V4 evidence; cannot evaluate "
"the sort-idempotency invariant"
)
policy = load_ranking_sort_policy()
for u in units_with_v4:
evidence = u["sorted_candidate_evidence"]
resorted = apply_ranking_sort(
evidence,
policy=policy,
label_key="label",
confidence_key="confidence",
v4_rank_key="v4_full_rank",
)
order_in = [
(c.get("label"), c.get("confidence"), c.get("template_id"))
for c in evidence
]
order_out = [
(c.get("label"), c.get("confidence"), c.get("template_id"))
for c in resorted
]
assert order_in == order_out, (
f"unit {u.get('unit_id')!r} sorted_candidate_evidence is not in "
f"policy order (u2 selector-loop ordering regressed):\n"
f" observed: {order_in[:6]}\n"
f" expected: {order_out[:6]}"
)
def test_mdx04_backend_frontend_rank_one_mirror(mdx04_env_toggle_run):
"""Stage 1 root-cause regression guard: backend "rank 1" view ≡
frontend ``frame_candidates[0]`` view on real mdx04 data.
Backend view = ``sorted_candidate_evidence[0]`` (policy-sorted selector
trace head — what the selector saw at iteration 1 of u2's sorted loop).
Frontend view = first entry of the dedup-then-slice mirror computed by
``_frontend_frame_candidates`` (Front/client/src/services/designAgentApi.ts
:586-661 primary path verbatim).
These two MUST refer to the same V4 candidate (matched on
``(template_id, label, confidence)``) for every unit emitted by the mdx04
pipeline run under ``AI_FALLBACK_ENABLED=off``. A mismatch here is the
exact post-fix surface of the Stage 1 root-cause divergence; the test is
sample-agnostic in its assertion (the divergence is structurally
impossible once both sides share the same source, not because mdx04
specifically lacks the divergence shape).
"""
plan = mdx04_env_toggle_run["plan_payload"]
units_with_v4 = _units_with_v4(plan)
assert units_with_v4, "no V4-bearing units in mdx04 application_plan"
for u in units_with_v4:
evidence = u["sorted_candidate_evidence"]
backend_head = evidence[0]
frontend_candidates = _frontend_frame_candidates(evidence)
assert frontend_candidates, (
f"unit {u.get('unit_id')!r}: frontend dedup mirror produced "
"an empty frame_candidates list (key fallback chain regressed)"
)
frontend_head = frontend_candidates[0]
backend_key = (
backend_head.get("template_id"),
backend_head.get("label"),
backend_head.get("confidence"),
)
frontend_key = (
frontend_head.get("template_id"),
frontend_head.get("label"),
frontend_head.get("confidence"),
)
assert backend_key == frontend_key, (
f"unit {u.get('unit_id')!r} backend rank-1 ≠ frontend "
f"frame_candidates[0]:\n"
f" backend : {backend_key}\n"
f" frontend : {frontend_key}\n"
" → Stage 1 root-cause divergence has re-surfaced; check u2/u3/u4 wiring."
)
def test_mdx04_application_status_ok_unit_selects_sorted_head(
mdx04_env_toggle_run,
):
"""When a unit's selector actually chose a real (non-provisional)
candidate (``application_status == "ok"`` and
``selection_path == "rank_1"``), the chosen frame must be
``sorted_candidate_evidence[0]``.
The candidate_evidence entry with ``decision == "selected"`` is the
selector's resolved choice; under u2 the loop iterates policy-sorted
order, so the head of ``sorted_candidate_evidence`` is the first
iteration. If the head is "selected" the invariant holds; the test
silently passes when no unit in this mdx04 run hits ok+rank_1 (the
scenario is sample-shape dependent and not contractually guaranteed
on every mdx04 emission).
"""
plan = mdx04_env_toggle_run["plan_payload"]
units_with_v4 = _units_with_v4(plan)
checked = 0
for u in units_with_v4:
if u.get("application_status") != "ok":
continue
if u.get("selection_path") != "rank_1":
continue
evidence = u["sorted_candidate_evidence"]
head = evidence[0]
selected_entries = [
c for c in evidence if c.get("decision") == "selected"
]
assert selected_entries, (
f"unit {u.get('unit_id')!r} has application_status=ok + "
"selection_path=rank_1 but no candidate_trace entry is marked "
"decision=selected (selector trace shape regressed)"
)
selected = selected_entries[0]
assert selected.get("template_id") == head.get("template_id"), (
f"unit {u.get('unit_id')!r}: backend selected template_id "
f"{selected.get('template_id')!r} ≠ sorted_candidate_evidence[0]"
f".template_id {head.get('template_id')!r}; u2 selector-loop "
"order must place the selected candidate at index 0"
)
checked += 1
# No hard floor — mdx04's V4 mix at the time of this test may yield zero
# ok+rank_1 units (sample-shape contingent). The mirror invariance above
# is the binding contract; this test is the stricter sub-invariant that
# only fires when a unit hits the ok+rank_1 path.
assert checked >= 0
@@ -0,0 +1,323 @@
"""IMP-40 u4 (issue #69) — synthetic role-policy matrix for
``_build_compare_table_2col`` label-default discriminator.
Stage 2 u4 contract (verbatim)::
Add synthetic role-policy tests for placeholder, fallback, absent role,
and unknown role using minimal Section plus contract inputs.
Why this is load-bearing
========================
u1 (frame_contracts.yaml F18) and u2 (F30/F31) opt the catalog into the
new ``{col_key}_label_default_role`` discriminator. u3 (mapper) implements
the runtime branch::
role == "placeholder" → col_{a,b}_label = "" (Figma visual
placeholder
suppressed)
role == "fallback" → col_{a,b}_label = catalog literal
(legacy behavior)
role absent → defaults to "fallback" (backward compat
for legacy
contracts)
role unknown → ValueError (no silent
miscategorization)
This file exercises that 4-row policy matrix at the
``_build_compare_table_2col`` boundary with **synthetic** Section + contract
inputs. No reliance on the YAML catalog, no sample-specific frame ids,
no MDX 03 / 04 / 05 literals. Catalog-vs-mapper drift detection is the
job of the integration snapshot path (u6), not this unit.
Scope (u4, Stage 2 plan)
========================
* 4 policy rows: placeholder / fallback / absent / unknown.
* ``SimpleNamespace`` Section stub (mirrors the pattern in
``tests/test_phase_z2_mapper_builder_missing.py``).
* Inline contract dicts — no catalog import.
* ``title`` slot omitted (``_resolve_title`` returns ``{}`` when
``payload.title.source`` is absent — verified at
``src/phase_z2_mapper.py:371-382``); keeps the assertion surface focused
on ``col_a_label`` / ``col_b_label`` resolution.
* Synthetic catalog literals (``LITERAL_COL_A`` / ``LITERAL_COL_B``) keep
the assertion sample-agnostic — the policy mechanism is the invariant,
not any specific Figma placeholder string.
Out of scope (other units)
==========================
* u1 catalog F18 role keys: covered by the integration snapshot drift in
u6 + the catalog-shape check via grep.
* u2 catalog F30 / F31 role keys: catalog-only, no runtime builder yet
(``compare_table_3col`` builder activation is a downstream follow-up
recorded in Stage 2 ``follow_up_candidates``).
* u5 F18-reuse regression with non-BIM/DX top_bullets content: separate
unit in the same file.
* u6 mdx 01 F18 ``slot_payload`` snapshot refresh.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from src.phase_z2_mapper import _build_compare_table_2col
# ─── Synthetic helpers ─────────────────────────────────────────────
_LITERAL_COL_A = "LITERAL_COL_A"
_LITERAL_COL_B = "LITERAL_COL_B"
def _make_section(raw_content: str = ""):
"""Minimal Section stub — only the attributes the builder reads.
``_build_compare_table_2col`` only touches ``section`` indirectly via
``_resolve_title``, which is a no-op when ``payload.title.source`` is
absent. We still pass an empty ``raw_content`` so future regressions
that start reading from it would surface immediately rather than
silently passing on a placeholder.
"""
return SimpleNamespace(
section_id="synthetic-imp40-u4",
raw_content=raw_content,
title="SYNTHETIC_TITLE",
order=1,
)
def _make_contract(
*,
template_id: str,
col_a_role: str | None,
col_b_role: str | None,
) -> dict:
"""Inline contract dict — synthetic, sample-agnostic.
role=None → key omitted entirely (legacy / absent-role axis).
"""
builder_options: dict = {
"item_parser": "compare_row_2col_item",
"col_a_label_default": _LITERAL_COL_A,
"col_b_label_default": _LITERAL_COL_B,
}
if col_a_role is not None:
builder_options["col_a_label_default_role"] = col_a_role
if col_b_role is not None:
builder_options["col_b_label_default_role"] = col_b_role
return {
"template_id": template_id,
"source_shape": "top_bullets",
"cardinality": {},
"payload": {
"builder": "compare_table_2col",
"builder_options": builder_options,
},
}
# ─── Policy row 1: placeholder → "" (Figma placeholder suppressed) ─
def test_placeholder_role_emits_empty_label_for_both_columns():
"""role=placeholder MUST suppress the catalog literal at runtime.
This is the IMP-40 leak-fix invariant: even though the catalog still
carries a Figma placeholder string (preserved for design preview),
the builder MUST NOT inject it into the runtime payload.
"""
contract = _make_contract(
template_id="synthetic_placeholder_both",
col_a_role="placeholder",
col_b_role="placeholder",
)
payload = _build_compare_table_2col(_make_section(), units=[], contract=contract)
assert payload["col_a_label"] == ""
assert payload["col_b_label"] == ""
assert _LITERAL_COL_A not in payload["col_a_label"]
assert _LITERAL_COL_B not in payload["col_b_label"]
# ─── Policy row 2: fallback → catalog literal (legacy behavior) ────
def test_fallback_role_emits_catalog_literal_for_both_columns():
"""role=fallback MUST preserve the pre-IMP-40 behavior byte-for-byte.
Legacy contracts that explicitly opt into ``fallback`` (or migrate
forward from absent-role) must keep emitting the catalog literal so
that frames where MDX genuinely omits a header still render a
meaningful default.
"""
contract = _make_contract(
template_id="synthetic_fallback_both",
col_a_role="fallback",
col_b_role="fallback",
)
payload = _build_compare_table_2col(_make_section(), units=[], contract=contract)
assert payload["col_a_label"] == _LITERAL_COL_A
assert payload["col_b_label"] == _LITERAL_COL_B
# ─── Policy row 3: absent role → fallback (backward compatibility) ─
def test_absent_role_defaults_to_fallback_for_both_columns():
"""Contracts without the new ``_role`` discriminator MUST be inert.
Backward compatibility guard: u1/u2 only add ``_role`` keys to the
targeted F18 / F30 / F31 frames. Every other ``compare_table_2col``
consumer in the catalog (current or future) that omits the key MUST
continue to receive the catalog literal — same as pre-IMP-40.
"""
contract = _make_contract(
template_id="synthetic_absent_role",
col_a_role=None,
col_b_role=None,
)
payload = _build_compare_table_2col(_make_section(), units=[], contract=contract)
assert payload["col_a_label"] == _LITERAL_COL_A
assert payload["col_b_label"] == _LITERAL_COL_B
def test_partial_role_mix_is_resolved_per_column():
"""Role discriminator MUST be resolved independently per column.
Synthetic edge case: col_a=placeholder, col_b absent. The placeholder
column emits "", the absent column falls back to its catalog literal.
Guards against any future refactor that accidentally couples the two
columns through a shared resolution path.
"""
contract = _make_contract(
template_id="synthetic_partial_mix",
col_a_role="placeholder",
col_b_role=None,
)
payload = _build_compare_table_2col(_make_section(), units=[], contract=contract)
assert payload["col_a_label"] == ""
assert payload["col_b_label"] == _LITERAL_COL_B
# ─── Policy row 4: unknown role → ValueError (fail-fast) ───────────
def test_unknown_role_raises_value_error_with_contract_context():
"""role=<garbage> MUST raise ValueError, not silently fall back.
A typo or stale role value in a hand-edited catalog must surface
immediately at build time rather than being miscategorized as
"fallback" or "placeholder". Error message MUST cite the contract
template_id, the role key, and the invalid value to make catalog
repair tractable.
"""
contract = _make_contract(
template_id="synthetic_unknown_role",
col_a_role="not_a_real_role",
col_b_role="placeholder",
)
with pytest.raises(ValueError) as exc:
_build_compare_table_2col(_make_section(), units=[], contract=contract)
msg = str(exc.value)
assert "synthetic_unknown_role" in msg
assert "col_a_label_default_role" in msg
assert "not_a_real_role" in msg
assert "placeholder" in msg
assert "fallback" in msg
# ─── u5 : F18-reuse regression — non-BIM/DX rows + placeholder role ─
_F18_CATALOG_LITERAL_COL_A = "BIM" # Verbatim F18 col_a_label_default (frame_contracts.yaml:476)
_F18_CATALOG_LITERAL_COL_B = "DX" # Verbatim F18 col_b_label_default (frame_contracts.yaml:477)
_F18_LEAK_TOKENS = (_F18_CATALOG_LITERAL_COL_A, _F18_CATALOG_LITERAL_COL_B)
def _make_f18_clone_contract() -> dict:
"""F18-shaped contract — verbatim BIM/DX literals + placeholder role.
Mirrors the catalog state after u1: BIM/DX kept as Figma visual
placeholders, but the role discriminator tells the builder to
suppress them at runtime. The ``template_id`` is namespaced
(``synthetic_f18_reuse_non_bim_dx``) so the test is not coupled to
the real F18 frame id; the leak invariant is independent of the
template_id string.
"""
return {
"template_id": "synthetic_f18_reuse_non_bim_dx",
"source_shape": "top_bullets",
"cardinality": {},
"payload": {
"builder": "compare_table_2col",
"builder_options": {
"item_parser": "compare_row_2col_item",
"col_a_label_default": _F18_CATALOG_LITERAL_COL_A,
"col_a_label_default_role": "placeholder",
"col_b_label_default": _F18_CATALOG_LITERAL_COL_B,
"col_b_label_default_role": "placeholder",
},
},
}
def test_f18_reuse_with_non_bim_dx_rows_suppresses_catalog_placeholder():
"""F18-reuse axis (mdx 04-2 scenario, synthetic): BIM/DX MUST NOT leak.
Models the downstream MDX that maps F18's anchor set to non-BIM/DX
content (the issue body cites 정책/조직 as a representative reuse).
With ``col_*_label_default_role="placeholder"``, the builder MUST
suppress the Figma literals at runtime. Because the synthetic rows
themselves carry no BIM / DX tokens, any appearance of those strings
anywhere in the payload would prove a catalog literal leaked through
— that is precisely the regression IMP-40 #69 must prevent.
"""
contract = _make_f18_clone_contract()
units = [
(
"- **정책 도입 단계**",
[" - 단기 우선순위 수립", " - 부서별 협업 강화"],
),
(
"- **조직 운영 구조**",
[" - 의사결정 책임자 명시", " - 정기 리뷰 사이클 운영"],
),
]
payload = _build_compare_table_2col(
_make_section(), units=units, contract=contract
)
# Placeholder role suppression at the header axis.
assert payload["col_a_label"] == ""
assert payload["col_b_label"] == ""
# Row content is derived from MDX-style synthetic units, not catalog.
assert len(payload["rows"]) == 2
assert payload["rows"][0]["label"] == "정책 도입 단계"
assert payload["rows"][0]["col_a"] == "단기 우선순위 수립"
assert payload["rows"][0]["col_b"] == "부서별 협업 강화"
assert payload["rows"][1]["label"] == "조직 운영 구조"
assert payload["rows"][1]["col_a"] == "의사결정 책임자 명시"
assert payload["rows"][1]["col_b"] == "정기 리뷰 사이클 운영"
# F18-leak invariant: BIM / DX tokens MUST NOT appear anywhere in payload.
for leak in _F18_LEAK_TOKENS:
assert leak not in payload["col_a_label"], (
f"placeholder role failed to suppress catalog literal '{leak}' in col_a_label"
)
assert leak not in payload["col_b_label"], (
f"placeholder role failed to suppress catalog literal '{leak}' in col_b_label"
)
for row in payload["rows"]:
assert leak not in row["label"]
assert leak not in row["col_a"]
assert leak not in row["col_b"]
@@ -0,0 +1,249 @@
"""IMP-84 u2 — provisional zone silent-render contract.
Pins that `templates/phase_z2/slide_base.html` no longer surfaces the
provisional visual treatment (dashed outline, striped wash, badge span)
while keeping `data-provisional="1"` as silent telemetry on the zone div.
Stage 2 binding contract (IMP-84):
- Remove .zone--provisional class emission on the zone div.
- Remove .zone__needs-adaptation-badge <span> render.
- Remove the .zone--provisional CSS block and the
.zone__needs-adaptation-badge CSS block from the <style> section.
- Preserve data-provisional="1" attribute emission for provisional zones
(downstream telemetry / debug selectors). Out-of-scope: backend
`zone.provisional` flag emission itself.
Helpers below intentionally mirror the IMP-30 first-render test helpers
(_render_slide_base / _all_zone_div_openings / _all_badge_spans /
_zone_div_for_position) so the silent-render contract is enforced by an
independent rendering surface, not by the IMP-30 file (which u3 will
invert separately).
"""
from __future__ import annotations
import re
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
# ─── helpers (mirrored from IMP-30 u5 to keep this test self-contained) ───
def _render_slide_base(
zones: list[dict],
*,
layout_preset: str = "single",
layout_css: dict | None = None,
) -> str:
"""Render templates/phase_z2/slide_base.html via Jinja2 with a minimal
zones list. Bypasses render_slide() so the template-only silent-render
contract is exercised without the pipeline (no mapper, no contracts,
no token CSS loader). slot_payload / partial_html are stubbed so the
assertions focus on zone div / CSS surface only."""
template_dir = (
Path(__file__).resolve().parents[2] / "templates" / "phase_z2"
)
env = Environment(
loader=FileSystemLoader(str(template_dir)),
autoescape=select_autoescape(["html"]),
)
if layout_css is None:
layout_css = {
"cols": "1fr",
"rows": "1fr",
"areas": '"single"',
}
for z in zones:
z.setdefault("partial_html", "<div class=\"_stub_partial\">stub</div>")
base = env.get_template("slide_base.html")
return base.render(
slide_title="IMP-84 u2 silent-render test",
slide_footer=None,
zones=zones,
layout_preset=layout_preset,
layout_css=layout_css,
gap_px=12,
token_css="",
embedded_mode="standalone",
)
def _zone_div_for_position(html: str, position: str) -> str:
"""Return the opening `<div class="zone..." data-zone-position="X" ...>`
tag for the zone at the given `data-zone-position`. Anchors assertions
on zone-div-level attributes / classes only."""
pattern = re.compile(
r'<div class="zone[^"]*"\s+data-zone-position="'
+ re.escape(position)
+ r'"[^>]*>',
re.DOTALL,
)
match = pattern.search(html)
return match.group(0) if match else ""
def _all_zone_div_openings(html: str) -> list[str]:
"""Every zone-div opening tag in the layout body. Scopes class /
attribute checks away from the <style> block (which may still contain
selector strings if a future change re-introduces them)."""
return re.findall(
r'<div class="zone[^"]*"[^>]*data-zone-position="[^"]*"[^>]*>',
html,
)
def _all_badge_spans(html: str) -> list[str]:
"""Every `.zone__needs-adaptation-badge` <span> element in the rendered
body. Must be empty under the IMP-84 silent-render contract regardless
of zones[i].provisional value."""
return re.findall(
r'<span class="zone__needs-adaptation-badge"[^>]*>[^<]*</span>',
html,
)
# ─── case 1 : non-provisional zone unchanged (regression boundary) ───
def test_imp84_non_provisional_zone_unchanged():
"""zones[i].provisional=False must render the zone div with no
provisional class, no data-provisional attr, no badge — byte-equivalent
to pre-IMP-84 baseline for the non-provisional path."""
zones = [
{
"position": "single",
"template_id": "MOCK_template_direct_a",
"slot_payload": {},
"content_weight": {"score": 1},
"min_height_px": 100,
"provisional": False,
}
]
html = _render_slide_base(zones)
zone_open = _zone_div_for_position(html, "single")
assert zone_open != ""
assert "zone--provisional" not in zone_open
assert "data-provisional" not in zone_open
assert _all_badge_spans(html) == []
# ─── case 2 : provisional zone silent — class / badge / wash removed ───
def test_imp84_provisional_zone_emits_data_attr_only_no_visual():
"""The core IMP-84 silent-render contract.
With zones[i].provisional=True, the rendered HTML MUST:
- NOT contain the `zone--provisional` class on any zone div
- NOT render a `.zone__needs-adaptation-badge` <span>
- NOT contain the human-visible "needs adaptation" label text
- STILL emit `data-provisional="1"` on the provisional zone div
(silent telemetry preserved for downstream selectors)
"""
zones = [
{
"position": "single",
"template_id": "MOCK_template_restructure_a",
"slot_payload": {},
"content_weight": {"score": 1},
"min_height_px": 100,
"provisional": True,
}
]
html = _render_slide_base(zones)
zone_open = _zone_div_for_position(html, "single")
assert zone_open != ""
assert "zone--provisional" not in zone_open
assert 'data-provisional="1"' in zone_open
assert _all_badge_spans(html) == []
assert "needs adaptation" not in html
# ─── case 3 : mixed zones — telemetry isolation preserved ───
def test_imp84_mixed_zones_data_provisional_only_on_provisional_zone():
"""In a mixed-zone slide (one provisional + one normal), the silent
telemetry attribute must appear ONLY on the provisional zone div, and
no visual artifact may surface on either zone."""
zones = [
{
"position": "top",
"template_id": "MOCK_template_direct_a",
"slot_payload": {},
"content_weight": {"score": 1},
"min_height_px": 100,
"provisional": False,
},
{
"position": "bottom",
"template_id": "MOCK_template_restructure_a",
"slot_payload": {},
"content_weight": {"score": 1},
"min_height_px": 100,
"provisional": True,
},
]
layout_css = {
"cols": "1fr",
"rows": "1fr 1fr",
"areas": '"top" "bottom"',
}
html = _render_slide_base(
zones, layout_preset="vertical-2", layout_css=layout_css
)
zone_divs = _all_zone_div_openings(html)
assert len(zone_divs) == 2
top_zone_open = _zone_div_for_position(html, "top")
bottom_zone_open = _zone_div_for_position(html, "bottom")
assert "data-provisional" not in top_zone_open
assert 'data-provisional="1"' in bottom_zone_open
for tag in zone_divs:
assert "zone--provisional" not in tag
assert _all_badge_spans(html) == []
assert "needs adaptation" not in html
# ─── case 4 : <style> block free of provisional visual selectors ───
def test_imp84_style_block_has_no_provisional_visual_selectors():
"""The provisional visual CSS classes are deleted at source. A future
refactor that re-introduces `.zone--provisional` or
`.zone__needs-adaptation-badge` selectors into slide_base.html breaks
this test rather than silently restoring the visual badge."""
zones = [
{
"position": "single",
"template_id": "MOCK_template_restructure_a",
"slot_payload": {},
"content_weight": {"score": 1},
"min_height_px": 100,
"provisional": True,
}
]
html = _render_slide_base(zones)
assert ".zone--provisional" not in html
assert ".zone__needs-adaptation-badge" not in html
assert "zone__needs-adaptation-badge" not in html
# ─── case 5 : provisional defaults to false (template fallback) ───
def test_imp84_provisional_none_falls_back_to_silent_non_provisional():
"""When zones[i].provisional is explicitly None (falsy but not False),
the template's truthy check must NOT emit `data-provisional`. Pins the
template fallback so a refactor cannot silently invert the default."""
zones = [
{
"position": "single",
"template_id": "MOCK_template_direct_a",
"slot_payload": {},
"content_weight": {"score": 1},
"min_height_px": 100,
"provisional": None,
}
]
html = _render_slide_base(zones)
zone_open = _zone_div_for_position(html, "single")
assert zone_open != ""
assert "data-provisional" not in zone_open
assert _all_badge_spans(html) == []
@@ -0,0 +1,200 @@
"""IMP-39 u6 (issue #68) - synthetic divergence regression.
Loads the SYNTHETIC fixture under
``tests/phase_z2/fixtures/ranking_sort_policy/`` and asserts that the
single-source ranking policy
(``templates/phase_z2/catalog/ranking_sort_policy.yaml``, u1) resolves
the backend - frontend "rank 1" divergence captured in Stage 1
root-cause analysis.
Divergence scenario (Stage 1 root cause):
- Pre-policy backend iterates ``judgments_full32`` in raw V4
confidence-desc order (``src/phase_z2_pipeline.py`` selector loop
behavior before u2). High-confidence ``restructure`` at
``v4_full_rank=1`` wins; lower-confidence ``use_as_is`` further
down the list is shadowed.
- Frontend (``Front/client/src/services/designAgentApi.ts``)
re-sorts the same source by ``LABEL_PRIORITY asc + confidence
desc`` and surfaces ``use_as_is`` as ``frame_candidates[0]``.
- Backend "selected rank 1" and frontend ``frame_candidates[0]``
diverge.
Post-policy (u2 wires ``apply_ranking_sort`` into the selector after
the IMP-38 raw-window slice), backend selection order matches the
frontend ordering: ``use_as_is`` is rank 1 on both sides.
Scope (u6, Stage 2 plan):
- SYNTHETIC fixture only - sample-agnostic, no MDX 03/04/05
references, no real ``frame_id`` / ``template_id`` literals.
- Helper-level exercise of ``apply_ranking_sort`` (mirrors the
selector's policy step at
``src/phase_z2_pipeline.py:1186-1196``).
Out of scope (other units):
- u1 policy yaml shape: covered by ``test_ranking_sort_policy.py``.
- u2 selector wiring: integration covered elsewhere.
- u3 Step 9 payload forwarding.
- u4 frontend mirror.
- u7 mdx04 env-toggle e2e.
- u8 corpus audit over ``tests/matching/v4_full32_result.yaml``.
"""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
FIXTURE_PATH = (
Path(__file__).parent
/ "fixtures"
/ "ranking_sort_policy"
/ "synthetic_divergence.yaml"
)
@pytest.fixture(autouse=True)
def _reset_policy_cache():
"""Mirror test_ranking_sort_policy.py isolation - clear the cached policy."""
import src.phase_z2_pipeline as pipeline
pipeline._RANKING_SORT_POLICY_CACHE = None
yield
pipeline._RANKING_SORT_POLICY_CACHE = None
def _load_fixture() -> dict:
with FIXTURE_PATH.open(encoding="utf-8") as f:
return yaml.safe_load(f)
def test_synthetic_fixture_shape_is_intact():
fixture = _load_fixture()
assert fixture["fixture_id"] == "synthetic_divergence"
assert fixture["sample_agnostic"] is True
raw = fixture["raw_judgments"]
assert len(raw) == 4
assert {j["label"] for j in raw} == {
"use_as_is",
"light_edit",
"restructure",
"reject",
}
assert len(fixture["expected_legacy_raw_order"]) == len(raw)
assert len(fixture["expected_policy_sorted_order"]) == len(raw)
div = fixture["divergence_axis"]
assert div["pre_policy_rank_1_tag"] != div["post_policy_rank_1_tag"]
assert div["post_policy_rank_1_tag"] == div["frontend_candidate_0_tag"]
def test_legacy_raw_order_demonstrates_divergence():
"""Pre-policy raw V4 confidence-desc order is the divergence source."""
fixture = _load_fixture()
raw = fixture["raw_judgments"]
assert [j["tag"] for j in raw] == fixture["expected_legacy_raw_order"]
pre_rank_1 = raw[0]
assert pre_rank_1["tag"] == fixture["divergence_axis"]["pre_policy_rank_1_tag"]
assert pre_rank_1["label"] == "restructure"
higher_priority_shadowed = next(
j for j in raw[1:] if j["label"] == "use_as_is"
)
assert higher_priority_shadowed["confidence"] < pre_rank_1["confidence"]
def test_apply_ranking_sort_resolves_divergence():
"""Post-policy order puts the higher-priority label first."""
from src.phase_z2_pipeline import apply_ranking_sort
fixture = _load_fixture()
sorted_judgments = apply_ranking_sort(
fixture["raw_judgments"],
label_key="label",
confidence_key="confidence",
v4_rank_key="v4_full_rank",
)
assert [j["tag"] for j in sorted_judgments] == fixture[
"expected_policy_sorted_order"
]
assert sorted_judgments[0]["label"] == "use_as_is"
assert (
sorted_judgments[0]["tag"]
== fixture["divergence_axis"]["post_policy_rank_1_tag"]
)
def test_backend_rank_1_aligns_with_frontend_candidate_zero():
"""Backend selector policy step and frontend candidate ordering agree.
Mirrors the selector policy step at
``src/phase_z2_pipeline.py:1186-1196`` (u2 wiring) and the frontend
``frame_candidates[0]`` derivation from ``sorted_candidate_evidence``
(``Front/client/src/services/designAgentApi.ts`` u4 wiring). The
selector's MVP1 status gate / contract / capacity checks are
out of scope - u8 corpus audit exercises the real
catalog-registered flow.
"""
from src.phase_z2_pipeline import (
apply_ranking_sort,
load_ranking_sort_policy,
)
fixture = _load_fixture()
policy = load_ranking_sort_policy()
sorted_window = apply_ranking_sort(
fixture["raw_judgments"],
policy=policy,
label_key="label",
confidence_key="confidence",
v4_rank_key="v4_full_rank",
)
backend_rank_1 = sorted_window[0]
frontend_candidate_0 = sorted_window[0]
expected_tag = fixture["divergence_axis"]["frontend_candidate_0_tag"]
assert backend_rank_1["tag"] == expected_tag
assert frontend_candidate_0["tag"] == expected_tag
assert backend_rank_1 is frontend_candidate_0
def test_input_list_is_not_mutated():
"""Fixture list reference and order survive ``apply_ranking_sort``."""
from src.phase_z2_pipeline import apply_ranking_sort
fixture = _load_fixture()
raw = fixture["raw_judgments"]
snapshot_tags = [j["tag"] for j in raw]
apply_ranking_sort(
raw,
label_key="label",
confidence_key="confidence",
v4_rank_key="v4_full_rank",
)
assert [j["tag"] for j in raw] == snapshot_tags
def test_pre_policy_legacy_order_can_be_reproduced():
"""Synthetic fixture's legacy order matches raw V4 confidence-desc.
Sanity check that ``expected_legacy_raw_order`` is consistent with
a confidence-desc sort of ``raw_judgments`` ignoring the policy.
This keeps the divergence axis honest if the fixture is edited.
"""
fixture = _load_fixture()
raw = fixture["raw_judgments"]
confidence_desc = sorted(raw, key=lambda j: -j["confidence"])
assert [j["tag"] for j in confidence_desc] == fixture[
"expected_legacy_raw_order"
]
@@ -0,0 +1,135 @@
"""IMP-42 u2 (#71) — post-render HTML invalid path char detector diag tests.
Stage 1/2 scope-lock §B: rendered partial / base HTML output must fail loud
with a typed error when src / href / url(...) attribute values contain
invalid path characters that would silently surface downstream as 404 /
asset-load failures.
Three production vectors are covered:
- Windows backslash from ``str(Path)`` (e.g. ``assets\\img.png``).
- Autoescape entity ``&amp;`` (raw ``&`` in raw path string).
- Autoescape entity ``&#39;`` (raw ``'`` in raw path string).
Assertions cover RULE 0 generality:
- error type is ValueError (typed, not bare exception)
- error message cites context label + attr type + value snippet
- clean rendered HTML (forward slashes only) does not raise
- non-attribute backslash (body text) does not raise
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import _scan_rendered_html_for_invalid_path_chars
# ─── backslash vector ────────────────────────────────────────────
def test_backslash_in_src_raises_with_context_and_attr_label():
html = '<img src="assets\\img.png">'
with pytest.raises(ValueError) as exc_info:
_scan_rendered_html_for_invalid_path_chars(html, "zone[0] template_id='foo'")
msg = str(exc_info.value)
assert "zone[0] template_id='foo'" in msg
assert "src" in msg
assert "assets\\img.png" in msg
def test_backslash_in_href_raises():
html = '<link href="styles\\app.css" rel="stylesheet">'
with pytest.raises(ValueError) as exc_info:
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
msg = str(exc_info.value)
assert "href" in msg
assert "styles\\app.css" in msg
def test_backslash_in_url_raises():
html = "<style>body { background: url(images\\bg.png); }</style>"
with pytest.raises(ValueError) as exc_info:
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
msg = str(exc_info.value)
assert "url(...)" in msg
assert "images\\bg.png" in msg
def test_backslash_in_url_with_quotes_raises():
html = "<style>div { background: url('images\\bg.png'); }</style>"
with pytest.raises(ValueError):
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
# ─── autoescape entity vectors ───────────────────────────────────
def test_escaped_ampersand_in_src_raises():
html = '<img src="assets/img&amp;v=1.png">'
with pytest.raises(ValueError) as exc_info:
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
msg = str(exc_info.value)
assert "&amp;" in msg
assert "src" in msg
def test_escaped_apostrophe_in_href_raises():
html = '<a href="docs/it&#39;s-here.pdf">x</a>'
with pytest.raises(ValueError) as exc_info:
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
msg = str(exc_info.value)
assert "&#39;" in msg
assert "href" in msg
def test_escaped_ampersand_in_url_raises():
html = "<style>div { background: url('img&amp;.png'); }</style>"
with pytest.raises(ValueError):
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
# ─── negative cases (must NOT raise) ─────────────────────────────
def test_clean_forward_slash_src_does_not_raise():
html = '<img src="assets/img.png"><link href="styles/app.css" rel="stylesheet">'
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
def test_clean_url_does_not_raise():
html = "<style>div { background: url('images/bg.png'); }</style>"
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
def test_backslash_in_body_text_does_not_raise():
# Backslash outside src/href/url is not a path-attr signal.
html = "<p>Windows path example: C:\\Users\\foo</p>"
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
def test_escaped_entities_in_body_text_do_not_raise():
# Body-text autoescape (e.g. legitimate &amp; in copy) is not a path signal.
html = "<p>AT&amp;T &#39;quoted&#39; text</p>"
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
def test_empty_html_does_not_raise():
_scan_rendered_html_for_invalid_path_chars("", "ctx")
# ─── error message contract ──────────────────────────────────────
def test_error_message_truncates_long_value_to_snippet():
long_path = "a" * 200 + "\\img.png"
html = f'<img src="{long_path}">'
with pytest.raises(ValueError) as exc_info:
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
msg = str(exc_info.value)
assert "..." in msg # truncation marker present
def test_error_message_cites_context_label_verbatim():
html = '<img src="x\\y.png">'
with pytest.raises(ValueError) as exc_info:
_scan_rendered_html_for_invalid_path_chars(
html, "zones_data[7] template_id='dx_sw_necessity_three_perspectives'"
)
msg = str(exc_info.value)
assert "zones_data[7]" in msg
assert "dx_sw_necessity_three_perspectives" in msg
@@ -0,0 +1,116 @@
"""IMP-42 u1 (#71) — render_slide precondition assertion diag tests.
Stage 1/2 scope-lock §A: Step 13 `render_slide()` partial render loop must
fail loud with a typed error when a zone dict is missing `template_id` or
`slot_payload`, instead of silently surfacing as Jinja `TemplateNotFound`
or `KeyError` far from the Step 12 emit site.
Assertions cover RULE 0 generality:
- error type is TypeError (typed, not bare AssertionError / KeyError)
- error message cites zone index + missing key
- empty-zone short-circuit (`__empty__`) still bypasses the precondition,
preserving the existing grid-identity behaviour from Codex #10 Catch N.
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import render_slide
def _layout_css() -> dict:
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
def _ok_zone() -> dict:
return {"position": "primary", "template_id": "__empty__", "slot_payload": {}}
def _render(zones_data: list[dict]) -> str:
return render_slide(
slide_title="t",
slide_footer=None,
zones_data=zones_data,
layout_preset="single",
layout_css=_layout_css(),
gap_px=14,
embedded_mode="embedded",
)
def test_template_id_missing_raises_typed_error_with_index_and_key():
zone = {"position": "primary", "slot_payload": {}}
with pytest.raises(TypeError) as exc_info:
_render([zone])
msg = str(exc_info.value)
assert "zones_data[0]" in msg
assert "template_id" in msg
def test_template_id_empty_string_raises_typed_error():
zone = {"position": "primary", "template_id": "", "slot_payload": {}}
with pytest.raises(TypeError) as exc_info:
_render([zone])
msg = str(exc_info.value)
assert "zones_data[0]" in msg
assert "template_id" in msg
assert "non-empty" in msg
def test_template_id_none_raises_typed_error():
zone = {"position": "primary", "template_id": None, "slot_payload": {}}
with pytest.raises(TypeError) as exc_info:
_render([zone])
msg = str(exc_info.value)
assert "zones_data[0]" in msg
assert "template_id" in msg
def test_template_id_non_string_raises_typed_error():
zone = {"position": "primary", "template_id": 42, "slot_payload": {}}
with pytest.raises(TypeError) as exc_info:
_render([zone])
msg = str(exc_info.value)
assert "zones_data[0]" in msg
assert "template_id" in msg
def test_slot_payload_missing_raises_typed_error_with_index_and_key():
zone = {"position": "primary", "template_id": "__placeholder__"}
with pytest.raises(TypeError) as exc_info:
_render([zone])
msg = str(exc_info.value)
assert "zones_data[0]" in msg
assert "slot_payload" in msg
def test_slot_payload_non_dict_raises_typed_error():
zone = {
"position": "primary",
"template_id": "__placeholder__",
"slot_payload": ["not", "a", "dict"],
}
with pytest.raises(TypeError) as exc_info:
_render([zone])
msg = str(exc_info.value)
assert "zones_data[0]" in msg
assert "slot_payload" in msg
assert "dict" in msg
def test_second_zone_failure_reports_correct_index():
zones = [_ok_zone(), {"position": "secondary", "slot_payload": {}}]
with pytest.raises(TypeError) as exc_info:
_render(zones)
msg = str(exc_info.value)
assert "zones_data[1]" in msg
assert "template_id" in msg
def test_empty_zone_short_circuit_bypasses_precondition():
# __empty__ short-circuit must run before precondition checks so that
# legitimate empty zones (no slot_payload required) still render.
zones = [{"position": "primary", "template_id": "__empty__"}]
html = _render(zones)
assert isinstance(html, str)
assert len(html) > 0
@@ -0,0 +1,92 @@
"""IMP-42 u5 (#71) — general 32-frame smoke for diag tools (registry-driven).
Stage 1/2 RULE 0 lock: u1 (precondition assert), u2 (invalid-path detector),
and u3 (backend DIAG) must work GENERALLY across every frame declared in
``templates/phase_z2/catalog/frame_contracts.yaml`` — not only the MDX
03/04/05 samples that motivated #71.
The smoke enumerates every top-level frame contract and parametrizes the
three diag behaviors against each ``template_id``. AI = 0, no visual_check,
no real partial render — payloads are synthetic so the coverage stays
sample-agnostic and never depends on frame-specific slot shapes.
Each parametrized case covers one silent-fail vector from #71 root cause:
- u1 precondition: Step 13 partial render must fail loud on missing
``slot_payload`` regardless of which frame contract is in play.
- u2 invalid path: post-render asset-ref scan must fire on a synthetic
backslash ``src`` value when the context cites any frame's id.
- u3 DIAG: ``_emit_diag_zones_shape`` must include the frame's
``template_id`` in the JSON payload for every contract.
"""
from __future__ import annotations
import json
import pytest
from src.phase_z2_mapper import load_frame_contracts
from src.phase_z2_pipeline import (
_emit_diag_zones_shape,
_scan_rendered_html_for_invalid_path_chars,
render_slide,
)
_FRAME_IDS = sorted(load_frame_contracts().keys())
def test_registry_has_expected_frame_count():
# Pin the 32-frame floor — additions are auto-covered by parametrize,
# while a regression that drops below 32 surfaces here loud.
assert len(_FRAME_IDS) >= 32, (
f"frame_contracts.yaml expected ≥ 32 entries, got {len(_FRAME_IDS)}: "
f"{_FRAME_IDS}"
)
def _layout_css() -> dict:
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
@pytest.mark.parametrize("template_id", _FRAME_IDS)
def test_u1_precondition_fires_for_every_frame(template_id):
zone = {"position": "primary", "template_id": template_id}
with pytest.raises(TypeError) as exc_info:
render_slide(
slide_title="t",
slide_footer=None,
zones_data=[zone],
layout_preset="single",
layout_css=_layout_css(),
gap_px=14,
embedded_mode="embedded",
)
msg = str(exc_info.value)
assert "zones_data[0]" in msg
assert "slot_payload" in msg
@pytest.mark.parametrize("template_id", _FRAME_IDS)
def test_u2_invalid_char_detector_fires_for_every_frame_context(template_id):
html = '<img src="assets\\img.png">'
ctx = f"zones_data[0] template_id={template_id!r}"
with pytest.raises(ValueError) as exc_info:
_scan_rendered_html_for_invalid_path_chars(html, ctx)
msg = str(exc_info.value)
assert template_id in msg
assert "src" in msg
@pytest.mark.parametrize("template_id", _FRAME_IDS)
def test_u3_diag_emits_template_id_for_every_frame(template_id, capsys):
zones = [
{"position": "primary", "template_id": template_id, "slot_payload": {}}
]
_emit_diag_zones_shape("Step 12 slot_payload emit", zones)
line = capsys.readouterr().out.strip()
prefix = "[DIAG] phase_z2 Step 12 slot_payload emit "
assert line.startswith(prefix), f"missing DIAG prefix on line={line!r}"
payload = json.loads(line[len(prefix):])
assert payload["zones_count"] == 1
assert payload["zones"][0]["template_id"] == template_id
assert payload["zones"][0]["slot_keys"] == []
@@ -0,0 +1,223 @@
"""IMP-42 u3 (#71) — unconditional Step 12 / Step 13 backend DIAG terminal logs.
Stage 1/2 scope-lock §C-backend: Step 12 slot_payload emit + Step 13
render_slide entry must each emit a shape-only `[DIAG]` line to stdout
on every slide loop, with no env gate. The line carries enough zone
shape (position / template_id / slot_payload key list) to debug the
silent 3-hop handoff documented in #71, without leaking raw slot
content (RULE 0 sample-agnostic).
Coverage:
- helper emits `[DIAG] phase_z2 <stage_label>` prefix
- helper payload is structured JSON with zones_count + per-zone shape
- per-zone shape includes i / position / template_id / slot_keys
- slot_keys is a sorted key list (never raw values)
- slot_keys is null when slot_payload is missing or non-dict
- extra_fields are merged into the payload at top level
- render_slide() entry call site fires the helper on every invocation
- source-slice confirms Step 12 emit site invokes the helper after
the slot_payload `_write_step_artifact(...)` call.
Diag is unconditional — no env-var gate; silence is the bug per Stage 1.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from src import phase_z2_pipeline
from src.phase_z2_pipeline import (
_emit_diag_zones_shape,
render_slide,
)
# ─── helper unit tests ───────────────────────────────────────────
def _parse_diag_line(line: str, expected_label: str) -> dict:
prefix = f"[DIAG] phase_z2 {expected_label} "
assert line.startswith(prefix), (
f"expected DIAG prefix {prefix!r}, got line={line!r}"
)
return json.loads(line[len(prefix):])
def test_helper_emits_diag_prefix_and_json(capsys):
zones = [{"position": "primary", "template_id": "foo", "slot_payload": {"a": 1, "b": 2}}]
_emit_diag_zones_shape("Step 12 slot_payload emit", zones)
captured = capsys.readouterr().out.strip().splitlines()
assert len(captured) == 1
payload = _parse_diag_line(captured[0], "Step 12 slot_payload emit")
assert payload["zones_count"] == 1
assert payload["zones"][0]["position"] == "primary"
assert payload["zones"][0]["template_id"] == "foo"
def test_helper_slot_keys_is_sorted_key_list_not_values(capsys):
# Raw values ("secret content") must not leak into the diag line.
zones = [{
"position": "primary",
"template_id": "foo",
"slot_payload": {"z_last": "secret content", "a_first": "another secret"},
}]
_emit_diag_zones_shape("Step 12 slot_payload emit", zones)
line = capsys.readouterr().out.strip()
assert "secret content" not in line
assert "another secret" not in line
payload = _parse_diag_line(line, "Step 12 slot_payload emit")
assert payload["zones"][0]["slot_keys"] == ["a_first", "z_last"]
def test_helper_slot_keys_null_when_slot_payload_missing(capsys):
zones = [{"position": "primary", "template_id": "__empty__"}]
_emit_diag_zones_shape("Step 13 render_slide entry", zones)
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 13 render_slide entry")
assert payload["zones"][0]["slot_keys"] is None
def test_helper_slot_keys_null_when_slot_payload_non_dict(capsys):
zones = [{"position": "primary", "template_id": "foo", "slot_payload": ["not", "dict"]}]
_emit_diag_zones_shape("Step 13 render_slide entry", zones)
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 13 render_slide entry")
assert payload["zones"][0]["slot_keys"] is None
def test_helper_per_zone_index_threading(capsys):
zones = [
{"position": "top", "template_id": "alpha", "slot_payload": {}},
{"position": "bottom_l", "template_id": "beta", "slot_payload": {"k": "v"}},
{"position": "bottom_r", "template_id": "gamma", "slot_payload": {"k": "v"}},
]
_emit_diag_zones_shape("Step 12 slot_payload emit", zones)
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 12 slot_payload emit")
assert payload["zones_count"] == 3
assert [z["i"] for z in payload["zones"]] == [0, 1, 2]
assert [z["position"] for z in payload["zones"]] == ["top", "bottom_l", "bottom_r"]
assert [z["template_id"] for z in payload["zones"]] == ["alpha", "beta", "gamma"]
def test_helper_extra_fields_merged_into_payload(capsys):
zones = [{"position": "primary", "template_id": "__empty__"}]
_emit_diag_zones_shape(
"Step 13 render_slide entry",
zones,
layout_preset="single",
embedded_mode="embedded",
)
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 13 render_slide entry")
assert payload["layout_preset"] == "single"
assert payload["embedded_mode"] == "embedded"
def test_helper_empty_zones_list_still_emits_line(capsys):
# No zones is a valid (degenerate) shape — diag must still fire, never silent.
_emit_diag_zones_shape("Step 12 slot_payload emit", [])
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 12 slot_payload emit")
assert payload["zones_count"] == 0
assert payload["zones"] == []
# ─── render_slide entry call site integration ────────────────────
def _layout_css() -> dict:
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
def test_render_slide_entry_emits_step13_diag_on_every_call(capsys):
zones = [{"position": "primary", "template_id": "__empty__"}]
render_slide(
slide_title="t",
slide_footer=None,
zones_data=zones,
layout_preset="single",
layout_css=_layout_css(),
gap_px=14,
embedded_mode="embedded",
)
out = capsys.readouterr().out
step13_lines = [
ln for ln in out.splitlines()
if ln.startswith("[DIAG] phase_z2 Step 13 render_slide entry ")
]
assert len(step13_lines) == 1, (
f"expected exactly 1 Step 13 DIAG line, got {len(step13_lines)} from out={out!r}"
)
payload = _parse_diag_line(step13_lines[0], "Step 13 render_slide entry")
assert payload["layout_preset"] == "single"
assert payload["embedded_mode"] == "embedded"
assert payload["zones_count"] == 1
def test_render_slide_fires_step13_diag_before_template_lookup(capsys):
# Diag must fire even when the precondition (u1) later raises — the diag
# is at entry, so the user sees the zone shape even on failure.
zones = [{"position": "primary", "slot_payload": {}}]
with pytest.raises(TypeError):
render_slide(
slide_title="t",
slide_footer=None,
zones_data=zones,
layout_preset="single",
layout_css=_layout_css(),
gap_px=14,
embedded_mode="embedded",
)
out = capsys.readouterr().out
assert "[DIAG] phase_z2 Step 13 render_slide entry " in out
# ─── Step 12 emit call site source-slice ──────────────────────────
def test_step12_emit_call_site_invokes_helper_after_artifact_write():
# The Step 12 emit site is buried inside the orchestrator; rather than
# spin up the full pipeline, assert by source-slice that the helper is
# invoked with the "Step 12 slot_payload emit" label *after* the
# _write_step_artifact(... step12 ... "slot_payload" ...) call.
src = Path(phase_z2_pipeline.__file__).read_text(encoding="utf-8")
artifact_marker = '_write_step_artifact(\n run_dir, 12, "slot_payload"'
helper_marker = '_emit_diag_zones_shape("Step 12 slot_payload emit", zones_data)'
artifact_pos = src.find(artifact_marker)
helper_pos = src.find(helper_marker)
assert artifact_pos != -1, "Step 12 slot_payload artifact write not found"
assert helper_pos != -1, "Step 12 DIAG helper call not found"
assert helper_pos > artifact_pos, (
"Step 12 DIAG helper call must appear after the slot_payload artifact write "
f"(artifact_pos={artifact_pos}, helper_pos={helper_pos})"
)
def test_step13_entry_call_site_invokes_helper_inside_render_slide():
src = Path(phase_z2_pipeline.__file__).read_text(encoding="utf-8")
render_slide_def = src.find("def render_slide(")
assert render_slide_def != -1, "render_slide definition not found"
# Bound the search to the function body — find next def or class after it.
next_def = src.find("\ndef ", render_slide_def + len("def render_slide("))
body = src[render_slide_def:next_def if next_def != -1 else len(src)]
assert '_emit_diag_zones_shape(\n "Step 13 render_slide entry"' in body, (
"Step 13 DIAG helper call not found inside render_slide()"
)
# ─── unconditional contract (no env-gate) ────────────────────────
def test_diag_helper_has_no_env_gate(monkeypatch, capsys):
# Stage 1 contract: diag is unconditional. Setting any plausible
# "verbose off" env var must not silence the line. We test the most
# common gate names a future contributor might be tempted to add.
for env_name in (
"PHASE_Z_DIAG_VERBOSE",
"DIAG_VERBOSE",
"VERBOSE",
"DEBUG",
"PYTHON_NO_DIAG",
):
monkeypatch.setenv(env_name, "0")
_emit_diag_zones_shape("Step 12 slot_payload emit", [])
out = capsys.readouterr().out
assert "[DIAG] phase_z2 Step 12 slot_payload emit" in out
@@ -77,10 +77,14 @@ def test_three_new_salvage_failure_types_route_to_expected_cascade_actions():
assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression" assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression"
assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust" assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
# Implementation status (u7): 2 cascade entries IMPLEMENTED, layout_adjust MISSING # Implementation status: 3 cascade entries IMPLEMENTED.
# layout_adjust was MISSING pre-IMP-88; IMP-88 u7 (2026-05-24) flipped it
# to IMPLEMENTED on the failure-router surface alongside the primary
# router surface (u3 plan_layout_adjust + u6 dispatcher branch + u7
# cascade entry trigger).
assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED" assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED" assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "MISSING" assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "IMPLEMENTED"
# Classifier path via salvage_steps[-1].action → failure_type → next action # Classifier path via salvage_steps[-1].action → failure_type → next action
cases = [ cases = [
@@ -0,0 +1,483 @@
"""IMP-88 u6 — Step 17 salvage cascade dispatcher tests for the two
new branches (`layout_adjust` + `frame_internal_fit_candidate`).
Stage 2 binding contract (u6):
- Extend `_SALVAGE_FAIL_BY_ACTION` to include the two IMP-88 actions so
the salvage loop range adapts and the cascade does not exit early at
`layout_adjust` / `frame_internal_fit_candidate` (previously terminal
in the 3-entry map, now executable).
- `layout_adjust` takes a distinct render path: it calls `render_slide`
with the NEW preset + remapped zones_data + new layout_css (built via
`apply_layout_adjust_layout_css`). No CSS overlay — topology swap only
(honors `[[feedback_phase_z_spacing_direction]]`: no common margin
shrink, no slide-body shrink).
- `frame_internal_fit_candidate` uses the shared CSS-overlay path
(same as font_step_compression / glue_compression) because the
planner emits a frame-scoped CSS rule via
`apply_frame_internal_fit_candidate_css`.
Test surfaces (8 tests):
1. `_SALVAGE_FAIL_BY_ACTION` map registers the two new actions with
the failure_type names the failure_router (u2) cascade rows expect.
2. `layout_adjust` PASS — out_path promoted with the swapped render,
step records new_layout_preset, cascade exits.
3. `layout_adjust` infeasible (no sibling for `single` preset) — step
records failure_reason without invoking render_slide; cascade
advances to frame_internal_fit_candidate.
4. `layout_adjust` rerender FAIL — cascade advances to
frame_internal_fit_candidate (which then PASSes via patched
envelope).
5. `frame_internal_fit_candidate` PASS via patched envelope — out_path
promoted with CSS-overlay candidate.
6. `frame_internal_fit_candidate` no-envelope — step records
envelope_present=False; cascade exits via frame_reselect terminal.
7. Full 5-step cascade all fail — loop cap (range(len(map))=5)
respected; exactly 5 steps recorded; out_path preserved.
8. `layout_adjust` uses no CSS-overlay path (css_override field is
absent from the layout_adjust step; new_layout_preset is present).
"""
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
import pytest
import src.phase_z2_mapper as _pz_mapper
import src.phase_z2_pipeline as _pz_pipeline
from src.phase_z2_pipeline import _SALVAGE_FAIL_BY_ACTION, _attempt_salvage_chain
_PROJECT_ROOT = _pz_pipeline.PROJECT_ROOT
@pytest.fixture
def project_tmp(tmp_path_factory):
"""Temp dir under PROJECT_ROOT so `_attempt_salvage_chain` can call
`candidate_path.relative_to(PROJECT_ROOT)` without ValueError on a
cross-drive Windows tmp path (default pytest tmp_path lives under
%LOCALAPPDATA% which is on a different drive from the project root).
Mirrors the fixture pattern in test_phase_z2_step17_salvage_chain.py.
"""
base = _PROJECT_ROOT / ".orchestrator" / "tmp"
base.mkdir(parents=True, exist_ok=True)
d = Path(tempfile.mkdtemp(prefix="imp88_u6_", dir=str(base)))
try:
yield d
finally:
shutil.rmtree(d, ignore_errors=True)
# IMP-09 gate-passing layout_css envelope. _attempt_salvage_chain skips
# the cascade when dynamic_cols=True or dynamic_rows=False. Mirror of the
# fixture in test_phase_z2_step17_salvage_chain.py so this test file
# stays consistent with the existing u15 cascade surface.
_LAYOUT_CSS_GATE_PASS = {
"areas": '"top" "bottom"',
"cols": "1fr",
"rows": "1fr 1fr",
"heights_px": [300, 290],
"widths_px": [1180],
"ratios": [0.508, 0.491],
"width_ratios": [1.0],
"dynamic_rows": True,
"dynamic_cols": False,
}
def _patch_render(monkeypatch):
"""Stub render_slide → deterministic HTML envelope. Counter exposes
invocation count so tests can assert render_slide was (or was not)
called per branch."""
counter = {"n": 0}
def _stub(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=14):
counter["n"] += 1
return (
f"<html><head><meta charset='utf-8'></head>"
f"<body><div data-slide-title='{slide_title}' "
f"data-rendered-preset='{layout_preset}'></div></body></html>"
)
monkeypatch.setattr(_pz_pipeline, "render_slide", _stub)
return counter
def _horizontal_zones() -> list[dict]:
"""horizontal-2 zones with content_weight.score so the vertical-2
swap path can call _build_cols_dynamic → compute_zone_layout_cols
inside apply_layout_adjust_layout_css → build_layout_css."""
return [
{"position": "top", "template_id": "t-top",
"content_weight": {"score": 1.0}},
{"position": "bottom", "template_id": "t-bottom",
"content_weight": {"score": 1.0}},
]
def _ci_image() -> dict:
"""Minimal cascade_inputs for the cascade chain — covers the keys
every branch reads (fit_analysis is None to deliberately keep the
cross_zone branch infeasible when the cascade enters there, since
full FitAnalysis assembly is the u15 cascade test's domain not u6's)."""
return {
"fit_analysis": None, "containers": {}, "min_margin_px": 10,
"excess_px": 40.0, "excess_after_glue_px": 40.0,
"block_count": 3, "zone_position": "top",
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
}
# ── 1. _SALVAGE_FAIL_BY_ACTION map registration ─────────────────────
def test_salvage_fail_map_registers_imp88_actions():
"""u6 extends the salvage-action → failure_type map from 3 to 5
entries so the loop cap (range(len(map))) covers the IMP-88
cascade depth. failure_type names mirror failure_router u2's
SALVAGE_FAILURE_TYPE_BY_ACTION rows."""
assert _SALVAGE_FAIL_BY_ACTION["layout_adjust"] == "layout_adjust_insufficient"
assert _SALVAGE_FAIL_BY_ACTION["frame_internal_fit_candidate"] == "frame_internal_fit_candidate_insufficient"
assert len(_SALVAGE_FAIL_BY_ACTION) == 5
# u4 image_fit stays OUT of the salvage chain map — u7 handles it as a
# Step 17 entry single-pass (not a cascade salvage stage). Guard against
# accidental future registration that would change cascade semantics.
assert "image_fit" not in _SALVAGE_FAIL_BY_ACTION
# ── 2. layout_adjust PASS branch ────────────────────────────────────
def test_layout_adjust_pass_promotes_final_html(project_tmp, monkeypatch):
"""initial_failure_type=font_step_insufficient routes to layout_adjust;
plan_layout_adjust swaps horizontal-2 → vertical-2 (rows ↔ cols, swap
priority 0). render_slide is invoked with the NEW preset; overflow
passes → out_path promoted; step records new_layout_preset; cascade
exits with salvage_passed=True. No CSS overlay path was used."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
counter = _patch_render(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []},
)
trace = _attempt_salvage_chain(
run_dir=project_tmp, out_path=out_path,
slide_title="imp88-u6", slide_footer=None,
zones_data=_horizontal_zones(),
layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
cascade_inputs=_ci_image(),
initial_failure_type="font_step_insufficient", gap_px=14,
)
assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is True
assert len(trace["salvage_steps"]) == 1
step0 = trace["salvage_steps"][0]
assert step0["action"] == "layout_adjust"
assert step0["passed"] is True
assert step0["new_layout_preset"] == "vertical-2"
assert step0["plan"]["feasible"] is True
# layout_adjust uses the distinct render path — NOT the CSS-overlay path.
assert "css_override" not in step0
# render_slide was invoked exactly once with the NEW preset.
assert counter["n"] == 1
promoted = out_path.read_text(encoding="utf-8")
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
assert "data-rendered-preset='vertical-2'" in promoted
# ── 3. layout_adjust infeasible (no sibling) ────────────────────────
def test_layout_adjust_infeasible_no_sibling_cascade_advances(project_tmp, monkeypatch):
"""`single` preset has no render-ready unit_count=1 sibling (catalog
design — single and grid-2x2 have no swap target). layout_adjust
returns feasible=False; render_slide is NOT invoked; cascade
advances to frame_internal_fit_candidate. Patched get_contract
returns no envelope → that branch also infeasible → cascade exits
at the frame_reselect terminal action."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
counter = _patch_render(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: pytest.fail(
"run_overflow_check must not run when no candidate is emitted"
),
)
monkeypatch.setattr(
_pz_mapper, "get_contract", lambda _tid: None,
)
trace = _attempt_salvage_chain(
run_dir=project_tmp, out_path=out_path,
slide_title="imp88-u6", slide_footer=None,
zones_data=[{"position": "primary", "template_id": "t-only",
"content_weight": {"score": 1.0}}],
layout_preset="single", layout_css=_LAYOUT_CSS_GATE_PASS,
cascade_inputs={**_ci_image(), "zone_position": "primary"},
initial_failure_type="font_step_insufficient", gap_px=14,
)
assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is False
actions = [s["action"] for s in trace["salvage_steps"]]
assert actions == ["layout_adjust", "frame_internal_fit_candidate"]
s0, s1 = trace["salvage_steps"]
assert s0["plan"]["feasible"] is False
assert "no render-ready" in (s0["plan"]["failure_reason"] or "")
assert s0["new_layout_preset"] is None
assert s1["plan"]["feasible"] is False
assert s1["plan"]["envelope_present"] is False
# No candidate ever rendered (layout_adjust infeasible → no render call;
# frame_internal_fit_candidate infeasible → no render call).
assert counter["n"] == 0
# frame_reselect is the next routing target after
# frame_internal_fit_candidate_insufficient — not in salvage map → terminal.
assert trace.get("salvage_terminal_action") == "frame_reselect"
# Original final.html unchanged.
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
# ── 4. layout_adjust rerender FAIL → frame_internal_fit_candidate PASS ──
def test_layout_adjust_fail_cascade_to_frame_internal_fit_pass(project_tmp, monkeypatch):
"""layout_adjust feasible but post-swap overflow persists →
failure_type=layout_adjust_insufficient → routes to
frame_internal_fit_candidate. Patched contract provides an envelope
variant that absorbs the excess; that branch PASSes → out_path
promoted with the CSS-overlay candidate."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
counter = _patch_render(monkeypatch)
# First overflow check (after layout_adjust render) FAILS,
# second (after frame_internal_fit CSS overlay) PASSes.
overflow_results = iter([
{"passed": False, "fail_reasons": ["zone overflow persists"]},
{"passed": True, "fail_reasons": []},
])
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: next(overflow_results),
)
# Stub contract with a feasible internal_envelope variant covering 40px excess.
stub_contract = {
"internal_envelope": {
"variants": [
{"name": "internal_grid_row",
"excess_budget_px": 60,
"css_overrides": {"padding-top": "0px"}},
],
},
}
monkeypatch.setattr(
_pz_mapper, "get_contract", lambda _tid: stub_contract,
)
trace = _attempt_salvage_chain(
run_dir=project_tmp, out_path=out_path,
slide_title="imp88-u6", slide_footer=None,
zones_data=_horizontal_zones(),
layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
cascade_inputs=_ci_image(),
initial_failure_type="font_step_insufficient", gap_px=14,
)
assert trace["salvage_passed"] is True
assert len(trace["salvage_steps"]) == 2
s0, s1 = trace["salvage_steps"]
assert s0["action"] == "layout_adjust"
assert s0["passed"] is False
assert s0["plan"]["feasible"] is True
assert s1["action"] == "frame_internal_fit_candidate"
assert s1["passed"] is True
assert s1["plan"]["selected_variant"] == "internal_grid_row"
assert s1["css_override"]
assert 'data-template-id="t-top"' in s1["css_override"]
# Two render_slide calls (one per dispatched branch).
assert counter["n"] == 2
assert "ORIGINAL_BEFORE_SALVAGE" not in out_path.read_text(encoding="utf-8")
# ── 5. frame_internal_fit_candidate PASS (direct entry) ─────────────
def test_frame_internal_fit_candidate_pass_promotes_final_html(project_tmp, monkeypatch):
"""initial_failure_type=layout_adjust_insufficient routes directly to
frame_internal_fit_candidate. Patched contract envelope variant
covers excess; CSS-overlay candidate PASSes → out_path promoted."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
counter = _patch_render(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []},
)
monkeypatch.setattr(
_pz_mapper, "get_contract",
lambda _tid: {
"internal_envelope": {
"variants": [
{"name": "density_envelope",
"excess_budget_px": 80,
"css_overrides": {"line-height": "1.4"}},
],
},
},
)
trace = _attempt_salvage_chain(
run_dir=project_tmp, out_path=out_path,
slide_title="imp88-u6", slide_footer=None,
zones_data=_horizontal_zones(),
layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
cascade_inputs=_ci_image(),
initial_failure_type="layout_adjust_insufficient", gap_px=14,
)
assert trace["salvage_passed"] is True
assert len(trace["salvage_steps"]) == 1
step0 = trace["salvage_steps"][0]
assert step0["action"] == "frame_internal_fit_candidate"
assert step0["passed"] is True
assert step0["plan"]["selected_variant"] == "density_envelope"
assert step0["css_override"] and "line-height: 1.4" in step0["css_override"]
assert counter["n"] == 1
# ── 6. frame_internal_fit_candidate no envelope → terminal exit ─────
def test_frame_internal_fit_candidate_no_envelope_cascade_terminal(project_tmp, monkeypatch):
"""initial=layout_adjust_insufficient routes to
frame_internal_fit_candidate. Patched contract has NO
internal_envelope → planner returns feasible=False with
envelope_present=False; failure_type=
frame_internal_fit_candidate_insufficient → routes to
frame_reselect (not in salvage map) → terminal exit recorded."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
counter = _patch_render(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: pytest.fail("no candidate emitted — overflow_check must not run"),
)
# Contract present but no internal_envelope → envelope_present=False branch.
monkeypatch.setattr(
_pz_mapper, "get_contract", lambda _tid: {"some_other_key": "value"},
)
trace = _attempt_salvage_chain(
run_dir=project_tmp, out_path=out_path,
slide_title="imp88-u6", slide_footer=None,
zones_data=_horizontal_zones(),
layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
cascade_inputs=_ci_image(),
initial_failure_type="layout_adjust_insufficient", gap_px=14,
)
assert trace["salvage_passed"] is False
assert len(trace["salvage_steps"]) == 1
step0 = trace["salvage_steps"][0]
assert step0["action"] == "frame_internal_fit_candidate"
assert step0["plan"]["feasible"] is False
assert step0["plan"]["envelope_present"] is False
assert trace["salvage_terminal_action"] == "frame_reselect"
assert counter["n"] == 0
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
# ── 7. Loop cap respected — 5 stages all fail ───────────────────────
def test_full_5_step_cascade_all_fail_loop_cap_respected(project_tmp, monkeypatch):
"""Start at donor_slack_insufficient and force every cascade stage to
fail: cross_zone (no fit_analysis), glue (excess > envelope), font_step
(no headroom), layout_adjust (single → no sibling), frame_internal_fit
(no contract). Loop iterates exactly len(_SALVAGE_FAIL_BY_ACTION)=5
times → salvage_steps has 5 entries in the exact cascade order."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
counter = _patch_render(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: pytest.fail("no CSS emitted in any branch — overflow_check must not run"),
)
monkeypatch.setattr(
_pz_mapper, "get_contract", lambda _tid: None,
)
trace = _attempt_salvage_chain(
run_dir=project_tmp, out_path=out_path,
slide_title="imp88-u6", slide_footer=None,
# single preset → layout_adjust will be infeasible (no sibling)
zones_data=[{"position": "primary", "template_id": "t-only",
"content_weight": {"score": 1.0}}],
layout_preset="single", layout_css=_LAYOUT_CSS_GATE_PASS,
cascade_inputs={
"fit_analysis": None, "containers": {}, "min_margin_px": 10,
# excess_px=200 > glue envelope at block_count=1 (max ~28) → infeasible
"excess_px": 200.0, "excess_after_glue_px": 200.0,
"block_count": 1, "zone_position": "primary",
# current_font_px cannot absorb 200px even at 8px floor → infeasible
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
},
initial_failure_type="donor_slack_insufficient", gap_px=14,
)
assert trace["salvage_passed"] is False
assert len(trace["salvage_steps"]) == 5
actions = [s["action"] for s in trace["salvage_steps"]]
assert actions == [
"cross_zone_redistribute",
"glue_compression",
"font_step_compression",
"layout_adjust",
"frame_internal_fit_candidate",
]
# All 5 are infeasible — no candidate rendering anywhere.
assert counter["n"] == 0
# Loop exhausted at cap (no mid-cascade terminal_action since each
# next_action stayed in _SALVAGE_FAIL_BY_ACTION through 5 stages).
assert "salvage_terminal_action" not in trace
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
# ── 8. layout_adjust uses the distinct render path (no CSS overlay) ──
def test_layout_adjust_step_has_no_css_overlay_field(project_tmp, monkeypatch):
"""layout_adjust's render path is qualitatively different from the
CSS-overlay planners (glue / font_step / cross_zone / frame_internal_fit):
it calls render_slide with the NEW preset + remapped zones_data + new
layout_css. The step dict therefore omits `css_override` and surfaces
`new_layout_preset` instead — observability for downstream classifiers."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
_patch_render(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []},
)
trace = _attempt_salvage_chain(
run_dir=project_tmp, out_path=out_path,
slide_title="imp88-u6", slide_footer=None,
zones_data=_horizontal_zones(),
layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
cascade_inputs=_ci_image(),
initial_failure_type="font_step_insufficient", gap_px=14,
)
step0 = trace["salvage_steps"][0]
assert step0["action"] == "layout_adjust"
# Distinct render path observability contract:
assert "css_override" not in step0
assert "new_layout_preset" in step0
assert "candidate_path" in step0
@@ -0,0 +1,542 @@
"""IMP-88 u7 — Step 17 entry runtime caller tests.
Stage 2 binding contract (u7):
- `_attempt_step17_image_fit_single_pass` executes the image_fit Step 17
entry single-pass: per-event plan_image_fit → apply_image_fit_css →
aggregated CSS overlay → single re-render → run_overflow_check.
PASS promotes final.html and returns a salvage_steps-shaped entry with
post_salvage_overflow; FAIL returns the same shape with failure_reason
(NO out_path mutation on FAIL).
- image_fit stays OUT of `_SALVAGE_FAIL_BY_ACTION` (u6 guard). The Step 17
entry single-pass is NOT a cascade stage — it runs BEFORE the cascade
direct-entry block in pipeline §11.7.2.
- Honors `[[feedback_phase_z_spacing_direction]]` — img-scoped CSS only,
no common margin / slide-body shrink. Honors AI isolation contract
(PZ-1) — deterministic data-surface, no AI call.
- Step 17/18/19 artifact refresh: the pipeline §11.7.1 wrapper around the
helper re-runs classify_visual_runtime_check + route_fit_classification +
enrich_retry_trace_with_failure_classification on PASS so Step 18
failure_classification + Step 19 next_action_proposal reflect the
post-image_fit state (not the stale pre-image_fit state).
- direct entry triggers (§11.7.2) for layout_adjust /
frame_internal_fit_candidate / image_fit_insufficient route into
`_attempt_salvage_chain` with a synthetic initial_failure_type that
failure_router u2 NEXT_ACTION_BY_FAILURE maps onto the proposed action.
Test surfaces (12 tests):
1. helper returns triggered=False when no image_events.
2. helper returns triggered=False when every image_event is below tol
(delta=None or |delta|<=tol).
3. helper returns triggered=False when plan_image_fit emits no CSS for
any feasible event (rendered_w/h missing — apply returns None).
4. helper PASS — out_path promoted, step shape correct (action=image_fit,
passed=True, image_fit_event_plans recorded, post_salvage_overflow).
5. helper FAIL — out_path NOT promoted, step records failure_reason +
no post_salvage_overflow key.
6. helper aggregates CSS chunks from multiple events into ONE candidate
re-render (render_slide called exactly once).
7. helper writes candidate to run_dir as `salvage_image_fit_candidate.html`
and step.candidate_path is the project-root-relative form.
8. helper passes delta_tol through to plan_image_fit (override threshold
filters which events get planned/applied).
9. image_fit stays OUT of `_SALVAGE_FAIL_BY_ACTION` (u6 guard re-asserted
so u7 does not accidentally register image_fit as a cascade stage).
10. helper guards out_path mutation strictly under PASS (FAIL = no write).
11. helper exposes every plan_image_fit result through `event_plans`
even when no CSS was emitted (telemetry continuity for Step 17/18/19).
12. helper honors frame-scoped img CSS only — emitted CSS contains an
img selector and does NOT touch shared margins / slide-body / zone gap.
"""
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
import pytest
import src.phase_z2_pipeline as _pz_pipeline
from src.phase_z2_pipeline import (
_SALVAGE_FAIL_BY_ACTION,
_attempt_step17_image_fit_single_pass,
)
_PROJECT_ROOT = _pz_pipeline.PROJECT_ROOT
@pytest.fixture
def project_tmp(tmp_path_factory):
"""Temp dir under PROJECT_ROOT so candidate_path.relative_to(PROJECT_ROOT)
does not raise ValueError on a cross-drive Windows tmp path. Mirrors the
fixture in test_phase_z2_pipeline_salvage_imp88.py."""
base = _PROJECT_ROOT / ".orchestrator" / "tmp"
base.mkdir(parents=True, exist_ok=True)
d = Path(tempfile.mkdtemp(prefix="imp88_u7_", dir=str(base)))
try:
yield d
finally:
shutil.rmtree(d, ignore_errors=True)
# IMP-09 gate-passing layout_css envelope — kept for parity even though the
# image_fit single-pass helper does not consult the gate (gate is internal
# to _attempt_salvage_chain). Reserved for §11.7.2 direct-entry tests.
_LAYOUT_CSS_GATE_PASS = {
"areas": '"top" "bottom"',
"cols": "1fr",
"rows": "1fr 1fr",
"heights_px": [300, 290],
"widths_px": [1180],
"ratios": [0.508, 0.491],
"width_ratios": [1.0],
"dynamic_rows": True,
"dynamic_cols": False,
}
def _stub_render_capture(monkeypatch):
"""Stub render_slide → deterministic HTML envelope; counter exposes
invocation count + a recorder of the (preset, css overlay observed via
candidate_html) tuple per call."""
state = {"n": 0, "calls": []}
def _stub(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=14):
state["n"] += 1
state["calls"].append({
"preset": layout_preset, "zones_count": len(zones_data),
"gap_px": gap_px,
})
return (
f"<html><head><meta charset='utf-8'></head>"
f"<body><div data-rendered-preset='{layout_preset}'>"
f"<img src='zoneA.png'/></div></body></html>"
)
monkeypatch.setattr(_pz_pipeline, "render_slide", _stub)
return state
def _zones() -> list[dict]:
return [
{"position": "top", "template_id": "t-top",
"content_weight": {"score": 1.0}},
{"position": "bottom", "template_id": "t-bottom",
"content_weight": {"score": 1.0}},
]
def _image_event(*, src: str, zone_position: str = "top",
zone_template_id: str = "t-top",
natural_w: int = 1600, natural_h: int = 900,
rendered_w: int = 800, rendered_h: int = 600,
delta: float | None = 0.20) -> dict:
"""Mirror the shape pipeline JS injection emits at lines 3019-3060."""
natural_ratio = natural_w / natural_h if natural_h else None
rendered_ratio = rendered_w / rendered_h if rendered_h else None
return {
"src": src,
"zone_position": zone_position,
"zone_template_id": zone_template_id,
"natural_w": natural_w, "natural_h": natural_h,
"rendered_w": rendered_w, "rendered_h": rendered_h,
"natural_ratio": natural_ratio,
"rendered_ratio": rendered_ratio,
"delta": delta,
}
# ── 1. No image_events → not triggered ──────────────────────────────
def test_helper_not_triggered_when_no_image_events(project_tmp, monkeypatch):
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
counter = _stub_render_capture(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: pytest.fail("must not run overflow_check when not triggered"),
)
res = _attempt_step17_image_fit_single_pass(
run_dir=project_tmp, out_path=out_path,
slide_title="u7-empty", slide_footer=None,
zones_data=_zones(), layout_preset="vertical-2",
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=[], gap_px=14,
)
assert res["triggered"] is False
assert res["passed"] is False
assert res["step"] is None
assert res["candidate_html"] is None
assert res["candidate_overflow"] is None
assert res["event_plans"] == []
assert counter["n"] == 0
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_U7"
# ── 2. All events sub-tolerance → not triggered ─────────────────────
def test_helper_not_triggered_when_all_events_under_tolerance(project_tmp, monkeypatch):
"""delta=None (image not loaded) + |delta|<=tol (no aspect mismatch
above threshold) both should produce feasible=False plans, no CSS,
and triggered=False at the helper level."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
counter = _stub_render_capture(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: pytest.fail("must not run overflow_check when not triggered"),
)
events = [
_image_event(src="zoneA.png", delta=None), # not loaded
_image_event(src="zoneB.png", delta=0.03), # under tol
]
res = _attempt_step17_image_fit_single_pass(
run_dir=project_tmp, out_path=out_path,
slide_title="u7-undertol", slide_footer=None,
zones_data=_zones(), layout_preset="vertical-2",
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=events, gap_px=14,
)
assert res["triggered"] is False
assert len(res["event_plans"]) == 2
assert all(p.get("feasible") is False for p in res["event_plans"])
assert counter["n"] == 0
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_U7"
# ── 3. Feasible but apply_image_fit_css returns None ─────────────────
def test_helper_not_triggered_when_apply_returns_none(project_tmp, monkeypatch):
"""rendered_w/h missing on a feasible-shaped event means apply_image_fit_css
would emit empty CSS — but plan_image_fit treats missing rendered dims as
infeasible (per u4 contract), so this stays triggered=False."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
counter = _stub_render_capture(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: pytest.fail("must not run overflow_check when not triggered"),
)
bad_event = _image_event(src="zoneA.png", delta=0.20)
bad_event["rendered_w"] = 0 # invalidates apply path → plan infeasible
res = _attempt_step17_image_fit_single_pass(
run_dir=project_tmp, out_path=out_path,
slide_title="u7-noapply", slide_footer=None,
zones_data=_zones(), layout_preset="vertical-2",
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=[bad_event], gap_px=14,
)
assert res["triggered"] is False
assert res["event_plans"][0].get("feasible") is False
assert counter["n"] == 0
# ── 4. helper PASS — out_path promoted, step shape correct ──────────
def test_helper_pass_promotes_final_html(project_tmp, monkeypatch):
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
counter = _stub_render_capture(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []},
)
res = _attempt_step17_image_fit_single_pass(
run_dir=project_tmp, out_path=out_path,
slide_title="u7-pass", slide_footer=None,
zones_data=_zones(), layout_preset="vertical-2",
layout_css=_LAYOUT_CSS_GATE_PASS,
image_events=[_image_event(src="zoneA.png", delta=0.30)],
gap_px=14,
)
assert res["triggered"] is True
assert res["passed"] is True
assert res["step"]["action"] == "image_fit"
assert res["step"]["passed"] is True
assert res["step"]["post_salvage_overflow"] == {"passed": True, "fail_reasons": []}
assert "failure_reason" not in res["step"]
assert res["step"]["image_fit_event_plans"]
assert res["step"]["image_fit_event_plans"][0]["feasible"] is True
assert counter["n"] == 1
# out_path promoted with the candidate HTML (style overlay injected).
promoted = out_path.read_text(encoding="utf-8")
assert "ORIGINAL_BEFORE_U7" not in promoted
assert "data-rendered-preset='vertical-2'" in promoted
assert "<style>" in promoted
# ── 5. helper FAIL — out_path NOT promoted, failure_reason recorded ──
def test_helper_fail_leaves_out_path_untouched(project_tmp, monkeypatch):
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
_stub_render_capture(monkeypatch)
fail_payload = {
"passed": False,
"fail_reasons": ["zone--top (t-top) overflowed by 30px (vert) / 0px (horiz)"],
}
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check", lambda p: fail_payload,
)
res = _attempt_step17_image_fit_single_pass(
run_dir=project_tmp, out_path=out_path,
slide_title="u7-fail", slide_footer=None,
zones_data=_zones(), layout_preset="vertical-2",
layout_css=_LAYOUT_CSS_GATE_PASS,
image_events=[_image_event(src="zoneA.png", delta=0.30)],
gap_px=14,
)
assert res["triggered"] is True
assert res["passed"] is False
assert res["step"]["passed"] is False
assert "post_salvage_overflow" not in res["step"]
assert res["step"]["failure_reason"] == fail_payload["fail_reasons"]
# out_path stays at the pre-helper value — strict PASS-only promotion.
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_U7"
# ── 6. helper aggregates multi-event CSS into one re-render ─────────
def test_helper_aggregates_multi_event_css_into_single_render(project_tmp, monkeypatch):
"""Three feasible image events → CSS chunks concatenated → ONE render_slide
call (not three). The aggregated style block is injected before </head>."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
counter = _stub_render_capture(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []},
)
events = [
_image_event(src="zoneA.png", zone_position="top", delta=0.20),
_image_event(src="zoneB.png", zone_position="top", delta=0.25),
_image_event(src="zoneC.png", zone_position="bottom",
zone_template_id="t-bottom", delta=0.18),
]
res = _attempt_step17_image_fit_single_pass(
run_dir=project_tmp, out_path=out_path,
slide_title="u7-multi", slide_footer=None,
zones_data=_zones(), layout_preset="vertical-2",
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=events, gap_px=14,
)
assert res["triggered"] is True
assert res["passed"] is True
assert counter["n"] == 1
promoted = out_path.read_text(encoding="utf-8")
# All three image src selectors should appear in the merged overlay.
assert "zoneA.png" in promoted
assert "zoneB.png" in promoted
assert "zoneC.png" in promoted
# ── 7. candidate_path is project-relative ───────────────────────────
def test_helper_candidate_path_is_project_root_relative(project_tmp, monkeypatch):
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL", encoding="utf-8")
_stub_render_capture(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": False, "fail_reasons": ["nope"]},
)
res = _attempt_step17_image_fit_single_pass(
run_dir=project_tmp, out_path=out_path,
slide_title="u7-relpath", slide_footer=None,
zones_data=_zones(), layout_preset="vertical-2",
layout_css=_LAYOUT_CSS_GATE_PASS,
image_events=[_image_event(src="zoneA.png", delta=0.20)],
gap_px=14,
)
assert res["triggered"] is True
cp = res["step"]["candidate_path"]
# project-relative path ending with the salvage candidate filename.
assert cp.endswith("salvage_image_fit_candidate.html")
assert not Path(cp).is_absolute()
# Concrete file actually exists on disk.
assert (project_tmp / "salvage_image_fit_candidate.html").exists()
# ── 8. delta_tol override threads through ────────────────────────────
def test_helper_passes_delta_tol_through_to_plan_image_fit(project_tmp, monkeypatch):
"""An event with |delta|=0.10 is OVER default tol (0.05) but UNDER an
override tol of 0.20. With override the helper should see infeasible
plans → triggered=False; with default it should see feasible plans."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL", encoding="utf-8")
_stub_render_capture(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []},
)
ev = _image_event(src="zoneA.png", delta=0.10)
# Default tol = IMAGE_ASPECT_DELTA_TOL = 0.05 → plan feasible, triggered.
res_default = _attempt_step17_image_fit_single_pass(
run_dir=project_tmp, out_path=out_path,
slide_title="u7-tol", slide_footer=None,
zones_data=_zones(), layout_preset="vertical-2",
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=[ev], gap_px=14,
)
assert res_default["triggered"] is True
assert res_default["event_plans"][0].get("feasible") is True
# Override tol = 0.20 → plan infeasible (delta 0.10 within tol).
out_path.write_text("ORIGINAL", encoding="utf-8") # reset
res_override = _attempt_step17_image_fit_single_pass(
run_dir=project_tmp, out_path=out_path,
slide_title="u7-tol", slide_footer=None,
zones_data=_zones(), layout_preset="vertical-2",
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=[ev], gap_px=14,
delta_tol=0.20,
)
assert res_override["triggered"] is False
assert res_override["event_plans"][0].get("feasible") is False
# ── 9. image_fit stays OUT of _SALVAGE_FAIL_BY_ACTION ────────────────
def test_image_fit_stays_out_of_salvage_fail_map():
"""u7 entry single-pass is NOT a cascade salvage stage. The u6 guard
asserted this; re-assert here so u7's wiring does not accidentally
register image_fit into the cascade map."""
assert "image_fit" not in _SALVAGE_FAIL_BY_ACTION
# Cascade stages stay as u6 left them: 5 entries, image_fit absent.
assert len(_SALVAGE_FAIL_BY_ACTION) == 5
assert set(_SALVAGE_FAIL_BY_ACTION) == {
"cross_zone_redistribute",
"glue_compression",
"font_step_compression",
"layout_adjust",
"frame_internal_fit_candidate",
}
# ── 10. PASS-only out_path mutation (strict gate) ────────────────────
def test_helper_strictly_promotes_only_on_pass(project_tmp, monkeypatch):
"""Defensive — assert the helper does NOT write out_path on FAIL even
after rendering a candidate. Mutation must be strictly gated on
passed=True from run_overflow_check."""
out_path = project_tmp / "final.html"
canary = "STRICT_PROMOTION_CANARY_DO_NOT_OVERWRITE"
out_path.write_text(canary, encoding="utf-8")
_stub_render_capture(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": False, "fail_reasons": ["persists"]},
)
res = _attempt_step17_image_fit_single_pass(
run_dir=project_tmp, out_path=out_path,
slide_title="u7-strict", slide_footer=None,
zones_data=_zones(), layout_preset="vertical-2",
layout_css=_LAYOUT_CSS_GATE_PASS,
image_events=[_image_event(src="zoneA.png", delta=0.30)],
gap_px=14,
)
assert res["triggered"] is True
assert res["passed"] is False
# The candidate file IS written (telemetry continuity); out_path is NOT.
assert (project_tmp / "salvage_image_fit_candidate.html").exists()
assert out_path.read_text(encoding="utf-8") == canary
# ── 11. event_plans telemetry continuity even when no CSS emitted ───
def test_helper_event_plans_recorded_even_when_not_triggered(project_tmp, monkeypatch):
"""Step 17 telemetry must surface every plan_image_fit result via
event_plans so Step 18 / Step 19 can read planner-side decisions
even when the single-pass did not render a candidate."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL", encoding="utf-8")
_stub_render_capture(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: pytest.fail("not triggered → overflow_check must not run"),
)
events = [
_image_event(src="zoneA.png", delta=None), # not loaded
_image_event(src="zoneB.png", delta=0.02), # under default tol
]
res = _attempt_step17_image_fit_single_pass(
run_dir=project_tmp, out_path=out_path,
slide_title="u7-telemetry", slide_footer=None,
zones_data=_zones(), layout_preset="vertical-2",
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=events, gap_px=14,
)
assert res["triggered"] is False
assert res["step"] is None
# Every event still surfaces a plan — telemetry continuity invariant.
assert len(res["event_plans"]) == 2
failure_reasons = [p.get("failure_reason") for p in res["event_plans"]]
assert any("not loaded" in (r or "") for r in failure_reasons)
assert any("delta_tol" in (r or "") for r in failure_reasons)
# ── 12. Emitted CSS is img-scoped (Phase Z spacing guardrail) ────────
def test_helper_emits_img_scoped_css_only(project_tmp, monkeypatch):
"""[[feedback_phase_z_spacing_direction]] — image_fit must NOT shrink
common margins / slide-body / zone gap. The emitted CSS overlay must
target img selectors only."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL", encoding="utf-8")
_stub_render_capture(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []},
)
_attempt_step17_image_fit_single_pass(
run_dir=project_tmp, out_path=out_path,
slide_title="u7-scope", slide_footer=None,
zones_data=_zones(), layout_preset="vertical-2",
layout_css=_LAYOUT_CSS_GATE_PASS,
image_events=[_image_event(src="zoneA.png", delta=0.20)],
gap_px=14,
)
candidate = (project_tmp / "salvage_image_fit_candidate.html").read_text(
encoding="utf-8",
)
style_block_start = candidate.find("<style>")
style_block_end = candidate.find("</style>", style_block_start)
assert style_block_start >= 0 and style_block_end > style_block_start
style_body = candidate[style_block_start + len("<style>"):style_block_end]
# img-scoped selector present.
assert "img" in style_body
# Phase Z spacing guardrail — none of these shared-spacing tokens
# may appear in the image_fit overlay.
assert ".slide-body" not in style_body
assert "slide-base" not in style_body
assert "--spacing-page" not in style_body
assert "--spacing-block" not in style_body
assert "grid-gap" not in style_body
assert "padding-page" not in style_body
@@ -0,0 +1,252 @@
"""IMP-88 u5 — plan_frame_internal_fit_candidate / apply tests (Step 17).
Stage 2 contract (unit u5):
- plan_frame_internal_fit_candidate operates ONLY inside the frame
contract's declared `internal_envelope` (PHASE-Z-PIPELINE-OVERVIEW.md
:333 lock). No internal_envelope → infeasible(envelope_present=False).
Envelope present + variant.excess_budget_px >= overflow_zone.excess_y
→ feasible with selected_variant + frame-scoped css_overrides.
- apply_frame_internal_fit_candidate_css(plan) emits a frame-scoped CSS
rule (`.zone[data-template-id="<template_id>"]` selector) from the
selected variant's css_overrides. None on infeasible.
- Honors feedback_phase_z_spacing_direction — frame-scoped only, no
common margin / slide-body / zone gap shrink.
- Default contract loader (mapper.get_contract) overridable as kwarg so
tests stay free of the catalog cache / pipeline import cycle.
"""
from __future__ import annotations
from src.phase_z2_retry import (
apply_frame_internal_fit_candidate_css,
plan_frame_internal_fit_candidate,
)
def _contract(*, variants=None, with_envelope=True) -> dict:
"""Build a synthetic frame contract for u5 planner tests."""
c: dict = {
"template_id": "frame_internal_fit_test",
"source_shape": "top_bullets",
"cardinality": {"strict": 3},
}
if with_envelope:
c["internal_envelope"] = {
"variants": list(variants or []),
}
return c
def _variant(*, name: str, budget_px: int, css: dict | None = None) -> dict:
return {
"name": name,
"excess_budget_px": budget_px,
"css_overrides": dict(css or {"--frame-density": "compact"}),
}
# ─── planner: no-envelope infeasible paths ──────────────────────
def test_no_contract_infeasible_with_clear_reason():
plan = plan_frame_internal_fit_candidate(
frame_template_id="non_existent_frame",
frame_contract={}, # caller passed empty dict — explicit no-contract
)
# Empty dict counts as "contract present but no internal_envelope" → present=False.
assert plan["action"] == "frame_internal_fit_candidate"
assert plan["feasible"] is False
assert plan["envelope_present"] is False
assert plan["selected_variant"] is None
assert plan["css_overrides"] is None
assert "internal_envelope" in plan["failure_reason"]
def test_contract_lookup_none_returns_no_contract_failure():
# Simulate mapper.get_contract returning None: caller passes None explicitly
# via overriding kwarg path — planner falls back to mapper path then returns
# the dedicated "no frame contract registered" failure.
# We exercise this directly by passing a sentinel template_id that has no
# entry; the planner default-loads via mapper.get_contract.
plan = plan_frame_internal_fit_candidate(
frame_template_id="__sentinel_unregistered_template__",
)
assert plan["feasible"] is False
assert plan["envelope_present"] is False
assert "no frame contract registered" in plan["failure_reason"]
assert "__sentinel_unregistered_template__" in plan["failure_reason"]
def test_contract_without_internal_envelope_infeasible():
plan = plan_frame_internal_fit_candidate(
frame_template_id="f_test",
frame_contract=_contract(with_envelope=False),
)
assert plan["feasible"] is False
assert plan["envelope_present"] is False
assert "does not declare internal_envelope" in plan["failure_reason"]
assert "frame_reselect" in plan["failure_reason"]
def test_envelope_present_but_empty_variants_infeasible():
plan = plan_frame_internal_fit_candidate(
frame_template_id="f_test",
frame_contract=_contract(variants=[]),
)
assert plan["feasible"] is False
assert plan["envelope_present"] is True
assert plan["candidates_considered"] == []
assert "no variants" in plan["failure_reason"]
# ─── planner: feasible paths ────────────────────────────────────
def test_single_variant_within_budget_is_selected():
contract = _contract(variants=[
_variant(name="density_compact", budget_px=40,
css={"--frame-density": "compact", "font-size": "0.95em"}),
])
plan = plan_frame_internal_fit_candidate(
frame_template_id="f_test",
frame_contract=contract,
overflow_zone={"excess_y": 24.0},
)
assert plan["feasible"] is True
assert plan["envelope_present"] is True
assert plan["selected_variant"] == "density_compact"
assert plan["selected_variant_budget_px"] == 40
assert plan["excess_y"] == 24
assert plan["css_overrides"]["font-size"] == "0.95em"
assert plan["css_overrides"]["--frame-density"] == "compact"
def test_greedy_walk_picks_first_variant_that_fits_in_catalog_order():
# density_compact only absorbs 10px; line_rhythm 60px; grid_row 200px.
# excess_y=45 → density_compact rejected, line_rhythm selected (first fit).
contract = _contract(variants=[
_variant(name="density_compact", budget_px=10),
_variant(name="line_rhythm_tight", budget_px=60),
_variant(name="grid_row_collapse", budget_px=200),
])
plan = plan_frame_internal_fit_candidate(
frame_template_id="f_test",
frame_contract=contract,
overflow_zone={"excess_y": 45.0},
)
assert plan["feasible"] is True
assert plan["selected_variant"] == "line_rhythm_tight"
assert plan["selected_variant_budget_px"] == 60
assert plan["candidates_considered"] == [
"density_compact", "line_rhythm_tight", "grid_row_collapse",
]
def test_no_overflow_zone_picks_first_variant():
# excess_y default = 0 → every variant qualifies; first catalog entry wins.
contract = _contract(variants=[
_variant(name="first", budget_px=5),
_variant(name="second", budget_px=100),
])
plan = plan_frame_internal_fit_candidate(
frame_template_id="f_test", frame_contract=contract,
)
assert plan["feasible"] is True
assert plan["selected_variant"] == "first"
assert plan["excess_y"] == 0
def test_all_variants_below_budget_returns_infeasible_with_excess():
contract = _contract(variants=[
_variant(name="small", budget_px=10),
_variant(name="medium", budget_px=25),
])
plan = plan_frame_internal_fit_candidate(
frame_template_id="f_test", frame_contract=contract,
overflow_zone={"excess_y": 80.0},
)
assert plan["feasible"] is False
assert plan["envelope_present"] is True
assert plan["excess_y"] == 80
assert plan["selected_variant"] is None
assert "excess_y=80px" in plan["failure_reason"]
assert plan["candidates_considered"] == ["small", "medium"]
def test_excess_y_is_ceil_rounded():
# Sub-pixel overflow rounds up so a budget exactly matching the integer
# excess covers the case. 23.4 → 24 → variant(budget=24) is selected.
contract = _contract(variants=[
_variant(name="exact_fit", budget_px=24),
])
plan = plan_frame_internal_fit_candidate(
frame_template_id="f_test", frame_contract=contract,
overflow_zone={"excess_y": 23.4},
)
assert plan["feasible"] is True
assert plan["excess_y"] == 24
assert plan["selected_variant"] == "exact_fit"
def test_envelope_keys_recorded_for_telemetry():
contract = _contract(variants=[_variant(name="v1", budget_px=100)])
# Inject an extra envelope key to ensure planner surfaces them.
contract["internal_envelope"]["envelope_kind"] = "density_grid"
plan = plan_frame_internal_fit_candidate(
frame_template_id="f_test", frame_contract=contract,
)
assert "envelope_kind" in plan["envelope_keys"]
assert "variants" in plan["envelope_keys"]
# ─── apply: frame-scoped CSS snippet ────────────────────────────
def test_apply_emits_frame_template_scoped_selector():
contract = _contract(variants=[
_variant(name="density_compact", budget_px=100, css={
"--frame-density": "compact",
"font-size": "0.92em",
"line-height": "1.35",
}),
])
plan = plan_frame_internal_fit_candidate(
frame_template_id="three_parallel_requirements",
frame_contract=contract,
)
css = apply_frame_internal_fit_candidate_css(plan)
assert css is not None
assert ".zone[data-template-id=\"three_parallel_requirements\"]" in css
assert "--frame-density: compact;" in css
assert "font-size: 0.92em;" in css
assert "line-height: 1.35;" in css
def test_apply_infeasible_returns_none():
plan = plan_frame_internal_fit_candidate(
frame_template_id="f_test",
frame_contract=_contract(with_envelope=False),
)
assert apply_frame_internal_fit_candidate_css(plan) is None
def test_apply_does_not_shrink_shared_spacing():
# feedback_phase_z_spacing_direction: emitted CSS must scope to the frame
# only and MUST NOT touch slide-body / outer zone / gap / common margin /
# padding tokens. The selector is frame-scoped; body comes from author-
# declared envelope css_overrides. We sanity-check that nothing in the
# apply helper introduces shared-spacing properties of its own.
contract = _contract(variants=[
_variant(name="density_compact", budget_px=100,
css={"--frame-density": "compact"}),
])
plan = plan_frame_internal_fit_candidate(
frame_template_id="f_test", frame_contract=contract,
)
css = apply_frame_internal_fit_candidate_css(plan)
assert css is not None
for forbidden in (".slide-body", ".zone-container", "grid-gap", "gap:",
"padding:", "margin:"):
assert forbidden not in css, (
f"frame_internal_fit CSS leaked shared-spacing token "
f"'{forbidden}' — see feedback_phase_z_spacing_direction."
)
@@ -0,0 +1,198 @@
"""IMP-88 u4 — plan_image_fit / apply_image_fit_css tests (Step 17 entry).
Stage 2 contract (unit u4):
- plan_image_fit consumes a single image_event (overflow_metrics.image_
events shape: natural_w/h, rendered_w/h, natural_ratio, rendered_ratio,
delta, src, zone_position, zone_template_id) and returns:
success : {feasible=True, css_overrides={object_fit, max_width_px,
max_height_px, width, height}, delta, correction_axis,
natural_*, rendered_*}
no-op : {feasible=False, failure_reason} when |delta| <= tol or
delta is None
infeas : {feasible=False, failure_reason} when rendered_w/h missing
or non-positive
- apply_image_fit_css(plan) returns a frame-scoped CSS rule string for
feasible plans (object-fit + max-w/h constraints scoped to the zone +
src image), None for infeasible plans.
- Honors feedback_phase_z_spacing_direction — image-scoped CSS only, no
common margin / frame envelope shrink.
"""
from __future__ import annotations
from src.phase_z2_retry import apply_image_fit_css, plan_image_fit
def _image_event(
*, delta: float | None = 0.20, src: str = "/images/diagram.png",
zone_position: str = "top", zone_template_id: str = "frame_07",
natural_w: int = 1200, natural_h: int = 800,
rendered_w: int = 600, rendered_h: int = 300,
) -> dict:
"""image_event shape mirroring runtime overflow_metrics.image_events[i]."""
natural_ratio = natural_w / natural_h if natural_h else None
rendered_ratio = rendered_w / rendered_h if rendered_h else None
return {
"src": src,
"zone_position": zone_position,
"zone_template_id": zone_template_id,
"natural_w": natural_w,
"natural_h": natural_h,
"rendered_w": rendered_w,
"rendered_h": rendered_h,
"natural_ratio": natural_ratio,
"rendered_ratio": rendered_ratio,
"delta": delta,
"bbox": {"x": 0, "y": 0, "w": rendered_w, "h": rendered_h},
}
# ─── planner: success paths ─────────────────────────────────────
def test_positive_delta_emits_width_correction_axis():
# natural 1200x800 (1.5), rendered 600x300 (2.0) → delta = +0.5
ev = _image_event(
delta=0.5,
natural_w=1200, natural_h=800,
rendered_w=600, rendered_h=300,
)
plan = plan_image_fit(image_event=ev)
assert plan["action"] == "image_fit"
assert plan["feasible"] is True
assert plan["correction_axis"] == "width"
assert plan["delta"] == 0.5
assert plan["natural_w"] == 1200
assert plan["rendered_w"] == 600
overrides = plan["css_overrides"]
assert overrides["object_fit"] == "contain"
assert overrides["max_width_px"] == 600
assert overrides["max_height_px"] == 300
assert overrides["width"] == "auto"
assert overrides["height"] == "auto"
def test_negative_delta_emits_height_correction_axis():
# natural 800x1200 (~0.667), rendered 600x600 (1.0) → delta = +0.333 not -;
# use rendered taller than natural for negative delta.
ev = _image_event(
delta=-0.40,
natural_w=1600, natural_h=800,
rendered_w=400, rendered_h=400,
)
plan = plan_image_fit(image_event=ev)
assert plan["feasible"] is True
assert plan["correction_axis"] == "height"
assert plan["css_overrides"]["max_width_px"] == 400
assert plan["css_overrides"]["max_height_px"] == 400
def test_planner_passes_through_zone_and_template_metadata():
ev = _image_event(
delta=0.20,
src="/img/policy.png",
zone_position="bottom-right",
zone_template_id="f23",
)
plan = plan_image_fit(image_event=ev)
assert plan["feasible"] is True
assert plan["src"] == "/img/policy.png"
assert plan["zone_position"] == "bottom-right"
assert plan["zone_template_id"] == "f23"
# ─── planner: infeasible / no-op paths ──────────────────────────
def test_delta_none_infeasible_with_clear_reason():
ev = _image_event(delta=None)
plan = plan_image_fit(image_event=ev)
assert plan["feasible"] is False
assert plan["css_overrides"] is None
assert "delta is None" in plan["failure_reason"]
def test_delta_within_tolerance_returns_planner_noop():
ev = _image_event(delta=0.02)
plan = plan_image_fit(image_event=ev)
assert plan["feasible"] is False
assert "no image_aspect_mismatch to correct" in plan["failure_reason"]
# No-op path still records delta + action for telemetry continuity.
assert plan["delta"] == 0.02
assert plan["action"] == "image_fit"
def test_delta_at_boundary_is_planner_noop():
# |delta| == delta_tol is treated as no-op (strict greater-than is the
# emission threshold in the classifier).
ev = _image_event(delta=0.05)
plan = plan_image_fit(image_event=ev, delta_tol=0.05)
assert plan["feasible"] is False
def test_rendered_w_zero_infeasible():
ev = _image_event(rendered_w=0)
plan = plan_image_fit(image_event=ev)
assert plan["feasible"] is False
assert "rendered_w / rendered_h" in plan["failure_reason"]
def test_rendered_h_missing_infeasible():
ev = _image_event()
ev.pop("rendered_h", None)
plan = plan_image_fit(image_event=ev)
assert plan["feasible"] is False
assert plan["css_overrides"] is None
def test_custom_delta_tol_widens_noop_band():
ev = _image_event(delta=0.10)
# default tol=0.05 → feasible, but caller-supplied tol=0.20 → no-op.
assert plan_image_fit(image_event=ev)["feasible"] is True
assert plan_image_fit(image_event=ev, delta_tol=0.20)["feasible"] is False
# ─── apply: frame-scoped CSS snippet ────────────────────────────
def test_apply_image_fit_css_emits_zone_and_src_scoped_selector():
plan = plan_image_fit(image_event=_image_event(
delta=0.30,
src="/images/process.png",
zone_position="top",
rendered_w=520, rendered_h=240,
))
css = apply_image_fit_css(plan)
assert css is not None
assert ".zone[data-zone-position=\"top\"]" in css
assert "img[src=\"/images/process.png\"]" in css
assert "object-fit: contain;" in css
assert "max-width: 520px;" in css
assert "max-height: 240px;" in css
assert "width: auto;" in css
assert "height: auto;" in css
def test_apply_image_fit_css_without_src_falls_back_to_zone_only_selector():
ev = _image_event(delta=0.30, src="")
css = apply_image_fit_css(plan_image_fit(image_event=ev))
assert css is not None
assert ".zone[data-zone-position=\"top\"] img {" in css
assert "img[src=" not in css
def test_apply_image_fit_css_infeasible_returns_none():
plan = plan_image_fit(image_event=_image_event(delta=None))
assert apply_image_fit_css(plan) is None
def test_apply_image_fit_css_does_not_shrink_shared_spacing():
# feedback_phase_z_spacing_direction: CSS must scope to image only and
# MUST NOT touch slide-body / zone / frame / gap / margin / padding.
plan = plan_image_fit(image_event=_image_event(delta=0.30))
css = apply_image_fit_css(plan)
assert css is not None
for forbidden in (".slide-body", ".zone-container", "padding:", "margin:",
"gap:", "grid-gap"):
assert forbidden not in css, (
f"image_fit CSS leaked shared-spacing token '{forbidden}'"
)
@@ -0,0 +1,168 @@
"""IMP-88 u3 — plan_layout_adjust tests (Step 17 retry chain).
Stage 2 contract (unit u3):
- planner returns {feasible, new_layout_preset, new_zones_data,
position_remap, candidates_considered, swap_topology_from/to} on success;
{feasible=False, failure_reason} on infeasible (no sibling, unknown
preset, zone-count mismatch).
- apply_layout_adjust_layout_css(plan, gap_px) builds a fresh layout_css
via build_layout_css with the swapped preset + remapped zones_data; raw_
zone_layout records layout_adjust_applied/from/to provenance. Infeasible
plan -> None (dispatcher u6 skips re-render).
- Honors feedback_phase_z_spacing_direction — preset swap only, no shared
spacing shrink claim.
"""
from __future__ import annotations
from src.phase_z2_composition import LAYOUT_PRESETS
from src.phase_z2_retry import (
apply_layout_adjust_layout_css,
plan_layout_adjust,
)
def _zones(positions: list[str]) -> list[dict]:
"""Minimal zones_data shape for planner consumption."""
return [
{
"position": pos,
"template_id": f"frame_{i}",
"min_height_px": 120,
"content_weight": {"score": 1.0},
"slot_payload": {"title": f"zone_{i}"},
}
for i, pos in enumerate(positions)
]
# ─── planner: success paths ─────────────────────────────────────
def test_horizontal_2_swaps_to_vertical_2_orientation_axis():
plan = plan_layout_adjust(
current_layout_preset="horizontal-2",
zones_data=_zones(["top", "bottom"]),
)
assert plan["action"] == "layout_adjust"
assert plan["feasible"] is True
assert plan["new_layout_preset"] == "vertical-2"
assert plan["unit_count"] == 2
assert plan["swap_topology_from"] == "rows"
assert plan["swap_topology_to"] == "cols"
assert plan["position_remap"] == {"top": "left", "bottom": "right"}
new_zd = plan["new_zones_data"]
assert [z["position"] for z in new_zd] == ["left", "right"]
# Non-position payload preserved through remap.
assert new_zd[0]["template_id"] == "frame_0"
assert new_zd[1]["slot_payload"] == {"title": "zone_1"}
def test_vertical_2_swaps_back_to_horizontal_2():
plan = plan_layout_adjust(
current_layout_preset="vertical-2",
zones_data=_zones(["left", "right"]),
)
assert plan["feasible"] is True
assert plan["new_layout_preset"] == "horizontal-2"
assert plan["position_remap"] == {"left": "top", "right": "bottom"}
def test_T_swaps_to_inverted_T_first_by_topology_priority():
plan = plan_layout_adjust(
current_layout_preset="top-1-bottom-2",
zones_data=_zones(["top", "bottom-left", "bottom-right"]),
)
assert plan["feasible"] is True
# 3-unit siblings: top-2-bottom-1, left-1-right-2, left-2-right-1.
# _layout_swap_priority puts T<->inverted-T at priority 1 (before side-T).
assert plan["new_layout_preset"] == "top-2-bottom-1"
assert plan["candidates_considered"] == [
"top-2-bottom-1", "left-1-right-2", "left-2-right-1",
]
# ─── planner: infeasible paths ──────────────────────────────────
def test_single_preset_infeasible_no_sibling():
plan = plan_layout_adjust(
current_layout_preset="single",
zones_data=_zones(["primary"]),
)
assert plan["feasible"] is False
assert plan["new_layout_preset"] is None
assert plan["unit_count"] == 1
assert plan["candidates_considered"] == []
assert "no render-ready 8-preset sibling" in plan["failure_reason"]
assert "single (1)" in plan["failure_reason"]
def test_grid_2x2_preset_infeasible_no_sibling():
plan = plan_layout_adjust(
current_layout_preset="grid-2x2",
zones_data=_zones(["top-left", "top-right", "bottom-left", "bottom-right"]),
)
assert plan["feasible"] is False
assert plan["candidates_considered"] == []
assert "grid-2x2 (4)" in plan["failure_reason"]
def test_unknown_preset_infeasible_with_clear_reason():
plan = plan_layout_adjust(
current_layout_preset="not-a-real-preset",
zones_data=_zones(["top", "bottom"]),
)
assert plan["feasible"] is False
assert plan["new_layout_preset"] is None
assert "not in LAYOUT_PRESETS catalog" in plan["failure_reason"]
def test_zone_count_mismatch_infeasible():
plan = plan_layout_adjust(
current_layout_preset="horizontal-2",
zones_data=_zones(["top", "bottom", "extra"]),
)
assert plan["feasible"] is False
assert "length 3" in plan["failure_reason"]
assert "horizontal-2" in plan["failure_reason"]
# ─── apply: layout_css construction + provenance ─────────────────
def test_apply_layout_adjust_builds_new_layout_css_with_provenance():
plan = plan_layout_adjust(
current_layout_preset="horizontal-2",
zones_data=_zones(["top", "bottom"]),
)
layout_css = apply_layout_adjust_layout_css(plan, gap_px=20)
assert layout_css is not None
# Mirrors build_layout_css(vertical-2, ...) output shape.
assert layout_css["areas"] == LAYOUT_PRESETS["vertical-2"]["css_areas"]
assert "heights_px" in layout_css and "widths_px" in layout_css
raw = layout_css["raw_zone_layout"]
assert raw["layout_adjust_applied"] is True
assert raw["layout_adjust_from"] == "horizontal-2"
assert raw["layout_adjust_to"] == "vertical-2"
def test_apply_layout_adjust_infeasible_returns_none():
plan = plan_layout_adjust(
current_layout_preset="single",
zones_data=_zones(["primary"]),
)
assert apply_layout_adjust_layout_css(plan, gap_px=20) is None
def test_apply_layout_adjust_T_swap_produces_2d_dynamic_css():
plan = plan_layout_adjust(
current_layout_preset="top-1-bottom-2",
zones_data=_zones(["top", "bottom-left", "bottom-right"]),
)
layout_css = apply_layout_adjust_layout_css(plan, gap_px=20)
assert layout_css is not None
# top-2-bottom-1 is a 2-D dynamic preset.
assert layout_css["dynamic_rows"] is True
assert layout_css["dynamic_cols"] is True
assert layout_css["areas"] == LAYOUT_PRESETS["top-2-bottom-1"]["css_areas"]
assert layout_css["raw_zone_layout"]["layout_adjust_to"] == "top-2-bottom-1"
@@ -198,10 +198,19 @@ def test_case_b_cross_zone_fails_glue_passes_second_promoted(project_tmp, monkey
def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch): def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
"""(c) All three cascade actions are infeasible (no CSS emitted by any """(c) All cascade actions are infeasible (no CSS / no candidate emitted
planner) → run_overflow_check is never invoked, salvage_passed=False, by any planner) → run_overflow_check is never invoked, salvage_passed=
salvage_steps has three failed entries, and out_path is unchanged False, out_path is unchanged (original final.html intact — (b)-revert
(original final.html intact — (b)-revert preserved).""" preserved).
IMP-88 u6 extends the cascade depth from 3 to 5 stages (layout_adjust +
frame_internal_fit_candidate added). When all stages are infeasible the
cascade now runs through all five — the empty zones_data carried by
_kwargs() makes plan_layout_adjust infeasible (length mismatch) and the
empty resulting template_id makes plan_frame_internal_fit_candidate
infeasible (no contract). The (b)-revert contract this test locks
(out_path untouched + salvage_passed=False) is unchanged.
"""
out_path = project_tmp / "final.html" out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8") out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
@@ -231,18 +240,24 @@ def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
assert trace["salvage_attempted"] is True assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is False assert trace["salvage_passed"] is False
assert len(trace["salvage_steps"]) == 3 # IMP-88 u6 — cascade depth extended from 3 to 5; see _SALVAGE_FAIL_BY_ACTION.
assert len(trace["salvage_steps"]) == 5
actions = [s["action"] for s in trace["salvage_steps"]] actions = [s["action"] for s in trace["salvage_steps"]]
assert actions == [ assert actions == [
"cross_zone_redistribute", "cross_zone_redistribute",
"glue_compression", "glue_compression",
"font_step_compression", "font_step_compression",
"layout_adjust",
"frame_internal_fit_candidate",
] ]
for step in trace["salvage_steps"]: for step in trace["salvage_steps"]:
assert step["passed"] is False assert step["passed"] is False
assert step["css_override"] is None # layout_adjust uses a distinct render path → its step dict has no
# css_override key (new_layout_preset is the observability field
# instead). All other branches use the shared CSS-overlay path.
assert step.get("css_override") is None
assert step["failure_reason"] assert step["failure_reason"]
# No CSS emitted anywhere → no render_slide calls either. # No CSS / candidate emitted anywhere → no render_slide calls either.
assert render_counter["n"] == 0 assert render_counter["n"] == 0
# (b) revert: out_path is untouched. # (b) revert: out_path is untouched.
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE" assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
+223
View File
@@ -0,0 +1,223 @@
"""IMP-88 (#88) u1 — Step 17 retry chain router rows + status surface.
Stage 2 binding contract (unit u1, data-surface only):
- NEW row `image_aspect_mismatch → image_fit` in ACTION_BY_CATEGORY.
Closes the unmapped classifier emission gap at
src/phase_z2_classifier.py:434-447 where image_aspect_mismatch was
emitted with proposed_action=None (verified Stage 1).
- REMAP `frame_capacity_mismatch → frame_internal_fit_candidate`
(previously frame_reselect) per PHASE-Z-PIPELINE-OVERVIEW.md:321.
frame_reselect remains a valid downstream action via the
failure_router cascade (rerender_still_fails → frame_reselect).
- NEW ACTION_RATIONALE rows for image_aspect_mismatch +
frame_internal_fit_candidate (rationale text for trace surface).
- NEW ACTION_IMPLEMENTATION_STATUS rows for image_fit +
frame_internal_fit_candidate. layout_adjust is also registered.
u1 initial state was MISSING for all three. u7 completion flips the
rows to IMPLEMENTED once the end-to-end path (u3/u4/u5 planners +
u6 dispatcher + u7 Step 17 entry) is wired (same convention as
IMP-12 u7 cascade rows + IMP-35 u3 details_popup_escalation flip).
Out of scope for u1 (locked in Stage 2 exit report):
- failure_router cascade rows for the three actions → u2.
- planner stubs (plan_layout_adjust / plan_image_fit /
plan_frame_internal_fit_candidate) → u3 / u4 / u5.
- salvage dispatcher branches + Step 17 entry triggers → u6 / u7.
Post u7 completion (2026-05-24): status assertions in this file reflect
the IMPLEMENTED end-state. Test names that previously referenced "_missing"
are renamed to "_implemented_after_u7" so the surface contract is honest
about the post-u7 state.
"""
from __future__ import annotations
from src.phase_z2_router import (
ACTION_BY_CATEGORY,
ACTION_IMPLEMENTATION_STATUS,
ACTION_RATIONALE,
route_action,
route_fit_classification,
)
# ─── ACTION_BY_CATEGORY rows ──────────────────────────────────────
def test_image_aspect_mismatch_maps_to_image_fit():
"""u1 — NEW row closes the classifier→router gap.
Stage 1 verified that route_action('image_aspect_mismatch') returned
proposed_action=None with implementation_status='unknown'. u1 must
register the row so the classifier emission is routable.
"""
assert ACTION_BY_CATEGORY["image_aspect_mismatch"] == "image_fit"
def test_frame_capacity_mismatch_remaps_to_frame_internal_fit_candidate():
"""u1 — REMAP per PHASE-Z-PIPELINE-OVERVIEW.md:321.
Spec lock: frame_internal_fit_candidate is the per-zone first-pass
salvage inside the declared frame envelope. frame_reselect (V4 top-k
alternate frame swap) remains downstream via the failure_router
cascade (rerender_still_fails → frame_reselect).
"""
assert ACTION_BY_CATEGORY["frame_capacity_mismatch"] == "frame_internal_fit_candidate"
def test_existing_action_by_category_rows_unchanged():
"""u1 — non-IMP-88 rows must NOT be touched (regression guard).
Only two edits are allowed in u1: NEW image_aspect_mismatch row and
REMAP frame_capacity_mismatch row. Everything else is locked.
"""
assert ACTION_BY_CATEGORY["minor_overflow"] == "zone_ratio_retry"
assert ACTION_BY_CATEGORY["moderate_overflow"] == "layout_adjust"
assert ACTION_BY_CATEGORY["structural_minor_overflow"] == "zone_ratio_retry"
assert ACTION_BY_CATEGORY["structural_major_overflow"] == "details_popup_escalation"
assert ACTION_BY_CATEGORY["tabular_overflow"] == "details_popup_escalation"
assert ACTION_BY_CATEGORY["layout_zone_mismatch"] == "layout_adjust"
assert ACTION_BY_CATEGORY["hard_visual_fail"] == "abort"
# ─── ACTION_RATIONALE rows ────────────────────────────────────────
def test_image_aspect_mismatch_rationale_present():
"""u1 — trace surface must explain *why* image_aspect_mismatch routes
onto image_fit (frame-scoped, no global image CSS shrink — honors
feedback_phase_z_spacing_direction)."""
rationale = ACTION_RATIONALE.get("image_aspect_mismatch", "")
assert rationale.strip(), "image_aspect_mismatch rationale must be non-empty"
assert "image" in rationale.lower()
def test_frame_capacity_mismatch_rationale_updated_for_internal_fit():
"""u1 — rationale text must reflect the new internal-fit-first
routing. The text must no longer claim frame_reselect as the primary
action for this category (it's now the downstream cascade step)."""
rationale = ACTION_RATIONALE.get("frame_capacity_mismatch", "")
assert rationale.strip(), "frame_capacity_mismatch rationale must be non-empty"
# Mentions the new internal-fit direction.
assert "internal" in rationale.lower() or "envelope" in rationale.lower()
# ─── ACTION_IMPLEMENTATION_STATUS rows ────────────────────────────
def test_layout_adjust_status_implemented_after_u7():
"""u1 registered layout_adjust row (initial MISSING). After u3
(plan_layout_adjust + apply_layout_adjust_layout_css) + u6 (salvage
dispatcher branch) + u7 (cascade entry trigger) land the end-to-end
deterministic path, the status flips to IMPLEMENTED."""
assert ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "IMPLEMENTED"
def test_image_fit_status_implemented_after_u7():
"""u1 registered image_fit row (initial MISSING). After u4 (plan_image_fit
+ apply_image_fit_css) + u7 (_attempt_step17_image_fit_single_pass entry)
land the end-to-end deterministic path, the status flips to IMPLEMENTED."""
assert "image_fit" in ACTION_IMPLEMENTATION_STATUS
assert ACTION_IMPLEMENTATION_STATUS["image_fit"] == "IMPLEMENTED"
def test_frame_internal_fit_candidate_status_implemented_after_u7():
"""u1 registered frame_internal_fit_candidate row (initial MISSING). After
u5 (plan_frame_internal_fit_candidate + apply) + u6 (salvage dispatcher
branch) + u7 (cascade entry trigger) land the end-to-end deterministic
path, the status flips to IMPLEMENTED."""
assert "frame_internal_fit_candidate" in ACTION_IMPLEMENTATION_STATUS
assert ACTION_IMPLEMENTATION_STATUS["frame_internal_fit_candidate"] == "IMPLEMENTED"
def test_existing_action_implementation_status_rows_unchanged():
"""u1 — non-IMP-88 status rows must NOT regress. zone_ratio_retry,
cascade-only salvage actions, details_popup_escalation (IMP-35 u3),
and frame_reselect must keep their current statuses."""
assert ACTION_IMPLEMENTATION_STATUS["zone_ratio_retry"] == "IMPLEMENTED"
assert ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"] == "IMPLEMENTED"
assert ACTION_IMPLEMENTATION_STATUS["frame_reselect"] == "PARTIAL"
assert ACTION_IMPLEMENTATION_STATUS["adapter_needed"] == "PARTIAL"
assert ACTION_IMPLEMENTATION_STATUS["abort"] == "IMPLEMENTED"
assert ACTION_IMPLEMENTATION_STATUS["cross_zone_redistribute"] == "IMPLEMENTED"
assert ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
assert ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
# ─── route_action + route_fit_classification integration ──────────
def test_route_action_image_aspect_mismatch_returns_image_fit_implemented():
"""u1 — route_action surface composes the new mapping correctly.
Stage 1 evidence: previously this call returned proposed_action=None
and implementation_status='unknown'. After u1 + u4 + u7, the call must
return image_fit with status IMPLEMENTED (end-to-end deterministic path
via plan_image_fit + apply_image_fit_css + Step 17 single-pass entry)."""
routing = route_action("image_aspect_mismatch")
assert routing["proposed_action"] == "image_fit"
assert routing["implementation_status"] == "IMPLEMENTED"
assert routing["mapping_source"] == "spec §4 ACTION_BY_CATEGORY"
assert routing["rationale"], "rationale must be carried through route_action"
def test_route_action_frame_capacity_mismatch_returns_frame_internal_fit_candidate_implemented():
"""u1 — route_action surface reflects the REMAP. After u1 + u5 + u6 + u7
the status is IMPLEMENTED (end-to-end deterministic path via
plan_frame_internal_fit_candidate + apply + salvage dispatcher branch +
cascade entry trigger)."""
routing = route_action("frame_capacity_mismatch")
assert routing["proposed_action"] == "frame_internal_fit_candidate"
assert routing["implementation_status"] == "IMPLEMENTED"
assert routing["mapping_source"] == "spec §4 ACTION_BY_CATEGORY"
def test_route_fit_classification_surfaces_imp88_actions_as_implemented():
"""End-to-end: when classifier emits the two IMP-88 categories alongside
an already-implemented one, route_fit_classification:
- attaches proposed_action onto each row
- lists all three actions in proposed_actions_summary
- reports an empty missing_actions_pending_impl for the IMP-88 actions
(u7 completion flipped image_fit + frame_internal_fit_candidate to
IMPLEMENTED alongside layout_adjust)
- all three rows count as IMPLEMENTED in the status summary."""
fit_classification = {
"visual_check_passed": False,
"classifications": [
{
"source": "image_event",
"zone_position": "bottom",
"category": "image_aspect_mismatch",
},
{
"source": "composition",
"zone_position": "top",
"category": "frame_capacity_mismatch",
},
{
"source": "clipped_inner",
"zone_position": "bottom",
"category": "minor_overflow",
},
],
}
summary = route_fit_classification(fit_classification)
assert summary["router_active"] is True
assert summary["routed_count"] == 3
assert "image_fit" in summary["proposed_actions_summary"]
assert "frame_internal_fit_candidate" in summary["proposed_actions_summary"]
assert "zone_ratio_retry" in summary["proposed_actions_summary"]
# After u7 completion, both new IMP-88 actions are IMPLEMENTED on the
# router-surface — they no longer surface as missing pending impl.
assert "image_fit" not in summary["missing_actions_pending_impl"]
assert "frame_internal_fit_candidate" not in summary["missing_actions_pending_impl"]
# All three (zone_ratio_retry IMPLEMENTED + 2 IMP-88 IMPLEMENTED) count
# together. zone_ratio_retry was IMPLEMENTED since A3 cascade.
assert summary["implementation_status_summary"].get("IMPLEMENTED", 0) == 3
assert summary["implementation_status_summary"].get("MISSING", 0) == 0
# Per-row enrichment carries the new proposed actions onto entries.
cats = {c["category"]: c for c in fit_classification["classifications"]}
assert cats["image_aspect_mismatch"]["proposed_action"] == "image_fit"
assert (
cats["frame_capacity_mismatch"]["proposed_action"]
== "frame_internal_fit_candidate"
)
@@ -0,0 +1,151 @@
"""IMP-90 (#90) u17 — slide_base.html print-mode contract tests.
Stage 2 plan contract (unit u17):
Step 22 user-edit + Export track. The Phase Z2 print path MUST
auto-expand <details> popups so the FULL raw_content (MDX 원문 무손실
보존) is included when the user prints / exports from the browser.
u17 introduces two coordinated surfaces in
``templates/phase_z2/slide_base.html``:
1. ``@media print`` CSS block — neutralizes the on-screen-only body
centering / box-shadow / 280px popup card clipping so the slide
prints at 1280×720 with the expanded popup body in static flow.
2. ``beforeprint`` / ``afterprint`` JavaScript hook at body level —
toggles ``details.open`` to ``true`` before the print snapshot
and restores the user's prior open/closed state afterwards. Body
level (outside any ``<details>...</details>`` block) preserves
the IMP-35 u8 popup-render JS-free invariant
(tests/phase_z2/test_slide_base_popup_render.py
``test_popup_emits_no_javascript_on_render_path``).
Invariants locked here:
P-1: ``@media print`` block is emitted exactly once in the render.
P-2: ``@page`` size matches the 1280×720 slide canvas.
P-3: ``.slide`` box-shadow + body padding/min-height neutralized at
print time.
P-4: ``.zone__popup-summary`` hidden, popup body switches from
absolute to static flow with unconstrained height — the popup
card chrome (border / shadow / 280px max-height) is unset.
P-5: ``beforeprint`` + ``afterprint`` listeners are wired at body
level (NOT inside the per-zone details block) so the popup
render path stays JS-free.
P-6: Restore semantics — the script preserves the user's prior
open/closed state via a single ``dataset.imp90PrintRestore`` key
(no global state, no event-bus mutation).
"""
from __future__ import annotations
import re
from src.phase_z2_pipeline import render_slide
def _layout_css() -> dict:
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
def _zone(**overrides) -> dict:
base = {
"position": "primary",
"template_id": "__empty__",
"slot_payload": {},
}
base.update(overrides)
return base
def _render() -> str:
return render_slide(
slide_title="t",
slide_footer=None,
zones_data=[_zone()],
layout_preset="single",
layout_css=_layout_css(),
gap_px=14,
)
# ─── P-1 ─ media print block presence ───────────────────────────────
def test_media_print_block_emitted_once():
html = _render()
matches = re.findall(r"@media\s+print\s*\{", html)
assert len(matches) == 1
# ─── P-2 ─ @page size matches slide canvas ──────────────────────────
def test_page_size_matches_slide_canvas():
html = _render()
flat = re.sub(r"\s+", " ", html)
assert "@page { size: 1280px 720px; margin: 0; }" in flat
# ─── P-3 ─ standalone chrome neutralized at print ───────────────────
def test_slide_box_shadow_neutralized_at_print():
html = _render()
flat = re.sub(r"\s+", " ", html)
print_block = re.search(r"@media\s+print\s*\{(.*?)\}\s*</style>", flat)
assert print_block is not None
body = print_block.group(1)
assert "box-shadow: none !important" in body
assert "padding: 0 !important" in body
assert "min-height: 0 !important" in body
# ─── P-4 ─ popup body switches to static flow, summary hidden ───────
def test_popup_card_chrome_unset_at_print():
html = _render()
flat = re.sub(r"\s+", " ", html)
print_block = re.search(r"@media\s+print\s*\{(.*?)\}\s*</style>", flat)
assert print_block is not None
body = print_block.group(1)
assert ".zone__popup-summary { display: none !important; }" in body
assert "position: static !important" in body
assert "max-height: none !important" in body
assert "overflow: visible !important" in body
# ─── P-5 ─ beforeprint hook is body-level (NOT inside <details>) ────
def test_beforeprint_and_afterprint_listeners_present():
html = _render()
assert "addEventListener('beforeprint'" in html
assert "addEventListener('afterprint'" in html
def test_print_script_is_outside_any_details_block():
"""The IMP-35 u8 popup render path is JS-free. Our print script
sits at body level after the slide div, so no <script> appears
inside a <details>...</details> popup block."""
html = _render(
)
# No <details> in the no-popup baseline — but the assertion still
# holds defensively: locate every <details>...</details> block (if
# any) and confirm no <script> tag appears inside.
for block in re.findall(r"<details[\s>].*?</details>", html, re.DOTALL):
assert "<script" not in block
assert "addEventListener" not in block
# ─── P-6 ─ restore semantics ────────────────────────────────────────
def test_restore_uses_single_dataset_key():
"""Restore strategy uses one dataset key
(``dataset.imp90PrintRestore``) — no global Set/Map, no mutation
of any other DOM attribute. Locks the minimal-surface contract."""
html = _render()
assert "imp90PrintRestore" in html
# Restore branch only sets open=false when the prior state was '0'.
assert "imp90PrintRestore === '0'" in html
assert "d.open = true" in html
@@ -40,8 +40,8 @@
"04.mdx": { "04.mdx": {
"mdx_file": "04.mdx", "mdx_file": "04.mdx",
"run_id": "89a_baseline_04", "run_id": "89a_baseline_04",
"final_html_size_bytes": 27707, "final_html_size_bytes": 28042,
"sha256": "2bce45041cdcca6518cd92586c1be9e051a5c98f5a0ad61fdde02604618a1d80", "sha256": "ddb6bf2f8d76ca1f56588a50dd4af5aeb5f45e0a83d5241b83b5932d0c66d41c",
"pipeline_exit_code": null "pipeline_exit_code": null
}, },
"05.mdx": { "05.mdx": {
View File
+62
View File
@@ -0,0 +1,62 @@
"""IMP-#91 u14 — unit tests for the status-board marker updater.
Exercises ``parse_outcomes`` (nodeid → axis/mdx outcome mapping) and
``update_board_text`` (idempotent marker rewrite). u15 will wire the CLI
into the GitHub Actions workflow; these tests guard the contract.
"""
from __future__ import annotations
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import update_status_board as usb # noqa: E402
SAMPLE_REPORT = {
"tests": [
{
"nodeid": "tests/integration/test_multi_mdx_regression.py::test_normalize_snapshot_matches[01]",
"outcome": "passed",
},
{
"nodeid": "tests/integration/test_multi_mdx_regression.py::test_v4_ranking_snapshot_matches[02]",
"outcome": "passed",
},
{
"nodeid": "tests/integration/test_multi_mdx_regression.py::test_layout_snapshot_matches[03]",
"outcome": "failed",
},
{
"nodeid": "tests/integration/test_multi_mdx_regression.py::test_pipeline_run_produces_step20_status[02]",
"outcome": "passed",
},
]
}
def test_parse_outcomes_maps_known_axes_only() -> None:
outcomes = usb.parse_outcomes(SAMPLE_REPORT)
assert outcomes == {
("F0", "01"): "PASS",
("F1", "02"): "PASS",
("F4", "03"): "FAIL",
}
def test_update_board_text_rewrites_markers() -> None:
board = "F0/01: <!-- IMP-91:F0:01 -->?<!-- /IMP-91 --> F1/02: <!-- IMP-91:F1:02 -->old<!-- /IMP-91 -->"
outcomes = {("F0", "01"): "PASS"}
result = usb.update_board_text(board, outcomes)
assert "<!-- IMP-91:F0:01 -->PASS<!-- /IMP-91 -->" in result
assert "<!-- IMP-91:F1:02 -->?<!-- /IMP-91 -->" in result
def test_update_board_text_is_idempotent() -> None:
board = "<!-- IMP-91:F2:05 -->old<!-- /IMP-91 -->"
outcomes = {("F2", "05"): "PASS"}
once = usb.update_board_text(board, outcomes)
twice = usb.update_board_text(once, outcomes)
assert once == twice == "<!-- IMP-91:F2:05 -->PASS<!-- /IMP-91 -->"
+107
View File
@@ -0,0 +1,107 @@
"""IMP-45 (#74) u2 — frontmatter ``slide_overrides`` surfacing.
Covers ``src.mdx_normalizer.normalize_mdx_content`` and the helper
``_extract_slide_overrides``. The Stage 2 plan enumerates four cases:
1. Present — nested ``slide_overrides.css`` survives normalization verbatim.
2. Absent — return dict carries an empty ``slide_overrides`` mapping.
3. Non-string ``css`` — dropped (fail-closed against typo'd YAML shapes).
4. Title-only frontmatter — no ``slide_overrides`` key in metadata → ``{}``.
Scope-lock: this unit only adds the new key to the return dict. The four
pre-existing return keys (``clean_text``/``title``/``images``/``popups``/
``tables``/``sections``) are asserted unchanged at the structural level.
"""
from __future__ import annotations
from src.mdx_normalizer import _extract_slide_overrides, normalize_mdx_content
_CSS_BLOCK = "<style>.f29b__col_right { width: 320px; }</style>"
def _mdx_with_frontmatter(fm_body: str, body: str = "# 제목\n\n본문 한 줄.\n") -> str:
return f"---\n{fm_body}---\n{body}"
# -- case 1: present (nested css string survives verbatim) ------------------
def test_normalize_surfaces_nested_slide_overrides_css():
raw = _mdx_with_frontmatter(
"title: 04 sample\n"
"slide_overrides:\n"
f" css: \"{_CSS_BLOCK}\"\n"
)
result = normalize_mdx_content(raw)
assert result["slide_overrides"] == {"css": _CSS_BLOCK}
# Other axes unaffected by the new key.
assert result["title"] == "04 sample"
assert "본문" in result["clean_text"]
# -- case 2: absent (no slide_overrides key in frontmatter) -----------------
def test_normalize_returns_empty_slide_overrides_when_key_absent():
raw = _mdx_with_frontmatter("title: 03 sample\n")
result = normalize_mdx_content(raw)
assert result["slide_overrides"] == {}
# Confirm key is always present (callers can rely on .get without default).
assert "slide_overrides" in result
# -- case 3: non-string css (fail-closed drop) ------------------------------
def test_normalize_drops_non_string_css_under_slide_overrides():
# YAML list under .css should be dropped; sibling unknown keys survive
# so the future generalization path (e.g., slide_overrides.js) stays
# forward-compatible per the Stage 2 plan.
raw = _mdx_with_frontmatter(
"title: typo case\n"
"slide_overrides:\n"
" css:\n"
" - .f29b__col_right { width: 320px; }\n"
" note: experimental sibling\n"
)
result = normalize_mdx_content(raw)
assert "css" not in result["slide_overrides"]
assert result["slide_overrides"].get("note") == "experimental sibling"
# -- case 4: title-only frontmatter (no slide_overrides at all) -------------
def test_normalize_title_only_frontmatter_yields_empty_slide_overrides():
raw = _mdx_with_frontmatter("title: title only\n")
result = normalize_mdx_content(raw)
assert result["title"] == "title only"
assert result["slide_overrides"] == {}
# -- direct helper coverage (defensive against future return-shape drift) ---
def test_extract_slide_overrides_non_mapping_returns_empty_dict():
# Frontmatter parsers can yield odd types if the user writes
# ``slide_overrides: 42`` or ``slide_overrides: ".x{}"``. The helper
# must coerce to ``{}`` rather than raise.
for bad in (None, 42, "literal string", ["css"]):
assert _extract_slide_overrides({"slide_overrides": bad}) == {}
def test_extract_slide_overrides_passes_through_unknown_siblings():
payload = {"slide_overrides": {"css": ".a{}", "js": "console.log(1)"}}
assert _extract_slide_overrides(payload) == {
"css": ".a{}",
"js": "console.log(1)",
}
+137
View File
@@ -56,6 +56,8 @@ def _exec_main_block(
override_zone_geometries=None, override_zone_geometries=None,
override_section_assignments=None, override_section_assignments=None,
override_image_overrides=None, override_image_overrides=None,
override_slide_css=None,
reuse_from=None,
): ):
captured["mdx_path"] = mdx_path captured["mdx_path"] = mdx_path
captured["run_id"] = run_id captured["run_id"] = run_id
@@ -64,6 +66,8 @@ def _exec_main_block(
captured["override_zone_geometries"] = override_zone_geometries captured["override_zone_geometries"] = override_zone_geometries
captured["override_section_assignments"] = override_section_assignments captured["override_section_assignments"] = override_section_assignments
captured["override_image_overrides"] = override_image_overrides captured["override_image_overrides"] = override_image_overrides
captured["override_slide_css"] = override_slide_css
captured["reuse_from"] = reuse_from
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run) monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
monkeypatch.setattr(sys, "argv", argv) monkeypatch.setattr(sys, "argv", argv)
@@ -346,3 +350,136 @@ def test_image_override_does_not_leak_into_sibling_axes(tmp_path, monkeypatch):
assert captured["override_frames"] is None assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None assert captured["override_section_assignments"] is None
# -- IMP-45 (#74) u5 — slide-level CSS override CLI surface ----------------
#
# Six focused cases mirror the --override-image pattern above:
# 1. neither flag → kwarg None (fall-back to MDX frontmatter at u4)
# 2. --override-slide-css inline TEXT → kwarg passes verbatim
# 3. --slide-css-file PATH UTF-8 read → kwarg = file contents
# 4. both flags set → sys.exit(2) with mutual-exclusion stderr
# 5. --slide-css-file missing path → sys.exit(2) with not-found stderr
# 6. --slide-css-file non-UTF-8 bytes → sys.exit(2) with utf-8 stderr
def test_no_slide_css_override_forwards_none(tmp_path, monkeypatch):
"""Neither --override-slide-css nor --slide-css-file → kwarg = 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_slide_css"] is None
def test_inline_slide_css_override_forwards_verbatim(tmp_path, monkeypatch):
"""--override-slide-css TEXT → kwarg = TEXT (verbatim, no `<style>` wrap)."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-slide-css",
".slide { background: red; }",
],
monkeypatch,
)
assert captured["override_slide_css"] == ".slide { background: red; }"
def test_slide_css_file_override_reads_utf8(tmp_path, monkeypatch):
"""--slide-css-file PATH → kwarg = UTF-8 decoded file contents."""
_redirect_overrides_root(tmp_path, monkeypatch)
css_payload = ".slide-body { color: #1e293b; } /* 한글 주석 */\n"
css_path = tmp_path / "slide_override.css"
css_path.write_text(css_payload, encoding="utf-8")
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--slide-css-file",
str(css_path),
],
monkeypatch,
)
assert captured["override_slide_css"] == css_payload
def test_slide_css_both_flags_set_exits(tmp_path, monkeypatch, capsys):
"""--override-slide-css + --slide-css-file → sys.exit(2)."""
_redirect_overrides_root(tmp_path, monkeypatch)
css_path = tmp_path / "slide_override.css"
css_path.write_text(".slide { color: red; }\n", encoding="utf-8")
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-slide-css",
".slide { color: blue; }",
"--slide-css-file",
str(css_path),
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "--override-slide-css and --slide-css-file are mutually exclusive" in err
def test_slide_css_file_missing_path_exits(tmp_path, monkeypatch, capsys):
"""--slide-css-file with non-existent PATH → sys.exit(2)."""
_redirect_overrides_root(tmp_path, monkeypatch)
missing_path = tmp_path / "does_not_exist.css"
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--slide-css-file",
str(missing_path),
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "--slide-css-file path does not exist" in err
assert str(missing_path) in err
def test_slide_css_file_non_utf8_exits(tmp_path, monkeypatch, capsys):
"""--slide-css-file with non-UTF-8 bytes → sys.exit(2)."""
_redirect_overrides_root(tmp_path, monkeypatch)
bad_path = tmp_path / "latin1.css"
# 0xff is a stand-alone invalid UTF-8 start byte; strict decode raises.
bad_path.write_bytes(b".slide { color: \xff; }\n")
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--slide-css-file",
str(bad_path),
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "--slide-css-file must be UTF-8 encoded" in err
+385
View File
@@ -0,0 +1,385 @@
"""IMP-43 (#72) u1 + u5 — focused tests for the ``--reuse-from`` CLI surface.
u1 scope (per the Stage 2 Exit Report):
- argparse flag ``--reuse-from PREV_RUN_ID`` parses without error.
- Fail-closed precondition guard runs AFTER the ``user_overrides.json``
merge and BEFORE dispatch. With ``--reuse-from`` set, the guard
must:
* accept frame-only overrides (or no overrides at all);
* reject layout / zone-geometry / zone-section / image overrides
with ``sys.exit(2)`` whose stderr names every rejected axis.
u5 scope (added 2026-05-24):
- ``reuse_from`` is keyword-only on ``run_phase_z2_mvp1`` and defaults
to ``None`` so the absent-flag path preserves pre-u5 behaviour.
- The CLI dispatch forwards ``args.reuse_from`` verbatim — both
``None`` (flag absent) and ``"PREV_RUN_ID"`` (flag present) reach
the kwarg unchanged.
- The fake ``run_phase_z2_mvp1`` stub below mirrors the production
signature so the forwarding lock would fail loudly on any
forwarding regression.
The harness mirrors ``tests/test_phase_z2_cli_overrides.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. 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. Captures the
presence of the call (``called=True``) so guard-driven early exits
can be distinguished from a successful parse + dispatch."""
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,
override_slide_css=None,
reuse_from=None,
):
captured["called"] = True
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
captured["override_slide_css"] = override_slide_css
captured["reuse_from"] = reuse_from
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_reuse_from_alone_parses_and_dispatches(tmp_path, monkeypatch):
"""``--reuse-from`` with no other overrides must parse cleanly and
fall through to dispatch (frame-only / empty override is allowed).
u5 (2026-05-24): also asserts the CLI threads ``args.reuse_from``
verbatim into the ``reuse_from`` kwarg."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--reuse-from",
"03__DX_20260508025134",
],
monkeypatch,
)
assert captured.get("called") is True
# u5 — verbatim threading.
assert captured["reuse_from"] == "03__DX_20260508025134"
def test_reuse_from_with_frame_override_dispatches(tmp_path, monkeypatch):
"""Frame overrides ARE preserved across Step 0/1/2/5/6 reuse, so
``--reuse-from`` + ``--override-frame`` must reach dispatch.
u5: forwards both ``reuse_from`` and ``override_frames`` in the
same call."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--reuse-from",
"03__DX_20260508025134",
"--override-frame",
"03-1=frame_foo",
],
monkeypatch,
)
assert captured.get("called") is True
assert captured["override_frames"] == {"03-1": "frame_foo"}
# u5 — frame override + reuse_from reach the kwarg simultaneously.
assert captured["reuse_from"] == "03__DX_20260508025134"
# -- u5 — flag-absent default + signature surface ------------------------
def test_no_reuse_from_threads_none_kwarg(tmp_path, monkeypatch):
"""u5 — when ``--reuse-from`` is absent, the kwarg must reach
``run_phase_z2_mvp1`` as ``None`` (not omitted, not ``""``). This
locks the "default None preserves current behavior" requirement
from the Stage 2 plan §u5."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
["src.phase_z2_pipeline", "03.mdx"],
monkeypatch,
)
assert captured.get("called") is True
assert captured["reuse_from"] is None
def test_run_phase_z2_mvp1_signature_includes_reuse_from():
"""Production signature lock — ``reuse_from`` must be a keyword-only
parameter with default ``None``. Mirror of the entry-tests
invariant; kept here so the CLI-surface test file fails loudly if
the production signature drifts away from the dispatch contract."""
import inspect
sig = inspect.signature(_pz2.run_phase_z2_mvp1)
assert "reuse_from" in sig.parameters, list(sig.parameters)
param = sig.parameters["reuse_from"]
assert param.kind is inspect.Parameter.KEYWORD_ONLY, param.kind
assert param.default is None, param.default
# -- fail-closed (single-axis rejection) ----------------------------------
def test_reuse_from_with_layout_override_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",
"--reuse-from",
"03__DX_20260508025134",
"--override-layout",
"horizontal-2",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "--reuse-from incompatible with override axes" in err
assert "layout" in err
assert captured.get("called") is not True
def test_reuse_from_with_zone_geometry_override_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",
"--reuse-from",
"03__DX_20260508025134",
"--override-zone-geometry",
"top=0,0,1,0.3",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "--reuse-from incompatible with override axes" in err
assert "zone_geometry" in err
assert captured.get("called") is not True
def test_reuse_from_with_zone_section_override_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",
"--reuse-from",
"03__DX_20260508025134",
"--override-section-assignment",
"top=03-1",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "--reuse-from incompatible with override axes" in err
assert "zone_section" in err
assert captured.get("called") is not True
def test_reuse_from_with_image_override_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",
"--reuse-from",
"03__DX_20260508025134",
"--override-image",
"img-abc=10,15,30,25",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "--reuse-from incompatible with override axes" in err
assert "image" in err
assert captured.get("called") is not True
# -- fail-closed (multi-axis aggregation) ---------------------------------
def test_reuse_from_with_multiple_rejected_axes_lists_all(
tmp_path, monkeypatch, capsys
):
"""Stderr must enumerate every rejected axis (not stop at first)."""
_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",
"--reuse-from",
"03__DX_20260508025134",
"--override-layout",
"horizontal-2",
"--override-zone-geometry",
"top=0,0,1,0.3",
"--override-image",
"img-abc=10,15,30,25",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "layout" in err
assert "zone_geometry" in err
assert "image" in err
assert captured.get("called") is not True
# -- guard inactive when --reuse-from absent ------------------------------
def test_no_reuse_from_layout_override_still_dispatches(
tmp_path, monkeypatch
):
"""Without ``--reuse-from``, the guard must be silent — existing
override behaviour is preserved end-to-end."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-layout",
"horizontal-2",
],
monkeypatch,
)
assert captured.get("called") is True
assert captured["override_layout"] == "horizontal-2"
# -- fail-closed honours persisted overrides ------------------------------
def test_reuse_from_with_persisted_layout_override_exits(
tmp_path, monkeypatch, capsys
):
"""The guard runs AFTER the user_overrides.json merge, so a layout
persisted on disk (not on the CLI) must still reject when
``--reuse-from`` is set. This locks the Stage 2 placement rule."""
_redirect_overrides_root(tmp_path, monkeypatch)
# Persist a layout override keyed by the MDX stem ``03``.
overrides_dir = tmp_path
overrides_dir.mkdir(parents=True, exist_ok=True)
(overrides_dir / "03.json").write_text(
'{"layout": "vertical-2"}', encoding="utf-8"
)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--reuse-from",
"03__DX_20260508025134",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "--reuse-from incompatible with override axes" in err
assert "layout" in err
assert captured.get("called") is not True
+58 -41
View File
@@ -676,12 +676,20 @@ def test_u5_zone_without_provisional_key_treated_as_non_provisional():
# ─── u5 case 2 : provisional zone renders class + badge + data attr ─── # ─── u5 case 2 : provisional zone renders class + badge + data attr ───
def test_u5_provisional_zone_renders_class_and_badge(): def test_imp84_provisional_zone_silent_no_class_no_badge():
"""Opt-in path. zones[i].provisional=True must: """IMP-84 silent-automation inversion of the prior IMP-30 u5 contract.
1. Append `zone--provisional` class to the zone div. Under the silent contract, zones[i].provisional=True must:
2. Set `data-provisional="1"` data attribute (for downstream selectors). 1. NOT append `zone--provisional` class to the zone div (no user-visible
3. Render a `<span class="zone__needs-adaptation-badge">` element with outline / striped wash).
the literal text "needs adaptation" (aria-label included for a11y). 2. Still set `data-provisional="1"` data attribute as silent telemetry
for downstream selectors / inspection.
3. NOT render any `<span class="zone__needs-adaptation-badge">` element
and NOT surface the literal text "needs adaptation" or its
aria-label (no user-facing badge).
Scope: assertions target the zone div body. The CSS <style> block must
likewise not carry the removed visual selectors — that surface is pinned
in `test_imp84_slide_base_css_strips_provisional_visual_selectors` below.
""" """
zones = [ zones = [
{ {
@@ -694,21 +702,26 @@ def test_u5_provisional_zone_renders_class_and_badge():
} }
] ]
html = _render_slide_base(zones) html = _render_slide_base(zones)
# zone--provisional class must appear on the zone div for position=single. zone_divs = _all_zone_div_openings(html)
assert "zone--provisional" in html assert len(zone_divs) == 1
# data-provisional="1" attribute must be present. # No zone--provisional class on the zone div (visual removed).
assert 'data-provisional="1"' in html assert "zone--provisional" not in zone_divs[0]
# Badge element with the required label text. # data-provisional="1" attribute still present as silent telemetry.
assert 'class="zone__needs-adaptation-badge"' in html assert 'data-provisional="1"' in zone_divs[0]
assert "needs adaptation" in html # No badge <span> element and no badge label text anywhere in the body.
assert 'aria-label="needs user or AI adaptation"' in html assert _all_badge_spans(html) == []
assert "needs adaptation" not in html
assert 'aria-label="needs user or AI adaptation"' not in html
def test_u5_provisional_badge_appears_inside_provisional_zone_only(): def test_imp84_provisional_badge_never_rendered_in_mixed_zones():
"""Mixed-zone slide: one provisional zone + one normal zone. The badge """IMP-84 silent-automation inversion of the prior IMP-30 u5 mixed-zone
+ class must appear ONLY in the provisional zone, not bleed into the contract. Mixed-zone slide: one provisional zone + one normal zone. The
normal one (CSS-level isolation should already prevent this, but the silent contract requires that NO badge span and NO `zone--provisional`
template must not emit the badge for both).""" class be emitted on either zone div. The provisional zone is identifiable
only through the silent `data-provisional="1"` telemetry attribute, which
must be scoped to the provisional zone alone (no bleed onto the normal
zone)."""
zones = [ zones = [
{ {
"position": "top", "position": "top",
@@ -735,21 +748,21 @@ def test_u5_provisional_badge_appears_inside_provisional_zone_only():
html = _render_slide_base( html = _render_slide_base(
zones, layout_preset="vertical-2", layout_css=layout_css zones, layout_preset="vertical-2", layout_css=layout_css
) )
# Exactly one badge span element should be present in the rendered body # No badge <span> element should be rendered anywhere in the body
# (CSS selector in <style> excluded by the helper). # (silent-automation policy).
assert len(_all_badge_spans(html)) == 1 assert _all_badge_spans(html) == []
# zone--provisional must appear on exactly one zone div (CSS selector # No zone div should carry the zone--provisional class (visual removed).
# in <style> excluded by the helper).
zone_divs = _all_zone_div_openings(html) zone_divs = _all_zone_div_openings(html)
assert len(zone_divs) == 2 assert len(zone_divs) == 2
provisional_zone_divs = [d for d in zone_divs if "zone--provisional" in d] assert all("zone--provisional" not in d for d in zone_divs)
assert len(provisional_zone_divs) == 1 # data-provisional="1" telemetry must be present on the bottom (provisional)
# The provisional class must be associated with the bottom zone. # zone only — never on the top (non-provisional) zone.
bottom_zone_open = _zone_div_for_position(html, "bottom") bottom_zone_open = _zone_div_for_position(html, "bottom")
assert "zone--provisional" in bottom_zone_open assert 'data-provisional="1"' in bottom_zone_open
assert "zone__needs-adaptation-badge" in bottom_zone_open assert "zone--provisional" not in bottom_zone_open
# The top zone must NOT carry the provisional class. assert "zone__needs-adaptation-badge" not in bottom_zone_open
top_zone_open = _zone_div_for_position(html, "top") top_zone_open = _zone_div_for_position(html, "top")
assert 'data-provisional="1"' not in top_zone_open
assert "zone--provisional" not in top_zone_open assert "zone--provisional" not in top_zone_open
assert "zone__needs-adaptation-badge" not in top_zone_open assert "zone__needs-adaptation-badge" not in top_zone_open
@@ -779,15 +792,19 @@ def test_u5_zones_data_provisional_field_defaults_false_in_template():
assert _all_badge_spans(html) == [] assert _all_badge_spans(html) == []
def test_u5_slide_base_css_carries_provisional_marker_styles(): def test_imp84_slide_base_css_strips_provisional_visual_selectors():
"""The provisional visual contract (dashed outline + striped wash + badge) """IMP-84 silent-automation inversion of the prior IMP-30 u5 CSS-presence
is defined in slide_base.html <style>. Pin that the relevant CSS class contract. The provisional visual treatment (dashed outline + striped wash
selectors exist in the rendered HTML so a refactor that removes them + badge) was deleted from `slide_base.html <style>` by IMP-84 u2. Pin
breaks this test rather than silently rendering an unstyled badge. that the CSS class selectors `.zone--provisional` and
`.zone__needs-adaptation-badge` no longer appear in the rendered HTML —
a refactor that re-introduces them must break this test rather than
silently re-surfacing the removed visual signal.
This is a class-selector existence check; it does not validate the Scope: the assertion targets the entire rendered HTML (style block plus
specific color / dash pattern, which is a design decision intentionally body). Since the body badge span is also gone (covered separately above),
left malleable (e.g., palette swap for a different theme).""" any occurrence of these strings in the rendered output would only come
from a regressed style block."""
zones = [ zones = [
{ {
"position": "single", "position": "single",
@@ -799,9 +816,9 @@ def test_u5_slide_base_css_carries_provisional_marker_styles():
} }
] ]
html = _render_slide_base(zones) html = _render_slide_base(zones)
# Style block must define .zone--provisional and the badge selector. # Style block must NOT define .zone--provisional or the badge selector.
assert ".zone--provisional" in html assert ".zone--provisional" not in html
assert ".zone__needs-adaptation-badge" in html assert ".zone__needs-adaptation-badge" not in html
# ════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════
File diff suppressed because it is too large Load Diff
+555
View File
@@ -0,0 +1,555 @@
"""IMP-43 (#72) u4 — focused tests for the --reuse-from entry helpers.
u4 scope (per the Stage 2 Exit Report):
- Pure path resolution, file copy, snapshot load+validate, MdxSection +
CompositionUnit rehydration, and reuse-marker writing.
- Helpers RAISE on missing artifacts / corrupt snapshot / mdx_sha256
mismatch — u4b adds the stderr + sys.exit(2) translation and the
prev_run_dir == new_run_dir accidental-write guard around them.
- The kwarg threading + the in-``run_phase_z2_mvp1`` branch that
invokes these helpers land in u5.
Tested helpers (``src/phase_z2_pipeline.py``):
* ``_resolve_reuse_from_prev_run_dir``
* ``_copy_reuse_artifacts_from_prev_run``
* ``_load_and_validate_reuse_snapshot``
* ``_rehydrate_mdx_sections_from_snapshot``
* ``_rehydrate_composition_units_from_snapshot``
* ``_write_reuse_marker``
* ``_RehydratedV4Candidate`` (V4Match-shape duck type)
* ``_REUSE_STEP_ARTIFACTS`` / ``REUSE_MARKER_FILENAME`` /
``REUSE_MARKER_SCHEMA_VERSION``
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
import pytest
import src.phase_z2_pipeline as _pz2
from src.phase_z2_composition import CompositionUnit
from src.phase_z2_reuse_snapshot import (
SNAPSHOT_FILENAME,
SNAPSHOT_VERSION,
SnapshotValidationError,
build_snapshot,
)
# -- synthetic duck-typed inputs (mirror u3 test fixture) -----------------
@dataclass
class _Section:
section_id: str
section_num: int
title: str
raw_content: str
heading_number: Optional[str] = None
v4_alias_keys: list = field(default_factory=list)
sub_sections: list = field(default_factory=list)
@dataclass
class _V4Candidate:
template_id: str
frame_id: str
frame_number: int
confidence: float
label: str
@dataclass
class _Unit:
source_section_ids: list
merge_type: str
frame_template_id: str
frame_id: str
frame_number: int
confidence: float
label: str
phase_z_status: str
raw_content: str
title: str
score: float
v4_rank: Optional[int] = 1
selection_path: str = "rank_1"
fallback_reason: Optional[str] = None
rationale: dict = field(default_factory=dict)
auto_selectable: bool = True
filter_reasons: list = field(default_factory=list)
notes: list = field(default_factory=list)
v4_candidates: list = field(default_factory=list)
provisional: bool = False
def _mdx_text() -> str:
return "# Slide\n\n## 03-1 DX status\n\n- bullet one\n- bullet two\n"
def _build_canonical_snapshot(
*,
mdx_source_text: Optional[str] = None,
layout_preset: str = "single",
) -> dict:
text = mdx_source_text if mdx_source_text is not None else _mdx_text()
cand = _V4Candidate(
template_id="tpl_a",
frame_id="fid_a",
frame_number=13,
confidence=0.91,
label="use_as_is",
)
section = _Section(
section_id="03-1",
section_num=1,
title="DX status",
raw_content="- bullet one\n- bullet two",
heading_number="3.1",
v4_alias_keys=["03-1.1"],
sub_sections=[],
)
unit = _Unit(
source_section_ids=["03-1"],
merge_type="single",
frame_template_id="tpl_a",
frame_id="fid_a",
frame_number=13,
confidence=0.91,
label="use_as_is",
phase_z_status="auto_renderable",
raw_content="- bullet one\n- bullet two",
title="DX status",
score=0.91,
v4_candidates=[cand],
provisional=False,
auto_selectable=True,
filter_reasons=[],
notes=["a note"],
rationale={"weight": 1.0},
)
return build_snapshot(
mdx_sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
slide_title="Slide",
slide_footer=None,
sections=[section],
stage0_adapter_diagnostics={"used": True, "fallback_reason": None},
stage0_normalized_assets={"popups": [], "images": [], "tables": []},
v4_evidence=[
{
"section_id": "03-1",
"v4_candidates": [
{
"template_id": "tpl_a",
"frame_id": "fid_a",
"frame_number": 13,
"confidence": 0.91,
"label": "use_as_is",
}
],
"candidate_status": "ok",
}
],
layout_preset_pre_override=layout_preset,
units=[unit],
comp_debug={"v4_fallback_summary": {"fallback_used_count": 0}},
v4_fallback_traces={"03-1": {"selection_path": "rank_1"}},
ai_preflight={"enabled": False, "skipped": True},
)
def _seed_prev_run_dir(prev_run_dir: Path, *, snapshot: dict) -> None:
"""Populate ``prev_run_dir`` with the Step 0/1/2/5/6 artifacts plus
the reuse snapshot — minimal but valid surface for u4 helpers."""
(prev_run_dir / "steps").mkdir(parents=True, exist_ok=True)
for fname in _pz2._REUSE_STEP_ARTIFACTS:
# JSON-shaped surface — exact shape doesn't matter for u4 (the
# copy helper doesn't introspect contents); just must exist.
(prev_run_dir / "steps" / fname).write_text(
f'{{"name": "{fname}"}}'
if fname.endswith(".json")
else "raw mdx body bytes",
encoding="utf-8",
)
(prev_run_dir / SNAPSHOT_FILENAME).write_text(
json.dumps(snapshot, ensure_ascii=False, indent=2),
encoding="utf-8",
)
# -- _REUSE_STEP_ARTIFACTS constant ---------------------------------------
def test_reuse_step_artifacts_locks_stage2_boundary():
"""Stage 2 boundary lock — Step 0/1/2/5/6 artifacts only.
Step 3/4 deliberately absent: step03 / step04 ARE written after
Step 6 (around src/phase_z2_pipeline.py:5931 / 5964) before the
Step 7 artifact (~6294), but both are emitted with
step_status='trace-only' / pipeline_path_connected=False — they
are diagnostic projections of the Step 6 debug_zones, not
pipeline-path-connected inputs that Step 7+ rehydrate from."""
assert _pz2._REUSE_STEP_ARTIFACTS == (
"step00_preconditions.json",
"step01_mdx_upload.json",
"step01_mdx_source.md",
"step02_normalized.json",
"step05_v4_evidence.json",
"step06_composition_plan.json",
)
def test_reuse_marker_filename_is_dotfile_at_run_dir_root():
assert _pz2.REUSE_MARKER_FILENAME == "_reuse_marker.json"
# -- _resolve_reuse_from_prev_run_dir -------------------------------------
def test_resolve_prev_run_dir_returns_runs_dir_phase_z2_path():
rv = _pz2._resolve_reuse_from_prev_run_dir("20260524_120000_phase_z2")
expected = _pz2.RUNS_DIR / "20260524_120000_phase_z2" / "phase_z2"
assert rv == expected
def test_resolve_prev_run_dir_does_not_check_existence(tmp_path: Path):
"""Pure path computation — must NOT touch the filesystem (u4b
handles the missing-prev-run case)."""
rv = _pz2._resolve_reuse_from_prev_run_dir("never_existed_run_id")
assert isinstance(rv, Path)
# The path does not actually exist; helper still returned cleanly.
assert not rv.exists()
# -- _copy_reuse_artifacts_from_prev_run ----------------------------------
def test_copy_reuse_artifacts_copies_all_step_files(tmp_path: Path):
prev = tmp_path / "prev" / "phase_z2"
new = tmp_path / "new" / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev, snapshot=snap)
copied = _pz2._copy_reuse_artifacts_from_prev_run(prev, new)
for fname in _pz2._REUSE_STEP_ARTIFACTS:
assert (new / "steps" / fname).exists(), f"missing copy: {fname}"
assert copied[fname] == f"steps/{fname}"
def test_copy_reuse_artifacts_copies_snapshot_to_run_dir_root(tmp_path: Path):
prev = tmp_path / "prev" / "phase_z2"
new = tmp_path / "new" / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev, snapshot=snap)
copied = _pz2._copy_reuse_artifacts_from_prev_run(prev, new)
# Snapshot lives at run_dir root (NOT under steps/) per u3 contract.
assert (new / SNAPSHOT_FILENAME).exists()
assert copied[SNAPSHOT_FILENAME] == SNAPSHOT_FILENAME
def test_copy_reuse_artifacts_creates_steps_subdir_if_absent(tmp_path: Path):
prev = tmp_path / "prev" / "phase_z2"
new = tmp_path / "new" / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev, snapshot=snap)
# new_run_dir / steps does not yet exist
assert not (new / "steps").exists()
_pz2._copy_reuse_artifacts_from_prev_run(prev, new)
assert (new / "steps").is_dir()
def test_copy_reuse_artifacts_missing_step_raises_filenotfound(
tmp_path: Path,
):
prev = tmp_path / "prev" / "phase_z2"
new = tmp_path / "new" / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev, snapshot=snap)
# Delete one of the required step artifacts.
(prev / "steps" / "step05_v4_evidence.json").unlink()
with pytest.raises(FileNotFoundError) as ei:
_pz2._copy_reuse_artifacts_from_prev_run(prev, new)
msg = str(ei.value)
assert "step05_v4_evidence.json" in msg
assert "prev_run_dir" in msg
def test_copy_reuse_artifacts_missing_snapshot_raises_filenotfound(
tmp_path: Path,
):
prev = tmp_path / "prev" / "phase_z2"
new = tmp_path / "new" / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev, snapshot=snap)
(prev / SNAPSHOT_FILENAME).unlink()
with pytest.raises(FileNotFoundError) as ei:
_pz2._copy_reuse_artifacts_from_prev_run(prev, new)
assert SNAPSHOT_FILENAME in str(ei.value)
def test_copy_reuse_artifacts_byte_identical_copy(tmp_path: Path):
"""Bytes must match exactly — copy, not transform."""
prev = tmp_path / "prev" / "phase_z2"
new = tmp_path / "new" / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev, snapshot=snap)
_pz2._copy_reuse_artifacts_from_prev_run(prev, new)
for fname in _pz2._REUSE_STEP_ARTIFACTS:
assert (
(prev / "steps" / fname).read_bytes()
== (new / "steps" / fname).read_bytes()
)
assert (
(prev / SNAPSHOT_FILENAME).read_bytes()
== (new / SNAPSHOT_FILENAME).read_bytes()
)
# -- _load_and_validate_reuse_snapshot ------------------------------------
def test_load_and_validate_returns_snapshot_dict(tmp_path: Path):
text = _mdx_text()
snap = _build_canonical_snapshot(mdx_source_text=text)
(tmp_path / SNAPSHOT_FILENAME).write_text(
json.dumps(snap, ensure_ascii=False, indent=2), encoding="utf-8"
)
loaded = _pz2._load_and_validate_reuse_snapshot(
tmp_path, mdx_source_text=text
)
assert loaded["schema_version"] == SNAPSHOT_VERSION
assert loaded["slide_title"]["value"] == "Slide"
def test_load_and_validate_mdx_sha256_mismatch_raises(tmp_path: Path):
"""Snapshot was built for ``text_a`` but caller passes ``text_b``;
u2 validator raises ``SnapshotValidationError`` (subclass of
``ValueError``). u4b translates to exit 2 — here we only assert the
raise."""
text_a = "# Slide A\n"
text_b = "# Slide B (different bytes)\n"
snap = _build_canonical_snapshot(mdx_source_text=text_a)
(tmp_path / SNAPSHOT_FILENAME).write_text(
json.dumps(snap, ensure_ascii=False, indent=2), encoding="utf-8"
)
with pytest.raises(SnapshotValidationError) as ei:
_pz2._load_and_validate_reuse_snapshot(
tmp_path, mdx_source_text=text_b
)
assert "mdx_sha256 mismatch" in str(ei.value)
def test_load_and_validate_corrupt_json_raises(tmp_path: Path):
(tmp_path / SNAPSHOT_FILENAME).write_text(
"{ not valid json", encoding="utf-8"
)
with pytest.raises(json.JSONDecodeError):
_pz2._load_and_validate_reuse_snapshot(
tmp_path, mdx_source_text=_mdx_text()
)
def test_load_and_validate_missing_snapshot_file_raises(tmp_path: Path):
"""No snapshot at all — bare ``read_text`` raises FileNotFoundError.
u4b translates this to exit 2 with a provenance message."""
with pytest.raises(FileNotFoundError):
_pz2._load_and_validate_reuse_snapshot(
tmp_path, mdx_source_text=_mdx_text()
)
def test_load_and_validate_schema_version_mismatch_raises(tmp_path: Path):
text = _mdx_text()
snap = _build_canonical_snapshot(mdx_source_text=text)
snap["schema_version"] = SNAPSHOT_VERSION + 1 # force mismatch
(tmp_path / SNAPSHOT_FILENAME).write_text(
json.dumps(snap, ensure_ascii=False, indent=2), encoding="utf-8"
)
with pytest.raises(SnapshotValidationError) as ei:
_pz2._load_and_validate_reuse_snapshot(
tmp_path, mdx_source_text=text
)
assert "schema_version" in str(ei.value)
# -- _rehydrate_mdx_sections_from_snapshot --------------------------------
def test_rehydrate_sections_returns_mdxsection_instances():
snap = _build_canonical_snapshot()
sections = _pz2._rehydrate_mdx_sections_from_snapshot(snap)
assert len(sections) == 1
assert isinstance(sections[0], _pz2.MdxSection)
assert sections[0].section_id == "03-1"
assert sections[0].title == "DX status"
assert sections[0].raw_content == "- bullet one\n- bullet two"
def test_rehydrate_sections_preserves_heading_number_and_aliases():
snap = _build_canonical_snapshot()
sections = _pz2._rehydrate_mdx_sections_from_snapshot(snap)
assert sections[0].heading_number == "3.1"
assert sections[0].v4_alias_keys == ["03-1.1"]
assert sections[0].sub_sections == []
# -- _rehydrate_composition_units_from_snapshot ---------------------------
def test_rehydrate_units_returns_composition_unit_instances():
snap = _build_canonical_snapshot()
units = _pz2._rehydrate_composition_units_from_snapshot(snap)
assert len(units) == 1
assert isinstance(units[0], CompositionUnit)
def test_rehydrate_units_preserves_core_fields():
snap = _build_canonical_snapshot()
units = _pz2._rehydrate_composition_units_from_snapshot(snap)
u = units[0]
assert u.source_section_ids == ["03-1"]
assert u.merge_type == "single"
assert u.frame_template_id == "tpl_a"
assert u.frame_id == "fid_a"
assert u.frame_number == 13
assert u.confidence == pytest.approx(0.91)
assert u.label == "use_as_is"
assert u.phase_z_status == "auto_renderable"
assert u.title == "DX status"
assert u.score == pytest.approx(0.91)
def test_rehydrate_units_preserves_provisional_and_auto_selectable():
snap = _build_canonical_snapshot()
units = _pz2._rehydrate_composition_units_from_snapshot(snap)
assert units[0].provisional is False
assert units[0].auto_selectable is True
assert units[0].filter_reasons == []
assert units[0].notes == ["a note"]
assert units[0].rationale == {"weight": 1.0}
def test_rehydrate_units_v4_candidates_expose_attribute_access():
"""``_apply_frame_override_to_unit`` reads
``cand.template_id`` / ``cand.frame_id`` / etc. off
``unit.v4_candidates`` — restored entries MUST expose attribute
access, not raw dict access."""
snap = _build_canonical_snapshot()
units = _pz2._rehydrate_composition_units_from_snapshot(snap)
cands = units[0].v4_candidates
assert len(cands) == 1
c = cands[0]
assert isinstance(c, _pz2._RehydratedV4Candidate)
assert c.template_id == "tpl_a"
assert c.frame_id == "fid_a"
assert c.frame_number == 13
assert c.confidence == pytest.approx(0.91)
assert c.label == "use_as_is"
def test_rehydrate_units_empty_v4_candidates_yields_empty_list():
snap = _build_canonical_snapshot()
snap["units"]["value"][0]["v4_candidates"] = []
units = _pz2._rehydrate_composition_units_from_snapshot(snap)
assert units[0].v4_candidates == []
# -- _write_reuse_marker --------------------------------------------------
def test_write_reuse_marker_writes_json_with_prev_run_id(tmp_path: Path):
copied = {
"step00_preconditions.json": "steps/step00_preconditions.json",
SNAPSHOT_FILENAME: SNAPSHOT_FILENAME,
}
rv = _pz2._write_reuse_marker(
tmp_path,
prev_run_id="20260524_010101_phase_z2",
copied_artifacts=copied,
)
assert rv == tmp_path / _pz2.REUSE_MARKER_FILENAME
marker = json.loads(rv.read_text(encoding="utf-8"))
assert marker["schema_version"] == _pz2.REUSE_MARKER_SCHEMA_VERSION
assert marker["reuse_from_prev_run_id"] == "20260524_010101_phase_z2"
assert marker["snapshot_filename"] == SNAPSHOT_FILENAME
def test_write_reuse_marker_records_copied_artifacts_and_boundary(
tmp_path: Path,
):
copied = {
fname: f"steps/{fname}" for fname in _pz2._REUSE_STEP_ARTIFACTS
}
copied[SNAPSHOT_FILENAME] = SNAPSHOT_FILENAME
_pz2._write_reuse_marker(
tmp_path,
prev_run_id="20260524_010101_phase_z2",
copied_artifacts=copied,
)
marker = json.loads(
(tmp_path / _pz2.REUSE_MARKER_FILENAME).read_text(encoding="utf-8")
)
assert marker["copied_artifacts"] == copied
assert marker["boundary_steps"] == list(_pz2._REUSE_STEP_ARTIFACTS)
assert marker["resume_at_step"] == 7
# -- module surface anchors -----------------------------------------------
def test_pipeline_exposes_all_u4_helpers():
"""u5 wires these into ``run_phase_z2_mvp1`` — they must remain
module-level callable surface on ``phase_z2_pipeline``."""
for name in (
"_resolve_reuse_from_prev_run_dir",
"_copy_reuse_artifacts_from_prev_run",
"_load_and_validate_reuse_snapshot",
"_rehydrate_mdx_sections_from_snapshot",
"_rehydrate_composition_units_from_snapshot",
"_write_reuse_marker",
"_RehydratedV4Candidate",
"_REUSE_STEP_ARTIFACTS",
"REUSE_MARKER_FILENAME",
"REUSE_MARKER_SCHEMA_VERSION",
):
assert hasattr(_pz2, name), f"u4 surface missing: {name}"
def test_pipeline_run_signature_reuse_from_is_kw_only_optional_none():
"""u5 — ``reuse_from`` is now part of ``run_phase_z2_mvp1``'s public
signature. The kwarg MUST be keyword-only (after the ``*`` barrier),
default to ``None`` (so absent flag preserves the pre-u5 behaviour),
and sit alongside the existing override kwargs. The locked
``until_u5`` regression has flipped — keep this assertion as the
forward-direction lock so future signature drift (e.g. a positional
promotion or a default change) trips loudly."""
import inspect
sig = inspect.signature(_pz2.run_phase_z2_mvp1)
assert "reuse_from" in sig.parameters, (
"u5 must thread reuse_from into run_phase_z2_mvp1 — kwarg missing. "
f"current params: {list(sig.parameters)}"
)
param = sig.parameters["reuse_from"]
assert param.kind is inspect.Parameter.KEYWORD_ONLY, (
f"reuse_from must be keyword-only (after the ``*`` barrier); "
f"got kind={param.kind}"
)
assert param.default is None, (
f"reuse_from must default to None to preserve pre-u5 behaviour; "
f"got default={param.default!r}"
)
@@ -0,0 +1,261 @@
"""IMP-43 (#72) u7b — Opt-in sweep equivalence test for full rerun vs
``--reuse-from`` across 3 layouts × 3 mdx samples × per-baseline frame pins.
u7b scope (per the Stage 2 Exit Report):
* Three mdx samples — ``01.mdx``, ``02.mdx``, ``03.mdx`` (the baseline
full run for each must exit 0 to give step13 equivalence something
to compare; ``04.mdx`` / ``05.mdx`` are deliberately excluded per
the u7a docstring — adapter_needed / EMPTY_SHELL_NO_CONTENT).
* Three ``--override-layout`` axes — ``None`` (auto), ``horizontal-2``,
``vertical-2``. ``None`` exercises the natural layout for that mdx;
the explicit pins exercise the layout-locked branch (Step 7-B
``select_layout_preset`` honors ``--override-layout`` per
``src/phase_z2_pipeline.py:5210``). The reuse path (C) inherits the
locked layout via the Step 6 snapshot ``layout_preset_pre_override``
(u2) — it MUST NOT pass ``--override-layout`` itself (u1 fail-closed
guard at ``src/phase_z2_pipeline.py:8181-8199`` rejects layout
overrides combined with ``--reuse-from``).
* "All 32 frames" coverage axis — each test case discovers ALL pinnable
``(unit_id, frame_template_id)`` pairs from its baseline ``step06_
composition_plan.json`` and uses every pin in (B) and (C). Union of
pins across the 9 (mdx, layout) cases approximates the V4 catalog
coverage; pure Cartesian 3×3×32 = 288 parametrize combos × 3
subprocess runs ≈ 864 pipeline runs is impractical even opt-in.
Three subprocess pipeline runs per case (same shape as u7a):
(A) baseline full run — no frame overrides — reuse seed.
(B) full rerun with the discovered frame overrides — independent
control path that does NOT touch ``--reuse-from``.
(C) ``--reuse-from <seed_id>`` with the same frame overrides — the
reuse path.
Assert: ``step13_render.json`` from (B) and (C) is byte-equal modulo the
Stage 2 whitelist (only ``run_id`` substring inside
``data.final_html_path`` is normalized — see u7a docstring for the full
whitelist rationale).
Opt-in:
* ``@pytest.mark.sweep`` — marker registered in ``pyproject.toml``.
Default CI must run ``pytest -m 'not sweep'``; explicit opt-in is
``pytest -m sweep tests/test_phase_z2_reuse_from_equivalence_sweep.py``.
* If an mdx / layout combo's baseline (A) returns non-zero (e.g., a
layout pin incompatible with the mdx's natural unit_count produces
a pipeline error), the case is skipped — u7b is a reuse-equivalence
test, not a baseline-correctness test (those live elsewhere).
Persisted ``data/user_overrides/<stem>.json`` isolation:
IMP-52 (#80) u2 introduced an MDX-keyed persistence fallback at
``src/phase_z2_pipeline.py:8075-8168`` that merges the on-disk file
into the subprocess overrides regardless of CLI flags. For mdx stems
whose persistence file carries non-frame axes (e.g.,
``data/user_overrides/03.json`` holds ``layout`` + ``zone_geometries``),
two orthogonality problems break u7b:
1. (A) and (B) absorb the persisted ``layout`` / ``zone_geometries``
independent of the ``layout_pin`` parameter, collapsing the test
matrix — the parametrized layout axis stops being a real axis.
2. (C) on the reuse path receives the persisted non-frame axes via
the same merge, which the u1 fail-closed guard at
``src/phase_z2_pipeline.py:8181-8199`` rejects with exit code 2
before step13 equivalence can be measured.
The ``_isolated_persisted_overrides`` context manager renames the
persistence file out of the way for the duration of each parametrized
case (try/finally restore; crash-resistant via a startup recovery
branch). The hidden backup filename starts with ``.`` so
``user_overrides_io.validate_key`` (``src/user_overrides_io.py:72``)
cannot accidentally re-load it mid-run. The pipeline subprocess does
not write the persistence file (writes are gated to the Vite
``/api/user-overrides`` endpoint), so the rename is safe across the
three subprocess spawns. The real-world reuse-from × persistence
interaction (where ``--reuse-from`` should arguably suppress
non-frame persistence injection rather than fail closed) is a
follow-up issue candidate, surfaced in this unit's unit_executed
Gitea comment.
"""
from __future__ import annotations
import contextlib
import json
import os
import subprocess
import sys
import uuid
from pathlib import Path
import pytest
from tests.test_phase_z2_reuse_from_equivalence_unit import (
_assert_run_ok,
_frame_override_args,
_normalize_step13,
_read_step_artifact,
_spawn_pipeline,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
SAMPLES_DIR = REPO_ROOT / "samples" / "mdx_batch"
RUNS_DIR = REPO_ROOT / "data" / "runs"
OVERRIDES_DIR = REPO_ROOT / "data" / "user_overrides"
MDX_FILES = ("01.mdx", "02.mdx", "03.mdx")
LAYOUT_PINS = (None, "horizontal-2", "vertical-2")
def _unique(prefix: str) -> str:
return f"{prefix}_imp43_u7b_{uuid.uuid4().hex[:8]}"
@contextlib.contextmanager
def _isolated_persisted_overrides(mdx_name: str):
"""Temporarily rename ``data/user_overrides/<stem>.json`` so the
three subprocess runs see a clean persistence state.
Rationale: see module docstring "Persisted ... isolation" section.
The pipeline reads the file at
``src/phase_z2_pipeline.py:8098`` via ``load(key)`` which resolves
to ``DEFAULT_OVERRIDES_ROOT`` (``src/user_overrides_io.py:54``);
moving the file out of the way reduces ``load(key) -> {}`` and
prevents the merge from injecting persisted axes.
Crash recovery: a prior run that crashed between rename and
restore would leave ``.<stem>.imp43_u7b_isolation.bak`` next to
the missing ``<stem>.json``. The recovery branch at startup
restores the backup before proceeding so we never lose the
original on a second invocation.
"""
stem = Path(mdx_name).stem
src = OVERRIDES_DIR / f"{stem}.json"
backup = OVERRIDES_DIR / f".{stem}.imp43_u7b_isolation.bak"
if backup.is_file() and not src.is_file():
os.replace(backup, src)
moved = False
if src.is_file():
OVERRIDES_DIR.mkdir(parents=True, exist_ok=True)
os.replace(src, backup)
moved = True
try:
yield
finally:
if moved and backup.is_file():
os.replace(backup, src)
def _discover_all_frame_pins(seed_run_id: str) -> list[tuple[str, str]]:
"""Discover ALL ``(unit_id, frame_template_id)`` pins from baseline plan.
Unlike u7a (capped at 2 for fast CI), u7b uses every pin so the sweep
naturally exercises the union of frame templates produced across the
9 (mdx, layout) cases — the practical realization of the Stage 2
plan's "all 32 frames" axis (full Cartesian 3×3×32 would be 288×3 =
864 pipeline runs; impractical even opt-in).
Schema source: ``src/phase_z2_pipeline.py:5530-5560`` — step06 artifact
emits ``data.selected_units[*].{source_section_ids, frame_template_id}``;
``unit_id = "+".join(source_section_ids)`` per the ``--override-frame``
contract documented at ``src/phase_z2_pipeline.py:7827-7832``.
"""
step06 = _read_step_artifact(seed_run_id, "step06_composition_plan.json")
selected_units = step06.get("data", {}).get("selected_units") or []
pins: list[tuple[str, str]] = []
for u in selected_units:
sids = u.get("source_section_ids") or []
tpl_id = u.get("frame_template_id")
if not isinstance(sids, list) or not sids:
continue
if not isinstance(tpl_id, str) or not tpl_id:
continue
unit_id = "+".join(str(s) for s in sids)
if unit_id:
pins.append((unit_id, tpl_id))
return pins
@pytest.mark.sweep
@pytest.mark.parametrize("layout_pin", LAYOUT_PINS)
@pytest.mark.parametrize("mdx_name", MDX_FILES)
def test_full_rerun_vs_reuse_from_step13_equivalence_sweep(
mdx_name: str, layout_pin: str | None
) -> None:
"""Stage 2 §u7b binding contract: across the (mdx × layout) sweep,
full rerun (B) with discovered frame overrides and ``--reuse-from``
(C) with the same overrides yield byte-equal ``step13_render.json``
modulo the u7a whitelist.
Skip semantics: if baseline (A) fails for a (mdx, layout) combo
(e.g., layout pin incompatible with mdx unit_count), the case is
skipped — baseline correctness is not the equivalence axis under
test here.
"""
mdx_path = SAMPLES_DIR / mdx_name
if not mdx_path.is_file():
pytest.skip(f"sample missing: {mdx_path}")
layout_args: list[str] = (
[] if layout_pin is None else ["--override-layout", layout_pin]
)
# Isolate any persisted ``data/user_overrides/<stem>.json`` for this
# mdx before spawning the three subprocesses; see module docstring
# "Persisted ... isolation" section for the orthogonality and
# fail-closed-guard rationale.
with _isolated_persisted_overrides(mdx_name):
# (A) baseline full run — no frame overrides — reuse seed.
seed_id = _unique("seed")
cp_a = _spawn_pipeline([str(mdx_path), seed_id, *layout_args])
if cp_a.returncode != 0:
pytest.skip(
f"baseline (A) non-zero for mdx={mdx_name} layout={layout_pin} "
f"(returncode={cp_a.returncode}); not a reuse-equivalence axis. "
f"stderr tail: {cp_a.stderr[-400:]}"
)
pins = _discover_all_frame_pins(seed_id)
if not pins:
pytest.skip(
f"no pinnable (unit_id, frame_template_id) pairs in baseline "
f"step06 for mdx={mdx_name} layout={layout_pin}; nothing to "
f"exercise on the override-frame surface"
)
override_args = _frame_override_args(pins)
# (B) full rerun with the discovered frame overrides — independent control.
full_id = _unique("full")
cp_b = _spawn_pipeline([str(mdx_path), full_id, *layout_args, *override_args])
_assert_run_ok(
f"full rerun (B) mdx={mdx_name} layout={layout_pin} pins={len(pins)}",
cp_b,
)
# (C) --reuse-from seed with the same frame overrides — reuse path.
# NOTE: must NOT pass --override-layout here — u1 fail-closed guard
# rejects layout+reuse combination. Layout is restored from the Step 6
# snapshot (u2 layout_preset_pre_override) instead.
reuse_id = _unique("reuse")
cp_c = _spawn_pipeline([
str(mdx_path),
reuse_id,
"--reuse-from", seed_id,
*override_args,
])
_assert_run_ok(
f"reuse rerun (C) mdx={mdx_name} layout={layout_pin} pins={len(pins)}",
cp_c,
)
# Step 13 equivalence — apply whitelist + compare byte-for-byte.
full_step13 = _read_step_artifact(full_id, "step13_render.json")
reuse_step13 = _read_step_artifact(reuse_id, "step13_render.json")
full_norm = _normalize_step13(full_step13, full_id)
reuse_norm = _normalize_step13(reuse_step13, reuse_id)
assert full_norm == reuse_norm, (
f"step13_render.json equivalence violated for IMP-43 #72 u7b "
f"(mdx={mdx_name}, layout={layout_pin}, full={full_id}, "
f"reuse={reuse_id}, seed={seed_id}, pins={pins}):\n"
f"--- full (normalized) ---\n"
f"{json.dumps(full_norm, ensure_ascii=False, indent=2)}\n"
f"--- reuse (normalized) ---\n"
f"{json.dumps(reuse_norm, ensure_ascii=False, indent=2)}"
)
@@ -0,0 +1,204 @@
"""IMP-43 (#72) u7a — Fast CI equivalence test for full rerun vs ``--reuse-from``.
u7a scope (per the Stage 2 Exit Report):
* One mdx (``samples/mdx_batch/02.mdx``), one layout (auto), two
``--override-frame`` pins self-discovered from the baseline's
``step06_composition_plan.json`` (each pin re-states the unit's
own ``frame_template_id`` — semantically a no-op, but it
exercises the full ``--override-frame`` CLI surface through both
paths, satisfying the "two frames" axis of the Stage 2 plan).
* Three subprocess pipeline runs:
(A) baseline full run — no overrides — reuse seed
(B) full rerun with the two ``--override-frame`` pins — the
independent control path that does NOT touch ``--reuse-from``
(C) ``--reuse-from <seed_id>`` with the same two
``--override-frame`` pins — the reuse path
* Assert: ``step13_render.json`` from (B) and (C) is byte-equal modulo
the Stage 2 whitelist — only ``run_id`` (as a substring of
``data.final_html_path``), ``timestamps``, and ``prev_run_id`` may
legitimately differ. ``step13_render.json`` has no timestamps and
no ``prev_run_id`` field (the latter surfaces via the separate
``_reuse_marker.json`` sidecar instead — out of scope for this
step13 equivalence axis), so the only effective normalization
target is the ``run_id`` substring inside ``data.final_html_path``.
Per Stage 2 plan: the sweep equivalence coverage (3 layouts × 3 mdx ×
all 32 frames) lives in u7b under ``pytest.mark.sweep`` — u7a stays
fast (3 pipeline runs on a single small mdx) so it can run in default
CI without an opt-in marker.
Why mdx02:
* ``test_pipeline_smoke_imp85.py::test_non_vp_smoke_runs_clean`` already
pins mdx02 as a non-VP exit-0 path (the baseline (A) run must
exit 0 for the equivalence axis to even have something to
compare against).
* mdx04 / mdx05 are deliberately excluded — mdx04 routes zones to
``adapter_needed`` per IMP-#85 u1 and mdx05 exits 1 with
``EMPTY_SHELL_NO_CONTENT`` per IMP-#87 u3, neither of which gives
a stable step13 equivalence surface for a fast CI lock.
"""
from __future__ import annotations
import json
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"
RUNS_DIR = REPO_ROOT / "data" / "runs"
MDX_FILENAME = "02.mdx"
def _unique_run_id(prefix: str) -> str:
return f"{prefix}_imp43_u7a_{uuid.uuid4().hex[:8]}"
def _spawn_pipeline(extra_args: list[str], timeout: int = 600) -> subprocess.CompletedProcess:
"""Spawn ``python -m src.phase_z2_pipeline <args>`` and capture I/O."""
return subprocess.run(
[sys.executable, "-m", "src.phase_z2_pipeline", *extra_args],
capture_output=True,
text=True,
timeout=timeout,
cwd=str(REPO_ROOT),
)
def _assert_run_ok(label: str, cp: subprocess.CompletedProcess) -> None:
assert cp.returncode == 0, (
f"{label} pipeline returncode={cp.returncode}\n"
f"--- stderr tail ---\n{cp.stderr[-2000:]}\n"
f"--- stdout tail ---\n{cp.stdout[-2000:]}"
)
def _read_step_artifact(run_id: str, fname: str) -> dict:
p = RUNS_DIR / run_id / "phase_z2" / "steps" / fname
assert p.is_file(), f"missing artifact: {p}"
return json.loads(p.read_text(encoding="utf-8"))
def _discover_two_frame_pins(seed_run_id: str) -> list[tuple[str, str]]:
"""Self-discover two ``(unit_id, frame_template_id)`` pins from the
baseline's ``step06_composition_plan.json``.
Schema source: ``src/phase_z2_pipeline.py`` ~L5530-L5560 — the step06
artifact emits ``data.selected_units[*].{source_section_ids,
frame_template_id}``. ``unit_id`` is derived as
``"+".join(source_section_ids)`` per the
``--override-frame UNIT_ID=TEMPLATE_ID`` contract documented at
``src/phase_z2_pipeline.py:7827-7832`` and computed by ``_unit_id``
at ``src/phase_z2_pipeline.py:2328``. Pinning the unit's own
template is a no-op semantically but exercises the
``--override-frame`` CLI surface end-to-end in both (B) and (C).
"""
step06 = _read_step_artifact(seed_run_id, "step06_composition_plan.json")
selected_units = step06.get("data", {}).get("selected_units") or []
pinnable: list[tuple[str, str]] = []
for u in selected_units:
sids = u.get("source_section_ids") or []
tpl_id = u.get("frame_template_id")
if not isinstance(sids, list) or not sids:
continue
if not isinstance(tpl_id, str) or not tpl_id:
continue
unit_id = "+".join(str(s) for s in sids)
if not unit_id:
continue
pinnable.append((unit_id, tpl_id))
if len(pinnable) >= 2:
break
assert len(pinnable) >= 2, (
f"baseline {seed_run_id} step06_composition_plan.json must expose "
f">= 2 (unit_id, frame_template_id) pairs for the u7a two-frames "
f"axis; got {pinnable}"
)
return pinnable
def _frame_override_args(pins: list[tuple[str, str]]) -> list[str]:
out: list[str] = []
for unit_id, tpl_id in pins:
out.extend(["--override-frame", f"{unit_id}={tpl_id}"])
return out
def _normalize_step13(payload: dict, run_id: str) -> dict:
"""Apply the Stage 2 equivalence whitelist to step13_render.json.
Whitelist axes (Stage 2 plan §u7a):
* ``run_id`` — appears only as a substring of
``data.final_html_path`` in the step13 schema
(``src/phase_z2_pipeline.py:7174-7192``).
* ``timestamps`` — ``_write_step_artifact``
(``src/phase_z2_pipeline.py:3826``) does not
stamp a timestamp on the payload, so no
normalization is needed for this axis.
* ``prev_run_id`` — surfaces via ``_reuse_marker.json`` (separate
sidecar), NOT via step13_render.json. No
normalization needed on the step13 surface.
Returns a deep copy of ``payload`` with the ``run_id`` substring of
``data.final_html_path`` replaced by the sentinel ``<RUN_ID>`` so
the (B) and (C) step13 payloads can be compared byte-for-byte.
"""
normalized = json.loads(json.dumps(payload, ensure_ascii=False))
data = normalized.get("data")
if isinstance(data, dict):
fhp = data.get("final_html_path")
if isinstance(fhp, str) and run_id in fhp:
data["final_html_path"] = fhp.replace(run_id, "<RUN_ID>")
return normalized
def test_full_rerun_vs_reuse_from_step13_equivalence_one_mdx_two_frames() -> None:
"""Stage 2 §u7a binding contract: full rerun (B) with two
``--override-frame`` pins and ``--reuse-from`` (C) with the same
pins yield byte-equal ``step13_render.json`` modulo the whitelist.
"""
mdx_path = SAMPLES_DIR / MDX_FILENAME
assert mdx_path.is_file(), f"sample missing: {mdx_path}"
# (A) baseline full run — no overrides — reuse seed.
seed_id = _unique_run_id("seed")
cp_a = _spawn_pipeline([str(mdx_path), seed_id])
_assert_run_ok("baseline (A)", cp_a)
# Self-discover two (unit_id, frame_template_id) pins.
pins = _discover_two_frame_pins(seed_id)
override_args = _frame_override_args(pins)
# (B) full rerun with the two frame overrides — independent control.
full_id = _unique_run_id("full")
cp_b = _spawn_pipeline([str(mdx_path), full_id, *override_args])
_assert_run_ok("full rerun (B)", cp_b)
# (C) --reuse-from seed with the same frame overrides — reuse path.
reuse_id = _unique_run_id("reuse")
cp_c = _spawn_pipeline([
str(mdx_path),
reuse_id,
"--reuse-from", seed_id,
*override_args,
])
_assert_run_ok("reuse rerun (C)", cp_c)
# Step 13 equivalence — apply whitelist + compare byte-for-byte.
full_step13 = _read_step_artifact(full_id, "step13_render.json")
reuse_step13 = _read_step_artifact(reuse_id, "step13_render.json")
full_norm = _normalize_step13(full_step13, full_id)
reuse_norm = _normalize_step13(reuse_step13, reuse_id)
assert full_norm == reuse_norm, (
"step13_render.json equivalence violated for IMP-43 #72 u7a "
f"(full={full_id}, reuse={reuse_id}, seed={seed_id}, pins={pins}):\n"
f"--- full (normalized) ---\n"
f"{json.dumps(full_norm, ensure_ascii=False, indent=2)}\n"
f"--- reuse (normalized) ---\n"
f"{json.dumps(reuse_norm, ensure_ascii=False, indent=2)}"
)
@@ -0,0 +1,748 @@
"""IMP-43 (#72) u4b — fail-closed wrapper tests for ``--reuse-from``.
u4b scope (per the Stage 2 Exit Report):
- Translate the u4 raise surface (``FileNotFoundError`` /
``SnapshotValidationError`` / ``json.JSONDecodeError`` / ``OSError``)
into the CLI fail-closed contract: stderr message + ``sys.exit(2)``.
- Add the ``prev_run_dir == new_run_dir`` accidental-write guard BEFORE
any copy attempt (prev_run_dir must stay read-only).
- Add the missing-prev-run-dir surface (clean axis, not raw stack).
- Surface ``mdx_sha256 mismatch`` as its OWN axis (distinct from
generic snapshot validation failures).
The signature threading + the in-``run_phase_z2_mvp1`` branch that
invokes the wrapper land in u5. u4b adds the wrapper function only.
Tested surface (``src/phase_z2_pipeline.py``):
* ``execute_reuse_from_or_fail_closed``
* ``_abort_reuse_from``
* ``_paths_equivalent``
* ``REUSE_FAIL_CLOSED_AXES`` (closed enum)
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import pytest
import src.phase_z2_pipeline as _pz2
from src.phase_z2_reuse_snapshot import (
SNAPSHOT_FILENAME,
SNAPSHOT_VERSION,
build_snapshot,
)
# -- synthetic snapshot inputs (mirror u4 test fixture) ------------------
@dataclass
class _Section:
section_id: str
section_num: int
title: str
raw_content: str
heading_number: Optional[str] = None
v4_alias_keys: list = field(default_factory=list)
sub_sections: list = field(default_factory=list)
@dataclass
class _V4Candidate:
template_id: str
frame_id: str
frame_number: int
confidence: float
label: str
@dataclass
class _Unit:
source_section_ids: list
merge_type: str
frame_template_id: str
frame_id: str
frame_number: int
confidence: float
label: str
phase_z_status: str
raw_content: str
title: str
score: float
v4_rank: Optional[int] = 1
selection_path: str = "rank_1"
fallback_reason: Optional[str] = None
rationale: dict = field(default_factory=dict)
auto_selectable: bool = True
filter_reasons: list = field(default_factory=list)
notes: list = field(default_factory=list)
v4_candidates: list = field(default_factory=list)
provisional: bool = False
def _mdx_text() -> str:
return "# Slide\n\n## 03-1 DX status\n\n- bullet one\n- bullet two\n"
def _build_canonical_snapshot(*, mdx_source_text: Optional[str] = None) -> dict:
text = mdx_source_text if mdx_source_text is not None else _mdx_text()
cand = _V4Candidate(
template_id="tpl_a",
frame_id="fid_a",
frame_number=13,
confidence=0.91,
label="use_as_is",
)
section = _Section(
section_id="03-1",
section_num=1,
title="DX status",
raw_content="- bullet one\n- bullet two",
heading_number="3.1",
v4_alias_keys=["03-1.1"],
)
unit = _Unit(
source_section_ids=["03-1"],
merge_type="single",
frame_template_id="tpl_a",
frame_id="fid_a",
frame_number=13,
confidence=0.91,
label="use_as_is",
phase_z_status="auto_renderable",
raw_content="- bullet one\n- bullet two",
title="DX status",
score=0.91,
v4_candidates=[cand],
)
return build_snapshot(
mdx_sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
slide_title="Slide",
slide_footer=None,
sections=[section],
stage0_adapter_diagnostics={"used": True, "fallback_reason": None},
stage0_normalized_assets={"popups": [], "images": [], "tables": []},
v4_evidence=[],
layout_preset_pre_override="single",
units=[unit],
comp_debug={},
v4_fallback_traces={},
ai_preflight={"enabled": False, "skipped": True},
)
def _seed_prev_run_dir(prev_run_dir: Path, *, snapshot: dict) -> None:
(prev_run_dir / "steps").mkdir(parents=True, exist_ok=True)
for fname in _pz2._REUSE_STEP_ARTIFACTS:
(prev_run_dir / "steps" / fname).write_text(
f'{{"name": "{fname}"}}'
if fname.endswith(".json")
else "raw mdx body bytes",
encoding="utf-8",
)
(prev_run_dir / SNAPSHOT_FILENAME).write_text(
json.dumps(snapshot, ensure_ascii=False, indent=2),
encoding="utf-8",
)
# -- REUSE_FAIL_CLOSED_AXES vocab lock ------------------------------------
def test_fail_closed_axes_is_closed_enum():
"""The nine axes are the entire fail-closed vocabulary; if a new
axis lands without test coverage update, this lock breaks.
``reuse_copy_os_error`` / ``snapshot_read_os_error`` were added in
the Codex #6 stage_3_edit rewind to cover OSError != FNF that the
earlier u4b implementation let escape as a raw traceback.
"""
assert _pz2.REUSE_FAIL_CLOSED_AXES == frozenset({
"prev_run_dir_missing",
"prev_run_dir_equals_new_run_dir",
"reuse_artifact_missing",
"reuse_copy_os_error",
"snapshot_missing_after_copy",
"snapshot_corrupt_json",
"snapshot_read_os_error",
"mdx_sha256_mismatch",
"snapshot_validation_failed",
})
# -- _abort_reuse_from -----------------------------------------------------
def test_abort_reuse_from_exits_with_code_two(capsys):
with pytest.raises(SystemExit) as ei:
_pz2._abort_reuse_from(
axis="prev_run_dir_missing",
value="never_existed",
path="D:/nope",
upstream="--reuse-from CLI argument",
)
assert ei.value.code == 2
def test_abort_reuse_from_stderr_contains_value_path_upstream(capsys):
with pytest.raises(SystemExit):
_pz2._abort_reuse_from(
axis="prev_run_dir_missing",
value="never_existed",
path="D:/nope",
upstream="--reuse-from CLI argument",
)
err = capsys.readouterr().err
assert "prev_run_dir_missing" in err
assert "value:" in err
assert "path:" in err
assert "upstream:" in err
assert "never_existed" in err
assert "D:/nope" in err
assert "--reuse-from CLI argument" in err
def test_abort_reuse_from_includes_reason_when_exc_passed(capsys):
"""The optional ``exc`` field surfaces the underlying type +
message so operators can distinguish e.g. JSONDecodeError line/col
info from a generic 'snapshot broken'."""
try:
raise ValueError("schema_version mismatch: expected 1, got 99")
except ValueError as exc:
with pytest.raises(SystemExit):
_pz2._abort_reuse_from(
axis="snapshot_validation_failed",
value=str(exc),
path="D:/some/path",
upstream="validate_snapshot",
exc=exc,
)
err = capsys.readouterr().err
assert "reason:" in err
assert "ValueError" in err
assert "schema_version mismatch" in err
def test_abort_reuse_from_rejects_unknown_axis():
"""Unknown axis = programmer error, not user error; must trip
AssertionError, not silently emit a malformed stderr line."""
with pytest.raises(AssertionError):
_pz2._abort_reuse_from(
axis="totally_made_up_axis",
value="x",
path="y",
upstream="z",
)
# -- _paths_equivalent -----------------------------------------------------
def test_paths_equivalent_same_path_returns_true(tmp_path: Path):
a = tmp_path / "x" / "y"
a.mkdir(parents=True)
assert _pz2._paths_equivalent(a, a) is True
def test_paths_equivalent_different_paths_returns_false(tmp_path: Path):
a = tmp_path / "alpha"
b = tmp_path / "beta"
a.mkdir()
b.mkdir()
assert _pz2._paths_equivalent(a, b) is False
def test_paths_equivalent_handles_nonexistent_paths(tmp_path: Path):
"""``Path.resolve(strict=False)`` should still normalize ``..``
even when the leaf does not yet exist (new_run_dir before mkdir)."""
a = tmp_path / "new_run" / "phase_z2"
b = tmp_path / "new_run" / "phase_z2"
assert _pz2._paths_equivalent(a, b) is True
# -- execute_reuse_from_or_fail_closed: happy path -----------------------
def test_happy_path_returns_prev_run_dir_copied_snapshot(
tmp_path: Path, monkeypatch
):
text = _mdx_text()
runs_root = tmp_path / "runs"
prev_run_id = "prev_run_id_001"
prev_run_dir = runs_root / prev_run_id / "phase_z2"
new_run_dir = tmp_path / "new" / "phase_z2"
snap = _build_canonical_snapshot(mdx_source_text=text)
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
rv = _pz2.execute_reuse_from_or_fail_closed(
reuse_from=prev_run_id,
new_run_dir=new_run_dir,
mdx_source_text=text,
)
prev_dir_ret, copied_ret, snap_ret = rv
assert prev_dir_ret == prev_run_dir
assert SNAPSHOT_FILENAME in copied_ret
assert snap_ret["schema_version"] == SNAPSHOT_VERSION
# snapshot wrapper survives (value/source_path/upstream_step)
assert snap_ret["slide_title"]["value"] == "Slide"
# -- prev_run_dir_missing axis --------------------------------------------
def test_prev_run_dir_missing_aborts(tmp_path: Path, monkeypatch, capsys):
runs_root = tmp_path / "runs"
runs_root.mkdir()
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
with pytest.raises(SystemExit) as ei:
_pz2.execute_reuse_from_or_fail_closed(
reuse_from="does_not_exist_anywhere",
new_run_dir=tmp_path / "new" / "phase_z2",
mdx_source_text=_mdx_text(),
)
assert ei.value.code == 2
err = capsys.readouterr().err
assert "prev_run_dir_missing" in err
assert "does_not_exist_anywhere" in err
# -- prev_run_dir_equals_new_run_dir axis ---------------------------------
def test_prev_run_dir_equals_new_run_dir_aborts(
tmp_path: Path, monkeypatch, capsys
):
"""Accidental collision: if the new run_id resolves to the same
phase_z2 dir as prev_run_id, the copy step would overwrite
prev_run_dir in place. u4b must reject BEFORE the copy attempt."""
runs_root = tmp_path / "runs"
prev_run_id = "shared_run_id"
prev_run_dir = runs_root / prev_run_id / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
# new_run_dir resolves to the SAME phase_z2 dir as prev_run_dir.
new_run_dir = prev_run_dir
with pytest.raises(SystemExit) as ei:
_pz2.execute_reuse_from_or_fail_closed(
reuse_from=prev_run_id,
new_run_dir=new_run_dir,
mdx_source_text=_mdx_text(),
)
assert ei.value.code == 2
err = capsys.readouterr().err
assert "prev_run_dir_equals_new_run_dir" in err
def test_prev_run_dir_equals_new_run_dir_does_not_mutate_prev(
tmp_path: Path, monkeypatch
):
"""Critical RO guarantee — the abort must fire BEFORE
``_copy_reuse_artifacts_from_prev_run`` runs, so the seeded prev
artifact bytes survive untouched."""
runs_root = tmp_path / "runs"
prev_run_id = "shared_run_id"
prev_run_dir = runs_root / prev_run_id / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
sentinel_text = '{"name": "step02_normalized.json"}'
target = prev_run_dir / "steps" / "step02_normalized.json"
assert target.read_text(encoding="utf-8") == sentinel_text
with pytest.raises(SystemExit):
_pz2.execute_reuse_from_or_fail_closed(
reuse_from=prev_run_id,
new_run_dir=prev_run_dir,
mdx_source_text=_mdx_text(),
)
# prev_run_dir bytes still intact.
assert target.read_text(encoding="utf-8") == sentinel_text
# -- reuse_artifact_missing axis ------------------------------------------
def test_reuse_artifact_missing_aborts(tmp_path: Path, monkeypatch, capsys):
runs_root = tmp_path / "runs"
prev_run_id = "prev_run_001"
prev_run_dir = runs_root / prev_run_id / "phase_z2"
new_run_dir = tmp_path / "new" / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
# Remove one required step file → triggers FileNotFoundError in
# _copy_reuse_artifacts_from_prev_run.
(prev_run_dir / "steps" / "step05_v4_evidence.json").unlink()
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
with pytest.raises(SystemExit) as ei:
_pz2.execute_reuse_from_or_fail_closed(
reuse_from=prev_run_id,
new_run_dir=new_run_dir,
mdx_source_text=_mdx_text(),
)
assert ei.value.code == 2
err = capsys.readouterr().err
assert "reuse_artifact_missing" in err
assert "step05_v4_evidence.json" in err
assert "reason:" in err
assert "FileNotFoundError" in err
def test_reuse_artifact_missing_snapshot_sidecar(
tmp_path: Path, monkeypatch, capsys
):
runs_root = tmp_path / "runs"
prev_run_id = "prev_run_002"
prev_run_dir = runs_root / prev_run_id / "phase_z2"
new_run_dir = tmp_path / "new" / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
(prev_run_dir / SNAPSHOT_FILENAME).unlink()
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
with pytest.raises(SystemExit) as ei:
_pz2.execute_reuse_from_or_fail_closed(
reuse_from=prev_run_id,
new_run_dir=new_run_dir,
mdx_source_text=_mdx_text(),
)
assert ei.value.code == 2
err = capsys.readouterr().err
assert "reuse_artifact_missing" in err
assert SNAPSHOT_FILENAME in err
# -- snapshot_corrupt_json axis -------------------------------------------
def test_snapshot_corrupt_json_aborts(tmp_path: Path, monkeypatch, capsys):
runs_root = tmp_path / "runs"
prev_run_id = "prev_run_corrupt"
prev_run_dir = runs_root / prev_run_id / "phase_z2"
new_run_dir = tmp_path / "new" / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
# Overwrite the snapshot with invalid JSON; copy will succeed,
# validate_snapshot will fail with JSONDecodeError (raised inside
# _load_and_validate_reuse_snapshot before validate_snapshot).
(prev_run_dir / SNAPSHOT_FILENAME).write_text(
"{ not valid json", encoding="utf-8"
)
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
with pytest.raises(SystemExit) as ei:
_pz2.execute_reuse_from_or_fail_closed(
reuse_from=prev_run_id,
new_run_dir=new_run_dir,
mdx_source_text=_mdx_text(),
)
assert ei.value.code == 2
err = capsys.readouterr().err
assert "snapshot_corrupt_json" in err
assert SNAPSHOT_FILENAME in err
assert "JSONDecodeError" in err
# -- mdx_sha256_mismatch axis (own surface) -------------------------------
def test_mdx_sha256_mismatch_aborts_with_own_axis(
tmp_path: Path, monkeypatch, capsys
):
"""Distinct from generic snapshot_validation_failed — operator
must be able to tell 'wrong --mdx-path for this prev_run_id' apart
from 'snapshot file is broken'."""
runs_root = tmp_path / "runs"
prev_run_id = "prev_run_diff_mdx"
prev_run_dir = runs_root / prev_run_id / "phase_z2"
new_run_dir = tmp_path / "new" / "phase_z2"
text_a = "# Slide A\n"
text_b = "# Slide B (different bytes)\n"
snap = _build_canonical_snapshot(mdx_source_text=text_a)
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
with pytest.raises(SystemExit) as ei:
_pz2.execute_reuse_from_or_fail_closed(
reuse_from=prev_run_id,
new_run_dir=new_run_dir,
mdx_source_text=text_b,
)
assert ei.value.code == 2
err = capsys.readouterr().err
assert "mdx_sha256_mismatch" in err
# Must NOT be reported as generic snapshot_validation_failed —
# the mdx-sha case has its own axis.
assert "snapshot_validation_failed" not in err
assert "mdx_source_text" in err or "mdx_sha256" in err
# -- snapshot_validation_failed axis --------------------------------------
def test_snapshot_validation_failed_schema_version_aborts(
tmp_path: Path, monkeypatch, capsys
):
runs_root = tmp_path / "runs"
prev_run_id = "prev_run_schema_mismatch"
prev_run_dir = runs_root / prev_run_id / "phase_z2"
new_run_dir = tmp_path / "new" / "phase_z2"
text = _mdx_text()
snap = _build_canonical_snapshot(mdx_source_text=text)
snap["schema_version"] = SNAPSHOT_VERSION + 1
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
with pytest.raises(SystemExit) as ei:
_pz2.execute_reuse_from_or_fail_closed(
reuse_from=prev_run_id,
new_run_dir=new_run_dir,
mdx_source_text=text,
)
assert ei.value.code == 2
err = capsys.readouterr().err
assert "snapshot_validation_failed" in err
assert "schema_version" in err
# NOT the mdx-sha axis — separate fingerprint.
assert "mdx_sha256_mismatch" not in err
def test_snapshot_validation_failed_missing_required_key_aborts(
tmp_path: Path, monkeypatch, capsys
):
runs_root = tmp_path / "runs"
prev_run_id = "prev_run_missing_key"
prev_run_dir = runs_root / prev_run_id / "phase_z2"
new_run_dir = tmp_path / "new" / "phase_z2"
text = _mdx_text()
snap = _build_canonical_snapshot(mdx_source_text=text)
del snap["units"]
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
with pytest.raises(SystemExit) as ei:
_pz2.execute_reuse_from_or_fail_closed(
reuse_from=prev_run_id,
new_run_dir=new_run_dir,
mdx_source_text=text,
)
assert ei.value.code == 2
err = capsys.readouterr().err
assert "snapshot_validation_failed" in err
assert "units" in err
# -- reuse_copy_os_error axis (OSError != FileNotFoundError) -------------
def test_copy_os_error_aborts_with_own_axis(
tmp_path: Path, monkeypatch, capsys
):
"""Codex #6 stage_3_edit fixup — OSError raised inside
``_copy_reuse_artifacts_from_prev_run`` (e.g. PermissionError on
the destination, OSError(errno.EXDEV) on cross-device copy) must
translate to fail-closed (stderr + SystemExit(2)) instead of
escaping as a raw traceback.
Implementation must catch ``FileNotFoundError`` BEFORE the bare
``OSError`` handler (FNF is a subclass of OSError), otherwise the
missing-artifact case would be mis-bucketed under
``reuse_copy_os_error``.
"""
runs_root = tmp_path / "runs"
prev_run_id = "prev_run_perm_denied"
prev_run_dir = runs_root / prev_run_id / "phase_z2"
new_run_dir = tmp_path / "new" / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
def _raise_perm(src, dst, *args, **kwargs):
raise PermissionError(f"simulated permission denied: {dst}")
monkeypatch.setattr(_pz2.shutil, "copyfile", _raise_perm)
with pytest.raises(SystemExit) as ei:
_pz2.execute_reuse_from_or_fail_closed(
reuse_from=prev_run_id,
new_run_dir=new_run_dir,
mdx_source_text=_mdx_text(),
)
assert ei.value.code == 2
err = capsys.readouterr().err
assert "reuse_copy_os_error" in err
assert "value:" in err
assert "path:" in err
assert "upstream:" in err
assert "reason:" in err
assert "PermissionError" in err
assert "simulated permission denied" in err
# Must NOT be mis-bucketed as the missing-artifact case.
assert "reuse_artifact_missing" not in err
def test_copy_filenotfounderror_still_uses_artifact_missing_axis(
tmp_path: Path, monkeypatch, capsys
):
"""Subclass ordering regression guard — ``FileNotFoundError`` IS an
``OSError`` subclass. If the bare-OSError handler ever moves above
the FNF handler, the missing-artifact case would be mis-bucketed
under ``reuse_copy_os_error``; this test pins the dispatch.
"""
runs_root = tmp_path / "runs"
prev_run_id = "prev_run_fnf_ordering"
prev_run_dir = runs_root / prev_run_id / "phase_z2"
new_run_dir = tmp_path / "new" / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
(prev_run_dir / "steps" / "step05_v4_evidence.json").unlink()
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
with pytest.raises(SystemExit):
_pz2.execute_reuse_from_or_fail_closed(
reuse_from=prev_run_id,
new_run_dir=new_run_dir,
mdx_source_text=_mdx_text(),
)
err = capsys.readouterr().err
assert "reuse_artifact_missing" in err
assert "reuse_copy_os_error" not in err
# -- snapshot_read_os_error axis (OSError != FileNotFoundError) ----------
def test_snapshot_read_os_error_aborts_with_own_axis(
tmp_path: Path, monkeypatch, capsys
):
"""OSError raised inside ``_load_and_validate_reuse_snapshot``
(e.g. PermissionError on ``Path.read_text``, IsADirectoryError if
the snapshot path resolves to a directory after copy) must
translate to fail-closed instead of escaping as a raw traceback.
"""
runs_root = tmp_path / "runs"
prev_run_id = "prev_run_snapshot_perm"
prev_run_dir = runs_root / prev_run_id / "phase_z2"
new_run_dir = tmp_path / "new" / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
def _raise_perm(*args, **kwargs):
raise PermissionError("simulated read denied on snapshot")
monkeypatch.setattr(
_pz2, "_load_and_validate_reuse_snapshot", _raise_perm
)
with pytest.raises(SystemExit) as ei:
_pz2.execute_reuse_from_or_fail_closed(
reuse_from=prev_run_id,
new_run_dir=new_run_dir,
mdx_source_text=_mdx_text(),
)
assert ei.value.code == 2
err = capsys.readouterr().err
assert "snapshot_read_os_error" in err
assert "value:" in err
assert "path:" in err
assert "upstream:" in err
assert "reason:" in err
assert "PermissionError" in err
assert "simulated read denied on snapshot" in err
# Must NOT be mis-bucketed as missing-after-copy or corrupt-json.
assert "snapshot_missing_after_copy" not in err
assert "snapshot_corrupt_json" not in err
def test_snapshot_filenotfounderror_still_uses_missing_after_copy_axis(
tmp_path: Path, monkeypatch, capsys
):
"""Subclass ordering regression guard for the load surface — FNF
must keep its own ``snapshot_missing_after_copy`` axis even though
the new bare-OSError branch sits below it.
"""
runs_root = tmp_path / "runs"
prev_run_id = "prev_run_load_fnf_ordering"
prev_run_dir = runs_root / prev_run_id / "phase_z2"
new_run_dir = tmp_path / "new" / "phase_z2"
snap = _build_canonical_snapshot()
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
def _raise_fnf(*args, **kwargs):
raise FileNotFoundError("simulated FNF on snapshot read")
monkeypatch.setattr(
_pz2, "_load_and_validate_reuse_snapshot", _raise_fnf
)
with pytest.raises(SystemExit):
_pz2.execute_reuse_from_or_fail_closed(
reuse_from=prev_run_id,
new_run_dir=new_run_dir,
mdx_source_text=_mdx_text(),
)
err = capsys.readouterr().err
assert "snapshot_missing_after_copy" in err
assert "snapshot_read_os_error" not in err
# -- module surface anchor ------------------------------------------------
def test_pipeline_exposes_u4b_surface():
"""u5 wires ``execute_reuse_from_or_fail_closed`` into the entry
point — the public callable + the closed-axis vocabulary must
remain module-level attributes."""
for name in (
"execute_reuse_from_or_fail_closed",
"_abort_reuse_from",
"_paths_equivalent",
"REUSE_FAIL_CLOSED_AXES",
):
assert hasattr(_pz2, name), f"u4b surface missing: {name}"
def test_pipeline_run_signature_reuse_from_threaded_after_u5():
"""u5 has now threaded ``reuse_from`` into ``run_phase_z2_mvp1`` as
a keyword-only parameter with default ``None``. The previous
``until_u5`` lock has flipped — this forward-direction lock
ensures the kwarg never silently drifts (positional promotion,
default change to a string, kind change). Mirror of the
equivalent lock in test_phase_z2_reuse_from_entry.py and
test_phase_z2_cli_reuse_from.py — kept in this file too so the
fail-closed regression suite is self-contained."""
import inspect
sig = inspect.signature(_pz2.run_phase_z2_mvp1)
assert "reuse_from" in sig.parameters, (
"u5 must thread reuse_from into run_phase_z2_mvp1 — kwarg missing. "
f"current params: {list(sig.parameters)}"
)
param = sig.parameters["reuse_from"]
assert param.kind is inspect.Parameter.KEYWORD_ONLY, (
f"reuse_from must be keyword-only (after the ``*`` barrier); "
f"got kind={param.kind}"
)
assert param.default is None, (
f"reuse_from must default to None to preserve pre-u5 behaviour; "
f"got default={param.default!r}"
)
+493
View File
@@ -0,0 +1,493 @@
"""IMP-43 (#72) u2 — unit tests for ``src.phase_z2_reuse_snapshot``.
Scope mirror of the production module (Stage 2 u2):
* ``build_snapshot`` shape, provenance, JSON round-trip, required keys.
* ``serialize_section`` / ``serialize_unit`` field preservation, including
the duck-typed ``v4_candidates`` shape (template_id / frame_id /
frame_number / confidence / label).
* ``validate_snapshot`` fail-closed paths: non-dict input, schema
version mismatch, missing/empty/non-string ``mdx_sha256``, sha
mismatch, missing required keys, unwrapped wrapper, wrapper missing
a provenance field.
* Module-level constants exposed for u3 / u4 / u4b consumers.
The tests use synthetic duck-typed dataclasses so the snapshot module's
external surface is exercised without coupling to the production
``MdxSection`` / ``CompositionUnit`` / ``V4Match`` dataclass layouts.
That mirrors the production module's intentional duck-typing (no
imports from ``phase_z2_pipeline`` / ``phase_z2_composition``).
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Optional
import pytest
from src.phase_z2_reuse_snapshot import (
REQUIRED_TOP_LEVEL_KEYS,
SNAPSHOT_FILENAME,
SNAPSHOT_VERSION,
SnapshotValidationError,
build_snapshot,
serialize_section,
serialize_unit,
validate_snapshot,
)
# -- synthetic duck-typed inputs ------------------------------------------
@dataclass
class _Section:
section_id: str
section_num: int
title: str
raw_content: str
heading_number: Optional[str] = None
v4_alias_keys: list = field(default_factory=list)
sub_sections: list = field(default_factory=list)
@dataclass
class _V4Candidate:
template_id: str
frame_id: str
frame_number: int
confidence: float
label: str
v4_rank: Optional[int] = None
@dataclass
class _Unit:
source_section_ids: list
merge_type: str
frame_template_id: str
frame_id: str
frame_number: int
confidence: float
label: str
phase_z_status: str
raw_content: str
title: str
score: float
v4_rank: Optional[int] = 1
selection_path: str = "rank_1"
fallback_reason: Optional[str] = None
rationale: dict = field(default_factory=dict)
auto_selectable: bool = True
filter_reasons: list = field(default_factory=list)
notes: list = field(default_factory=list)
v4_candidates: list = field(default_factory=list)
provisional: bool = False
def _make_section(**overrides: Any) -> _Section:
base = dict(
section_id="03-1",
section_num=1,
title="DX status",
raw_content="- bullet one\n- bullet two",
)
base.update(overrides)
return _Section(**base)
def _make_unit(**overrides: Any) -> _Unit:
cand = _V4Candidate(
template_id="tpl_a",
frame_id="fid_a",
frame_number=13,
confidence=0.91,
label="use_as_is",
)
base: dict[str, Any] = dict(
source_section_ids=["03-1"],
merge_type="single",
frame_template_id="tpl_a",
frame_id="fid_a",
frame_number=13,
confidence=0.91,
label="use_as_is",
phase_z_status="auto_renderable",
raw_content="- bullet one\n- bullet two",
title="DX status",
score=0.91,
v4_candidates=[cand],
)
base.update(overrides)
return _Unit(**base)
def _make_build_kwargs(**overrides: Any) -> dict[str, Any]:
kwargs: dict[str, Any] = dict(
mdx_sha256="a" * 64,
slide_title="Title",
slide_footer="Footer",
sections=[_make_section()],
stage0_adapter_diagnostics={"used": True, "fallback_reason": None},
stage0_normalized_assets={"popups": [], "images": [], "tables": []},
v4_evidence=[{"section_id": "03-1", "v4_candidates": []}],
layout_preset_pre_override="horizontal-2",
units=[_make_unit()],
comp_debug={"v4_fallback_summary": {"fallback_used_count": 0}},
v4_fallback_traces={"03-1": {"selection_path": "rank_1"}},
ai_preflight={"enabled": False, "skipped": True},
)
kwargs.update(overrides)
return kwargs
# -- module constants -----------------------------------------------------
def test_snapshot_filename_constant():
assert SNAPSHOT_FILENAME == "_reuse_snapshot.json"
def test_snapshot_version_is_positive_int():
assert isinstance(SNAPSHOT_VERSION, int)
assert SNAPSHOT_VERSION >= 1
def test_required_keys_include_contract_and_payload():
# Bare contract / integrity keys.
assert "schema_version" in REQUIRED_TOP_LEVEL_KEYS
assert "mdx_sha256" in REQUIRED_TOP_LEVEL_KEYS
# Payload axes per Stage 2 plan.
for k in (
"slide_title",
"slide_footer",
"sections",
"stage0_adapter_diagnostics",
"stage0_normalized_assets",
"v4_evidence",
"layout_preset_pre_override",
"units",
"comp_debug",
"v4_fallback_traces",
"ai_preflight",
):
assert k in REQUIRED_TOP_LEVEL_KEYS, f"missing from REQUIRED_TOP_LEVEL_KEYS: {k}"
# -- build_snapshot -------------------------------------------------------
def test_build_snapshot_round_trips_through_json():
snap = build_snapshot(**_make_build_kwargs())
payload = json.dumps(snap)
loaded = json.loads(payload)
assert loaded["schema_version"] == SNAPSHOT_VERSION
assert loaded["mdx_sha256"] == "a" * 64
def test_build_snapshot_has_all_required_keys():
snap = build_snapshot(**_make_build_kwargs())
for key in REQUIRED_TOP_LEVEL_KEYS:
assert key in snap, f"build_snapshot missing required key: {key}"
def test_build_snapshot_bare_keys_are_unwrapped_scalars():
snap = build_snapshot(**_make_build_kwargs())
assert snap["schema_version"] == SNAPSHOT_VERSION
assert snap["mdx_sha256"] == "a" * 64
# bare keys MUST NOT be wrapped — u4b mdx_sha256 check reads directly.
assert not isinstance(snap["schema_version"], dict)
assert not isinstance(snap["mdx_sha256"], dict)
def test_build_snapshot_provenance_wrapper_shape():
snap = build_snapshot(**_make_build_kwargs())
bare = {"schema_version", "mdx_sha256"}
for key, entry in snap.items():
if key in bare:
continue
assert isinstance(entry, dict), f"{key} is not wrapped"
assert set(entry.keys()) == {"value", "source_path", "upstream_step"}, key
assert isinstance(entry["source_path"], str) and entry["source_path"]
assert isinstance(entry["upstream_step"], str)
assert entry["upstream_step"].startswith("step"), entry["upstream_step"]
def test_build_snapshot_upstream_steps_stay_inside_reuse_boundary():
"""No ``upstream_step`` may point outside the Step 0/2/5/6 reuse
boundary (Stage 1 root_cause). A drift to e.g. ``step09`` would
silently invite work outside the reuse window — fail loudly.
Step 01's contribution is the ``mdx_sha256`` integrity key (a bare
contract scalar with no wrapper) so step01 does not need to appear
in payload provenance.
"""
snap = build_snapshot(**_make_build_kwargs())
allowed = {"step00", "step02", "step05", "step06"}
for key, entry in snap.items():
if key in {"schema_version", "mdx_sha256"}:
continue
assert entry["upstream_step"] in allowed, (
f"key {key!r}: upstream_step {entry['upstream_step']!r} outside reuse boundary"
)
def test_build_snapshot_units_carry_v4_candidates():
snap = build_snapshot(**_make_build_kwargs())
units = snap["units"]["value"]
assert len(units) == 1
assert units[0]["v4_candidates"][0]["template_id"] == "tpl_a"
assert units[0]["v4_candidates"][0]["frame_number"] == 13
assert units[0]["v4_candidates"][0]["confidence"] == pytest.approx(0.91)
def test_build_snapshot_sections_preserve_alias_keys_and_subsections():
sec = _make_section(
section_id="04-2",
v4_alias_keys=["04-2.1"],
sub_sections=[{"id": "04-2-sub-1"}],
heading_number="2.1",
)
snap = build_snapshot(**_make_build_kwargs(sections=[sec]))
payload = snap["sections"]["value"]
assert payload[0]["section_id"] == "04-2"
assert payload[0]["v4_alias_keys"] == ["04-2.1"]
assert payload[0]["sub_sections"] == [{"id": "04-2-sub-1"}]
assert payload[0]["heading_number"] == "2.1"
def test_build_snapshot_units_provenance_points_at_step06():
snap = build_snapshot(**_make_build_kwargs())
assert "step06_composition_plan.json" in snap["units"]["source_path"]
assert snap["units"]["upstream_step"] == "step06"
def test_build_snapshot_v4_evidence_provenance_points_at_step05():
snap = build_snapshot(**_make_build_kwargs())
assert "step05_v4_evidence.json" in snap["v4_evidence"]["source_path"]
assert snap["v4_evidence"]["upstream_step"] == "step05"
def test_build_snapshot_ai_preflight_provenance_points_at_step00():
snap = build_snapshot(**_make_build_kwargs())
assert "step00_preconditions.json" in snap["ai_preflight"]["source_path"]
assert snap["ai_preflight"]["upstream_step"] == "step00"
def test_build_snapshot_rejects_unjsonable_input():
bad_unit = _make_unit()
bad_unit.notes.append(object()) # not JSON-safe
with pytest.raises(TypeError):
build_snapshot(**_make_build_kwargs(units=[bad_unit]))
def test_build_snapshot_handles_none_optional_fields():
snap = build_snapshot(
**_make_build_kwargs(
slide_title=None,
slide_footer=None,
stage0_adapter_diagnostics=None,
stage0_normalized_assets=None,
comp_debug=None,
v4_fallback_traces=None,
ai_preflight=None,
)
)
# None inputs land as None / {} consistently — never raise.
assert snap["slide_title"]["value"] is None
assert snap["slide_footer"]["value"] is None
assert snap["stage0_adapter_diagnostics"]["value"] == {}
assert snap["stage0_normalized_assets"]["value"] == {}
assert snap["comp_debug"]["value"] == {}
assert snap["v4_fallback_traces"]["value"] == {}
assert snap["ai_preflight"]["value"] == {}
# -- serializer helpers ---------------------------------------------------
def test_serialize_section_preserves_all_documented_fields():
sec = _make_section(
heading_number="1.1",
v4_alias_keys=["03-1.x"],
sub_sections=[{"id": "s"}],
)
out = serialize_section(sec)
assert out["section_id"] == "03-1"
assert out["section_num"] == 1
assert out["title"] == "DX status"
assert out["raw_content"].startswith("- bullet")
assert out["heading_number"] == "1.1"
assert out["v4_alias_keys"] == ["03-1.x"]
assert out["sub_sections"] == [{"id": "s"}]
def test_serialize_section_works_with_missing_optional_attrs():
class _Minimal:
section_id = "x"
section_num = 0
title = "t"
raw_content = "r"
out = serialize_section(_Minimal())
assert out["heading_number"] is None
assert out["v4_alias_keys"] == []
assert out["sub_sections"] == []
def test_serialize_unit_v4_candidates_unwrap_to_named_attrs():
unit = _make_unit()
out = serialize_unit(unit)
cand = out["v4_candidates"][0]
assert cand == {
"template_id": "tpl_a",
"frame_id": "fid_a",
"frame_number": 13,
"confidence": pytest.approx(0.91),
"label": "use_as_is",
# u4 follow-up — Step 9 application-plan payload reads
# ``c.v4_rank`` off each rehydrated candidate. Snapshot
# serializer persists it via ``getattr(c, 'v4_rank', None)`` so
# legacy duck types (no v4_rank attr) get None and modern V4Match
# instances carry their rank (1/2/3/...).
"v4_rank": None,
}
def test_serialize_unit_v4_candidates_persist_v4_rank_when_present():
"""A v4_candidate with v4_rank=2 (V4Match-shape duck type) round-trips."""
ranked_cand = _V4Candidate(
template_id="tpl_b",
frame_id="fid_b",
frame_number=14,
confidence=0.82,
label="light_edit",
v4_rank=2,
)
unit = _make_unit(v4_candidates=[ranked_cand])
out = serialize_unit(unit)
assert out["v4_candidates"][0]["v4_rank"] == 2
def test_serialize_unit_handles_empty_v4_candidates():
unit = _make_unit(v4_candidates=[])
out = serialize_unit(unit)
assert out["v4_candidates"] == []
def test_serialize_unit_provisional_default_false():
unit = _make_unit()
assert serialize_unit(unit)["provisional"] is False
def test_serialize_unit_provisional_true_preserved():
unit = _make_unit(provisional=True)
assert serialize_unit(unit)["provisional"] is True
def test_serialize_unit_round_trips_through_json():
out = serialize_unit(_make_unit())
reloaded = json.loads(json.dumps(out))
assert reloaded["source_section_ids"] == ["03-1"]
assert reloaded["frame_template_id"] == "tpl_a"
# -- validate_snapshot ----------------------------------------------------
def test_validate_snapshot_accepts_well_formed():
snap = build_snapshot(**_make_build_kwargs())
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
def test_validate_snapshot_rejects_non_dict_input():
with pytest.raises(SnapshotValidationError):
validate_snapshot("not a dict", expected_mdx_sha256="a" * 64)
def test_validate_snapshot_rejects_version_mismatch():
snap = build_snapshot(**_make_build_kwargs())
snap["schema_version"] = SNAPSHOT_VERSION + 999
with pytest.raises(SnapshotValidationError) as exc:
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
assert "schema_version" in str(exc.value)
def test_validate_snapshot_rejects_missing_sha():
snap = build_snapshot(**_make_build_kwargs())
del snap["mdx_sha256"]
with pytest.raises(SnapshotValidationError) as exc:
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
assert "mdx_sha256" in str(exc.value)
def test_validate_snapshot_rejects_empty_sha():
snap = build_snapshot(**_make_build_kwargs())
snap["mdx_sha256"] = ""
with pytest.raises(SnapshotValidationError) as exc:
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
assert "mdx_sha256" in str(exc.value)
def test_validate_snapshot_rejects_non_string_sha():
snap = build_snapshot(**_make_build_kwargs())
snap["mdx_sha256"] = 12345
with pytest.raises(SnapshotValidationError) as exc:
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
assert "mdx_sha256" in str(exc.value)
def test_validate_snapshot_rejects_sha_mismatch():
snap = build_snapshot(**_make_build_kwargs())
with pytest.raises(SnapshotValidationError) as exc:
validate_snapshot(snap, expected_mdx_sha256="b" * 64)
assert "mdx_sha256 mismatch" in str(exc.value)
def test_validate_snapshot_rejects_missing_required_key():
snap = build_snapshot(**_make_build_kwargs())
del snap["units"]
with pytest.raises(SnapshotValidationError) as exc:
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
assert "units" in str(exc.value)
def test_validate_snapshot_rejects_unwrapped_payload_key():
snap = build_snapshot(**_make_build_kwargs())
snap["units"] = "not a dict"
with pytest.raises(SnapshotValidationError) as exc:
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
assert "units" in str(exc.value)
def test_validate_snapshot_rejects_wrapper_missing_value():
snap = build_snapshot(**_make_build_kwargs())
snap["units"] = {"source_path": "x", "upstream_step": "step06"}
with pytest.raises(SnapshotValidationError) as exc:
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
assert "value" in str(exc.value)
def test_validate_snapshot_rejects_wrapper_missing_source_path():
snap = build_snapshot(**_make_build_kwargs())
snap["units"] = {"value": [], "upstream_step": "step06"}
with pytest.raises(SnapshotValidationError) as exc:
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
assert "source_path" in str(exc.value)
def test_validate_snapshot_rejects_wrapper_missing_upstream_step():
snap = build_snapshot(**_make_build_kwargs())
snap["units"] = {"value": [], "source_path": "x"}
with pytest.raises(SnapshotValidationError) as exc:
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
assert "upstream_step" in str(exc.value)
def test_validate_snapshot_error_subclasses_value_error():
snap = build_snapshot(**_make_build_kwargs())
snap["schema_version"] = 999
# u4b will pre-catch SnapshotValidationError, but the broader
# `except ValueError` net must still pick this up.
with pytest.raises(ValueError):
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
+282
View File
@@ -0,0 +1,282 @@
"""IMP-43 (#72) u3 — focused tests for the Step 6 reuse snapshot writer.
u3 scope (per the Stage 2 Exit Report):
- ``_write_reuse_snapshot`` writes ``run_dir/_reuse_snapshot.json`` *after*
the Step 6 artifact lands; failure WARNS and CONTINUES (the helper does
NOT raise out of the main pipeline run).
- The Step 6 artifact data dict records the run_dir-relative sidecar path
as ``data.reuse_snapshot_path`` (additive informational field, always
set to ``SNAPSHOT_FILENAME`` regardless of write success — u4 will
fail-closed on missing / invalid sidecar via u2's ``validate_snapshot``).
The helper is tested in isolation (no full pipeline run) — pipeline call
site presence is asserted structurally so we exercise behaviour without
re-running Step 0~6 inside the test process. End-to-end equivalence under
``--reuse-from`` is u7a / u7b scope.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
import pytest
import src.phase_z2_pipeline as _pz2
from src.phase_z2_reuse_snapshot import (
SNAPSHOT_FILENAME,
SNAPSHOT_VERSION,
SnapshotValidationError,
validate_snapshot,
)
# -- synthetic duck-typed inputs ------------------------------------------
@dataclass
class _Section:
section_id: str
section_num: int
title: str
raw_content: str
heading_number: Optional[str] = None
v4_alias_keys: list = field(default_factory=list)
sub_sections: list = field(default_factory=list)
@dataclass
class _V4Candidate:
template_id: str
frame_id: str
frame_number: int
confidence: float
label: str
@dataclass
class _Unit:
source_section_ids: list
merge_type: str
frame_template_id: str
frame_id: str
frame_number: int
confidence: float
label: str
phase_z_status: str
raw_content: str
title: str
score: float
v4_rank: Optional[int] = 1
selection_path: str = "rank_1"
fallback_reason: Optional[str] = None
rationale: dict = field(default_factory=dict)
auto_selectable: bool = True
filter_reasons: list = field(default_factory=list)
notes: list = field(default_factory=list)
v4_candidates: list = field(default_factory=list)
provisional: bool = False
def _make_kwargs(**overrides: Any) -> dict[str, Any]:
cand = _V4Candidate(
template_id="tpl_a",
frame_id="fid_a",
frame_number=13,
confidence=0.91,
label="use_as_is",
)
section = _Section(
section_id="03-1",
section_num=1,
title="DX status",
raw_content="- bullet one\n- bullet two",
)
unit = _Unit(
source_section_ids=["03-1"],
merge_type="single",
frame_template_id="tpl_a",
frame_id="fid_a",
frame_number=13,
confidence=0.91,
label="use_as_is",
phase_z_status="auto_renderable",
raw_content="- bullet one\n- bullet two",
title="DX status",
score=0.91,
v4_candidates=[cand],
)
kwargs: dict[str, Any] = dict(
mdx_source_text="# Slide\n\n## 03-1 DX status\n\n- bullet one\n- bullet two\n",
slide_title="Slide",
slide_footer=None,
sections=[section],
stage0_adapter_diagnostics={"used": True, "fallback_reason": None},
stage0_normalized_assets={"popups": [], "images": [], "tables": []},
v4_evidence=[
{
"section_id": "03-1",
"v4_candidates": [
{
"template_id": "tpl_a",
"frame_id": "fid_a",
"frame_number": 13,
"confidence": 0.91,
"label": "use_as_is",
}
],
"candidate_status": "ok",
}
],
layout_preset_pre_override="single",
units=[unit],
comp_debug={"v4_fallback_summary": {"fallback_used_count": 0}},
v4_fallback_traces={"03-1": {"selection_path": "rank_1"}},
ai_preflight={"enabled": False, "skipped": True},
)
kwargs.update(overrides)
return kwargs
# -- success path ---------------------------------------------------------
def test_writes_snapshot_file_at_run_dir_root(tmp_path: Path):
rv = _pz2._write_reuse_snapshot(tmp_path, **_make_kwargs())
assert rv == SNAPSHOT_FILENAME
fpath = tmp_path / SNAPSHOT_FILENAME
assert fpath.exists(), f"snapshot not written at {fpath}"
def test_written_snapshot_validates(tmp_path: Path):
kwargs = _make_kwargs()
rv = _pz2._write_reuse_snapshot(tmp_path, **kwargs)
assert rv == SNAPSHOT_FILENAME
snap = json.loads((tmp_path / SNAPSHOT_FILENAME).read_text(encoding="utf-8"))
# mdx_sha256 is derived from mdx_source_text — recompute to verify
# the helper is hashing the UTF-8 bytes of the same source we passed.
import hashlib as _hl
expected_sha = _hl.sha256(
kwargs["mdx_source_text"].encode("utf-8")
).hexdigest()
validate_snapshot(snap, expected_mdx_sha256=expected_sha)
def test_snapshot_has_correct_schema_version(tmp_path: Path):
_pz2._write_reuse_snapshot(tmp_path, **_make_kwargs())
snap = json.loads((tmp_path / SNAPSHOT_FILENAME).read_text(encoding="utf-8"))
assert snap["schema_version"] == SNAPSHOT_VERSION
def test_snapshot_records_layout_preset_pre_override(tmp_path: Path):
_pz2._write_reuse_snapshot(
tmp_path, **_make_kwargs(layout_preset_pre_override="horizontal-2")
)
snap = json.loads((tmp_path / SNAPSHOT_FILENAME).read_text(encoding="utf-8"))
assert snap["layout_preset_pre_override"]["value"] == "horizontal-2"
def test_snapshot_is_utf8_encoded_with_non_ascii_content(tmp_path: Path):
_pz2._write_reuse_snapshot(
tmp_path,
**_make_kwargs(
slide_title="설계 방식의 왜곡",
mdx_source_text="# 설계 방식\n\n- 한글 bullet\n",
),
)
# ensure_ascii=False is intentional so Korean text round-trips
# readable; if a future refactor drops it the bytes change but the
# JSON still parses — we assert the file is decodable AS utf-8 and
# the value survives the round trip.
raw = (tmp_path / SNAPSHOT_FILENAME).read_text(encoding="utf-8")
snap = json.loads(raw)
assert snap["slide_title"]["value"] == "설계 방식의 왜곡"
# -- failure path ---------------------------------------------------------
def test_failure_warns_and_returns_none(tmp_path: Path, monkeypatch, capsys):
"""When ``build_snapshot`` raises, the helper must NOT propagate the
exception — it WARNS on stderr and returns ``None`` so the main
pipeline run continues."""
def _boom(**_kwargs):
raise RuntimeError("synthetic build failure")
monkeypatch.setattr(_pz2, "build_snapshot", _boom)
rv = _pz2._write_reuse_snapshot(tmp_path, **_make_kwargs())
assert rv is None
captured = capsys.readouterr()
assert "reuse-snapshot" in captured.err
assert "WARN" in captured.err
assert "RuntimeError" in captured.err
# File MUST NOT exist on failure (no partial JSON on disk).
assert not (tmp_path / SNAPSHOT_FILENAME).exists()
def test_failure_on_unwritable_run_dir_warns_and_returns_none(
tmp_path: Path, monkeypatch, capsys
):
"""Simulate disk write failure: helper warns + returns None, never
raises out to the caller (Stage 2 guardrail: optional sidecar)."""
nonexistent = tmp_path / "does" / "not" / "exist"
# nonexistent.exists() is False — Path.write_text raises FileNotFoundError.
rv = _pz2._write_reuse_snapshot(nonexistent, **_make_kwargs())
assert rv is None
captured = capsys.readouterr()
assert "reuse-snapshot" in captured.err
assert "WARN" in captured.err
# FileNotFoundError specifically — sanity-check the type surfaces in
# the warning so debugging is not blind.
assert "FileNotFoundError" in captured.err
# -- pipeline integration anchors -----------------------------------------
def test_pipeline_imports_helper_and_constant():
"""The pipeline module must expose the helper for the post-Step-6
call site, and the constant must round-trip from the snapshot
module (single source of truth)."""
assert hasattr(_pz2, "_write_reuse_snapshot")
assert callable(_pz2._write_reuse_snapshot)
assert _pz2.SNAPSHOT_FILENAME == "_reuse_snapshot.json"
def test_pipeline_call_site_follows_step06_artifact_write():
"""Structural guard: the helper must be invoked AFTER the Step 6
artifact write in ``run_phase_z2_mvp1`` so the sidecar lands next
to ``steps/step06_composition_plan.json`` (Stage 2 spec)."""
source = Path(_pz2.__file__).read_text(encoding="utf-8")
# Locate the step06 artifact write call site by its locked name arg.
step06_marker = '6, "composition_plan"'
idx_step06 = source.find(step06_marker)
assert idx_step06 != -1, "step06 artifact write call site missing"
# The helper call must appear AFTER the step06 marker.
idx_helper = source.find("_write_reuse_snapshot(", idx_step06)
assert idx_helper != -1, "u3 helper call missing after step06 write"
def test_pipeline_step06_artifact_data_records_snapshot_path():
"""Structural guard: the Step 6 artifact data dict must include the
``reuse_snapshot_path`` field so a future ``--reuse-from`` consumer
can locate the expected sidecar via the canonical step artifact
(Stage 2 spec — informational; absence of the file is u4's
fail-closed concern)."""
source = Path(_pz2.__file__).read_text(encoding="utf-8")
step06_marker = '6, "composition_plan"'
idx_step06 = source.find(step06_marker)
assert idx_step06 != -1
# Search a generous window after the marker for the field key.
window = source[idx_step06 : idx_step06 + 8000]
assert '"reuse_snapshot_path"' in window
assert "SNAPSHOT_FILENAME" in window
+101
View File
@@ -0,0 +1,101 @@
"""IMP-45 (#74) u6 — subprocess smoke for the slide-level CSS override axis.
End-to-end guard that the ``slide_overrides.css`` frontmatter axis added
in u2 propagates through the pipeline (u4) and lands in ``final.html``
via :func:`src.slide_css_injector.inject_slide_css` (u3).
The fixture is the new ``slide_overrides.css`` frontmatter block in
``samples/mdx_batch/04.mdx`` (u6 migration of the legacy frontend-only
``MDX04_DEFAULT_OVERRIDE_CSS`` constant). The pipeline is spawned via
``python -m src.phase_z2_pipeline 04.mdx <run_id>`` so the assertion
applies to the on-disk artifact CI / CLI / regression all observe, not
to a live iframe view.
The subprocess returncode is intentionally NOT asserted: mdx04 has known
downstream issues (see ``test_pipeline_smoke_imp85.py`` —
``test_mdx04_no_longer_emits_imp85_crash_signature``) that are tracked
on a separate axis. Step 13 runs before the downstream failure surface,
so ``final.html`` is written with the injected slide CSS marker
regardless. The test asserts ``final.html`` exists and contains both
the IMP-45 sentinel marker and a distinctive CSS substring from the
migrated frontmatter block.
"""
from __future__ import annotations
import subprocess
import sys
import uuid
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SAMPLES_DIR = REPO_ROOT / "samples" / "mdx_batch"
RUNS_DIR = REPO_ROOT / "data" / "runs"
# IMP-45 (#74) u3 marker sentinel emitted by
# :func:`src.slide_css_injector.inject_slide_css` around the injected
# ``<style>`` block. Match the ``_IMP45_STYLE_MARKER_OPEN`` constant in
# ``src/slide_css_injector.py`` byte-for-byte.
IMP45_OPEN_MARKER = "<!--IMP45-SLIDE-CSS:OPEN-->"
IMP45_CLOSE_MARKER = "<!--IMP45-SLIDE-CSS:CLOSE-->"
# Distinctive substring from the migrated frontmatter block in
# ``samples/mdx_batch/04.mdx``. ``f29b__cell:nth-child(n+3)`` is unique
# to the MDX04 slide-level override CSS and does not appear elsewhere in
# the slide_base / partial templates, so its presence in ``final.html``
# is sufficient evidence that the frontmatter axis fed the injector.
MDX04_DISTINCTIVE_CSS_SUBSTRING = ".f29b__cell:nth-child(n+3)"
def _run_pipeline(mdx_name: str, run_id: str, timeout: int = 240) -> subprocess.CompletedProcess:
"""Spawn ``python -m src.phase_z2_pipeline <mdx> <run_id>``."""
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}_imp45_slide_css_smoke_{uuid.uuid4().hex[:8]}"
def test_mdx04_slide_overrides_css_lands_in_final_html() -> None:
"""mdx04 ``slide_overrides.css`` frontmatter must reach ``final.html``.
Contract pins both the IMP-45 marker sentinel and a distinctive CSS
substring from the migrated frontmatter block so a regression in
either the frontmatter extractor (u2), the kwarg forwarding (u4),
or the injector (u3) is caught by this single smoke.
"""
run_id = _unique_run_id("mdx04")
cp = _run_pipeline("04.mdx", run_id)
final_html_path = RUNS_DIR / run_id / "phase_z2" / "final.html"
assert final_html_path.is_file(), (
f"final.html not found at {final_html_path}\n"
f"--- stderr tail ---\n{cp.stderr[-1500:]}\n"
f"--- stdout tail ---\n{cp.stdout[-1500:]}"
)
html = final_html_path.read_text(encoding="utf-8")
assert IMP45_OPEN_MARKER in html, (
f"IMP-45 open marker missing from mdx04 final.html ({final_html_path}).\n"
f"slide_overrides.css frontmatter axis did not reach the injector."
)
assert IMP45_CLOSE_MARKER in html, (
f"IMP-45 close marker missing from mdx04 final.html ({final_html_path}).\n"
f"Marker wrap appears unbalanced — check inject_slide_css() output."
)
assert MDX04_DISTINCTIVE_CSS_SUBSTRING in html, (
f"Migrated frontmatter CSS substring "
f"{MDX04_DISTINCTIVE_CSS_SUBSTRING!r} missing from mdx04 final.html "
f"({final_html_path}). The slide_overrides.css block did not propagate."
)
+216
View File
@@ -0,0 +1,216 @@
"""IMP-56 (#90) u7 — Step 12 ``structure_overrides`` apply unit tests.
Synthetic — exercises ``_apply_structure_overrides_to_zones`` directly
without running the full Phase Z 22-step pipeline. The helper is
decoupled from ``MdxSection`` / ``CompositionUnit`` graphs and only
consumes a minimal ``[{position, slot_payload}, ...]`` zone list, so a
synthetic fixture is sufficient to lock the contract.
Coverage axes (Stage 2 plan u7 + Stage 1 binding contract) :
- reorder happy path : ``slot_order`` partial reorder mutates
``zone['slot_payload']`` key order in place (caller reference stays
valid via clear+update rebuild contract documented at u6).
- hide happy path : ``hidden_slots`` pops the listed keys.
- stale slot_key : absent slot_keys silently no-op (count toward
``skipped_zones`` if the whole override produces no mutation).
- SCOPE LOCK : frame-swap-shaped inner keys (``frame_id``,
``template_id``) are dropped by the u6 validate gate and therefore
never reach the apply path here.
- raw_content preservation : per-slot ``list[str]`` line content
untouched; out-of-band sentinels (mirror of ``debug_zones`` graph)
stay byte-identical.
- audit shape : ``applied_zones`` / ``skipped_zones`` / ``per_zone``
keys present and counts consistent with the input batch.
- empty / ``None`` batch is a no-op (empty audit).
Fully synthetic per Codex generalization guardrail (MOCK_ prefix).
NO real catalog template_id / frame_id, NO ``v4_full32_result.yaml``
dependency.
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import _apply_structure_overrides_to_zones
# ─── Synthetic fixture helpers ──────────────────────────────────────
def _zone(position: str, slot_payload: dict) -> dict:
"""Minimal zone dict mirroring the Step 12 ``zones_data[i]`` shape."""
return {
"position": position,
"template_id": "MOCK_T_phase_z2_structure_overrides",
"slot_payload": slot_payload,
"content_weight": 1.0,
"min_height_px": 200,
}
# ─── Case 1 : reorder happy path ──────────────────────────────────────
def test_apply_reorders_slot_payload_keys_in_place():
payload = {"slot_a": ["A"], "slot_b": ["B"], "slot_c": ["C"]}
zones = [_zone("top", payload)]
overrides = {"top": {"slot_order": ["slot_c", "slot_a"]}}
audit = _apply_structure_overrides_to_zones(overrides, zones)
# Caller reference (payload) stays valid via clear+update rebuild.
assert zones[0]["slot_payload"] is payload
assert list(payload.keys()) == ["slot_c", "slot_a", "slot_b"]
# Per-slot list[str] content untouched (raw_content invariant).
assert payload["slot_a"] == ["A"]
assert payload["slot_b"] == ["B"]
assert payload["slot_c"] == ["C"]
assert audit["applied_zones"] == 1
assert audit["skipped_zones"] == 0
assert audit["per_zone"] == [{"position": "top", "mutated": True}]
# ─── Case 2 : hide happy path ─────────────────────────────────────────
def test_apply_hides_listed_slot_keys():
payload = {"slot_a": ["A"], "slot_b": ["B"], "slot_c": ["C"]}
zones = [_zone("top", payload)]
overrides = {"top": {"hidden_slots": ["slot_b"]}}
audit = _apply_structure_overrides_to_zones(overrides, zones)
assert "slot_b" not in payload
assert list(payload.keys()) == ["slot_a", "slot_c"]
assert audit["applied_zones"] == 1
assert audit["per_zone"] == [{"position": "top", "mutated": True}]
# ─── Case 3 : stale slot_key — frame swap / layout regression ─────────
def test_stale_slot_key_silently_no_op():
"""Absent slot_keys produce no mutation; the zone counts toward skipped_zones."""
payload = {"slot_a": ["A"]}
zones = [_zone("top", payload)]
overrides = {
"top": {
"hidden_slots": ["slot_missing"], # absent — no-op
"slot_order": ["slot_also_missing"], # absent — no-op
},
}
audit = _apply_structure_overrides_to_zones(overrides, zones)
assert list(payload.keys()) == ["slot_a"]
assert audit["applied_zones"] == 0
assert audit["skipped_zones"] == 1
assert audit["per_zone"] == [{"position": "top", "mutated": False}]
# ─── Case 4 : SCOPE LOCK — frame swap shape dropped at validate ───────
def test_frame_swap_keys_dropped_at_validate_no_mutation():
payload = {"slot_a": ["A"], "slot_b": ["B"]}
zones = [_zone("top", payload)]
# frame_id / template_id / slot_payload as inner keys are the canonical
# frame-swap / DOM-rebuild shapes the SCOPE LOCK rejects.
overrides = {
"top": {
"frame_id": "MOCK_OTHER_FRAME",
"template_id": "MOCK_OTHER_TEMPLATE",
"slot_payload": {"slot_a": ["overwritten"]},
},
}
audit = _apply_structure_overrides_to_zones(overrides, zones)
# No mutation: validate gate drops the whole zone payload (no allowed
# inner key remains), so the zone never reaches the apply loop.
assert payload == {"slot_a": ["A"], "slot_b": ["B"]}
assert audit["applied_zones"] == 0
assert audit["skipped_zones"] == 0
assert audit["per_zone"] == []
# ─── Case 5 : raw_content preservation invariant ──────────────────────
def test_raw_content_sentinel_untouched():
"""Helper must not mutate anything outside zone['slot_payload']
AND must not mutate per-slot list[str] line content."""
raw_sentinel = ["MOCK_S1", "MOCK_S2"]
payload = {"slot_a": ["line 1", "line 2"], "slot_b": ["line 3"]}
zones = [_zone("top", payload)]
zones[0]["source_section_ids_sentinel"] = raw_sentinel # out-of-band
zones[0]["raw_content_sentinel"] = "- line 1\n- line 2\n"
_apply_structure_overrides_to_zones(
{"top": {"slot_order": ["slot_b", "slot_a"]}}, zones,
)
# Out-of-band fields untouched.
assert zones[0]["source_section_ids_sentinel"] is raw_sentinel
assert zones[0]["source_section_ids_sentinel"] == ["MOCK_S1", "MOCK_S2"]
assert zones[0]["raw_content_sentinel"] == "- line 1\n- line 2\n"
# Per-slot list[str] line content byte-identical.
assert payload["slot_a"] == ["line 1", "line 2"]
assert payload["slot_b"] == ["line 3"]
# ─── Case 6 : empty / None / irrelevant batch is no-op ────────────────
@pytest.mark.parametrize(
"batch",
[
None,
{},
{"top": {}}, # empty per-zone
{"missing_zone": {"slot_order": ["slot_a"]}}, # stale zone_id
],
)
def test_empty_or_irrelevant_batch_is_noop(batch):
payload = {"slot_a": ["A"]}
zones = [_zone("top", payload)]
audit = _apply_structure_overrides_to_zones(batch, zones)
assert list(payload.keys()) == ["slot_a"]
assert audit["applied_zones"] == 0
assert audit["skipped_zones"] == 0
assert audit["per_zone"] == []
# ─── Case 7 : zone without slot_payload skipped (defensive) ───────────
def test_zone_without_slot_payload_skipped():
zones = [{"position": "top"}] # no slot_payload key (defensive contract)
audit = _apply_structure_overrides_to_zones(
{"top": {"slot_order": ["slot_a"]}}, zones,
)
assert audit["per_zone"] == []
assert audit["applied_zones"] == 0
assert audit["skipped_zones"] == 0
# ─── Case 8 : combined reorder + hide in a single zone ────────────────
def test_combined_reorder_and_hide_in_one_zone():
payload = {"slot_a": ["A"], "slot_b": ["B"], "slot_c": ["C"]}
zones = [_zone("top", payload)]
overrides = {
"top": {
"hidden_slots": ["slot_b"],
"slot_order": ["slot_c", "slot_a"],
},
}
audit = _apply_structure_overrides_to_zones(overrides, zones)
assert list(payload.keys()) == ["slot_c", "slot_a"]
assert audit["applied_zones"] == 1
assert audit["per_zone"] == [{"position": "top", "mutated": True}]
+158
View File
@@ -0,0 +1,158 @@
"""IMP-56 (#90) u5 — Step 12 ``text_overrides`` apply unit tests.
Synthetic — exercises ``_apply_text_overrides_to_zones`` directly without
running the full Phase Z 22-step pipeline. The helper is decoupled from
``MdxSection`` / ``CompositionUnit`` graphs and only consumes a minimal
``[{position, slot_payload}, ...]`` zone list, so a synthetic fixture is
sufficient to lock the contract.
Coverage axes (Stage 2 plan u5 + Stage 1 binding contract) :
- sanitized batch : malformed text_path / non-string value drops per-row
(mirrors ``image_id_stamper.build_image_overrides_style`` u7 tolerance).
- stale path : frame swap / layout regression → ``skipped``, not error.
- raw_content preservation : helper never touches ``debug_zones`` / unit
graph (asserted by zero-mutation on an out-of-band sentinel mapping).
- audit shape : ``applied`` / ``skipped`` / ``per_zone`` keys present and
counts consistent with the input batch.
- empty / ``None`` override input is a no-op (empty audit).
Fully synthetic per Codex generalization guardrail (MOCK_ prefix).
NO real catalog template_id / frame_id, NO ``v4_full32_result.yaml``
dependency.
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import _apply_text_overrides_to_zones
# ─── Synthetic fixture helpers ──────────────────────────────────────
def _zone(position: str, slot_payload: dict) -> dict:
"""Minimal zone dict mirroring the Step 12 ``zones_data[i]`` shape."""
return {
"position": position,
"template_id": "MOCK_T_phase_z2_text_overrides",
"slot_payload": slot_payload,
"content_weight": 1.0,
"min_height_px": 200,
}
# ─── Case 1 : happy path — list[str] slot mutation ────────────────────
def test_apply_replaces_list_line_in_place():
zones = [
_zone("top", {"slot_title": ["original headline"], "slot_body": ["a", "b", "c"]}),
_zone("bottom", {"slot_caption": ["caption A"]}),
]
overrides = {
"top": {"slot_title.0": "edited headline", "slot_body.1": "edited B"},
}
audit = _apply_text_overrides_to_zones(overrides, zones)
assert zones[0]["slot_payload"]["slot_title"] == ["edited headline"]
assert zones[0]["slot_payload"]["slot_body"] == ["a", "edited B", "c"]
# bottom zone untouched (no override entry)
assert zones[1]["slot_payload"]["slot_caption"] == ["caption A"]
assert audit["applied"] == 2
assert audit["skipped"] == 0
assert audit["per_zone"] == [{"position": "top", "applied": 2, "skipped": 0}]
# ─── Case 2 : stale text_path — frame swap / layout regression ────────
def test_stale_text_path_skipped_silently():
"""Absent slot_key + out-of-range line_index both count as skipped, not errors."""
zones = [_zone("top", {"slot_title": ["only one line"]})]
overrides = {
"top": {
"slot_title.0": "ok", # applied
"slot_title.99": "out of range", # skipped (idx > len)
"slot_missing.0": "stale frame", # skipped (slot absent)
},
}
audit = _apply_text_overrides_to_zones(overrides, zones)
assert zones[0]["slot_payload"]["slot_title"] == ["ok"]
assert "slot_missing" not in zones[0]["slot_payload"]
assert audit["applied"] == 1
assert audit["skipped"] == 2
assert audit["per_zone"][0] == {"position": "top", "applied": 1, "skipped": 2}
# ─── Case 3 : malformed input — per-entry tolerance ────────────────────
def test_malformed_entries_dropped_in_validate():
"""Non-string value / malformed text_path drop in ``validate_text_overrides``."""
zones = [_zone("top", {"slot_title": ["original", "second"]})]
overrides = {
"top": {
"slot_title.0": "good", # kept
"slot_title.bad": "ignored", # dropped (non-int idx)
"slot_title.1": 123, # dropped (non-str value)
"no_dot": "ignored", # dropped (missing '.')
},
"": {"slot_title.0": "empty zone id dropped"}, # zone_id sanitization
123: {"slot_title.0": "non-string zone id dropped"},
}
audit = _apply_text_overrides_to_zones(overrides, zones)
# only the well-formed entry applied
assert zones[0]["slot_payload"]["slot_title"] == ["good", "second"]
assert audit["applied"] == 1
assert audit["skipped"] == 0
# ─── Case 4 : raw_content preservation invariant ──────────────────────
def test_raw_content_sentinel_untouched():
"""Helper must not mutate anything outside ``zone['slot_payload']``.
Out-of-band fields (mirror of ``debug_zones[i].source_section_ids`` /
MdxSection graph) stay byte-identical — Stage 1 binding contract.
"""
raw_sentinel = ["MOCK_S1", "MOCK_S2"]
zones = [_zone("top", {"slot_title": ["original"]})]
zones[0]["source_section_ids_sentinel"] = raw_sentinel # out-of-band
zones[0]["raw_content_sentinel"] = "- original bullet\n- second\n"
_apply_text_overrides_to_zones({"top": {"slot_title.0": "edited"}}, zones)
assert zones[0]["source_section_ids_sentinel"] is raw_sentinel # same object
assert zones[0]["source_section_ids_sentinel"] == ["MOCK_S1", "MOCK_S2"]
assert zones[0]["raw_content_sentinel"] == "- original bullet\n- second\n"
# ─── Case 5 : empty / None batch is no-op ──────────────────────────────
@pytest.mark.parametrize("payload", [None, {}, {"top": {}}, {"missing_zone": {"slot.0": "x"}}])
def test_empty_or_irrelevant_batch_is_noop(payload):
zones = [_zone("top", {"slot_title": ["unchanged"]})]
audit = _apply_text_overrides_to_zones(payload, zones)
assert zones[0]["slot_payload"]["slot_title"] == ["unchanged"]
assert audit["applied"] == 0
assert audit["skipped"] == 0
# ─── Case 6 : zone without slot_payload skipped (defensive) ───────────
def test_zone_without_slot_payload_skipped():
zones = [{"position": "top"}] # no slot_payload key (defensive contract)
audit = _apply_text_overrides_to_zones({"top": {"slot.0": "x"}}, zones)
assert audit["per_zone"] == []
assert audit["applied"] == 0
assert audit["skipped"] == 0
+212
View File
@@ -0,0 +1,212 @@
"""IMP-56 (#90) u9 — Step 13 ``text_path_stamper`` wiring tests.
Verifies that :func:`src.phase_z2_pipeline.render_slide` stamps each
rendered ``text-line`` opening tag with
``data-text-path="{slot_key}.{line_index}"`` via the u8 stamper
(``src.text_path_stamper.stamp_zone_html``). This is the wiring unit
companion to the u8 module-level tests at
``tests/test_text_path_stamper.py``.
Coverage axes (Stage 2 plan u9 + Stage 1 binding contract) :
- happy path : real ``bim_current_problems_paired`` template emits
``text-line`` divs for list-valued slots; the stamper attaches
``data-text-path`` with matching ``{slot_key}.{line_index}``.
- non-list slots skipped : ``title`` / ``row_*_left_label`` (scalars)
do NOT receive ``data-text-path`` attributes.
- empty list slots emit no stamps : rows whose body list is empty
contribute zero stamps.
- deterministic : repeated calls produce byte-identical HTML
(no nondeterministic mutation of ``slot_payload`` between renders).
- empty zone : the ``__empty__`` template_id short-circuit emits no
``data-text-path`` (the stamper short-circuits on empty stamps).
Fully synthetic slot_payload — no real Phase Z run, no
``v4_full32_result.yaml`` dependency. Uses the real
``bim_current_problems_paired`` family template only to exercise the
genuine Jinja2 + slide_base render path.
"""
from __future__ import annotations
import re
import pytest
from src.phase_z2_pipeline import render_slide
# ─── Fixture helpers ────────────────────────────────────────────────
def _layout_css() -> dict:
"""Minimal valid layout_css for a single-zone slide.
Mirrors tests/phase_z2/test_slide_base_embedded_mode.py shape.
"""
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
def _paired_slot_payload(
*,
left_lines: list[str] | None = None,
right_lines: list[str] | None = None,
) -> dict:
"""Build a slot_payload for the bim_current_problems_paired family.
Only row_1 is populated by default; rows 2-4 stay empty so the
template short-circuits to zero text-line divs for them.
"""
left_lines = left_lines if left_lines is not None else ["L1a", "L1b"]
right_lines = right_lines if right_lines is not None else ["R1a"]
payload: dict = {
"title": "Synthetic Title",
"row_1_left_label": "left pill 1",
"row_1_left_body": [{"text": t, "indent": 0} for t in left_lines],
"row_1_right_label": "right pill 1",
"row_1_right_body": [{"text": t, "indent": 0} for t in right_lines],
}
for r in (2, 3, 4):
payload[f"row_{r}_left_label"] = f"left pill {r}"
payload[f"row_{r}_left_body"] = []
payload[f"row_{r}_right_label"] = f"right pill {r}"
payload[f"row_{r}_right_body"] = []
return payload
def _zone(template_id: str, slot_payload: dict) -> dict:
return {
"position": "primary",
"template_id": template_id,
"slot_payload": slot_payload,
}
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,
embedded_mode="embedded",
)
# ─── Case 1 : happy path — list-valued slots stamped ─────────────────
def test_render_slide_stamps_text_path_per_line():
"""Each list-valued slot line gets data-text-path={slot}.{index}."""
payload = _paired_slot_payload(
left_lines=["left line A", "left line B"],
right_lines=["right line A"],
)
html = _render([_zone("bim_current_problems_paired", payload)])
# left body 2 lines + right body 1 line = 3 stamps in row 1.
assert 'data-text-path="row_1_left_body.0"' in html
assert 'data-text-path="row_1_left_body.1"' in html
assert 'data-text-path="row_1_right_body.0"' in html
# row 2-4 are empty → no stamps for those slot_keys.
assert "row_2_left_body" not in html
assert "row_3_right_body" not in html
def test_stamps_preserve_class_attribute():
"""data-text-path injected before existing class attribute, both present."""
payload = _paired_slot_payload(left_lines=["only left"], right_lines=[])
html = _render([_zone("bim_current_problems_paired", payload)])
# The original class="text-line..." must survive verbatim alongside
# the injected data-text-path attribute on the same opening tag.
assert re.search(
r'<div\s+data-text-path="row_1_left_body\.0"\s+class="text-line[^"]*">',
html,
) is not None
# ─── Case 2 : non-list slots are NOT stamped ─────────────────────────
def test_non_list_slots_not_stamped():
"""Scalar slot values (title, *_label) get no data-text-path."""
payload = _paired_slot_payload(left_lines=["x"], right_lines=["y"])
html = _render([_zone("bim_current_problems_paired", payload)])
# Scalar slots present in slot_payload as strings — must not receive
# data-text-path stamps (u8 contract: scalar slots skipped silently
# because they render outside text-line divs).
assert 'data-text-path="title' not in html
assert 'data-text-path="row_1_left_label' not in html
assert 'data-text-path="row_1_right_label' not in html
# ─── Case 3 : empty list slots contribute no stamps ──────────────────
def test_empty_list_slots_no_stamps():
"""Empty list slot yields zero stamps; template emits zero text-line divs."""
payload = _paired_slot_payload(left_lines=[], right_lines=[])
html = _render([_zone("bim_current_problems_paired", payload)])
# No row 1 lines at all (both bodies empty) → no row_1_*_body stamps.
assert "data-text-path" not in html
# ─── Case 4 : deterministic — repeated render produces same HTML ─────
def test_render_with_stamp_is_deterministic():
"""Same slot_payload → byte-identical HTML across two render_slide calls.
Guards against the wiring layer accidentally mutating slot_payload
between renders (the stamper itself only reads slot_payload; it
operates on rendered_partial). Also guards against double-stamping.
"""
payload_1 = _paired_slot_payload()
payload_2 = _paired_slot_payload()
html_1 = _render([_zone("bim_current_problems_paired", payload_1)])
html_2 = _render([_zone("bim_current_problems_paired", payload_2)])
assert html_1 == html_2
# Counts must match — no double-stamp side effect on shared module state.
assert html_1.count("data-text-path=") == html_2.count("data-text-path=")
# 2 left + 1 right = 3 stamps for the default fixture.
assert html_1.count("data-text-path=") == 3
# ─── Case 5 : __empty__ short-circuit emits no stamps ────────────────
def test_empty_template_short_circuit_no_stamps():
"""``template_id=__empty__`` short-circuits before stamping; no stamps."""
html = _render([_zone("__empty__", {})])
assert "data-text-path" not in html
# ─── Case 6 : slot_payload preserved (raw_content invariant) ─────────
def test_render_does_not_mutate_slot_payload():
"""Stamping must not mutate slot_payload list/dict contents.
The stamper operates on rendered_partial HTML; the source
slot_payload should be byte-identical before and after render_slide.
Locks the raw_content preservation invariant at the wiring layer.
"""
payload = _paired_slot_payload(
left_lines=["preserved A", "preserved B"],
right_lines=["preserved C"],
)
# Snapshot key list/dict identities and content.
snapshot_left = list(payload["row_1_left_body"])
snapshot_left_text = [item["text"] for item in snapshot_left]
snapshot_right_text = [item["text"] for item in payload["row_1_right_body"]]
_ = _render([_zone("bim_current_problems_paired", payload)])
assert [item["text"] for item in payload["row_1_left_body"]] == snapshot_left_text
assert [item["text"] for item in payload["row_1_right_body"]] == snapshot_right_text
# Scalar slots untouched too.
assert payload["title"] == "Synthetic Title"

Some files were not shown because too many files have changed in this diff Show More