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
109 changed files with 22577 additions and 1085 deletions
+1 -38
View File
@@ -400,44 +400,7 @@
"Read(//tmp/**)",
"Read(//d/tmp/**)",
"Bash(python run_mdx03_pipeline.py --phase-z2 --run-id mvp1.5b_test5)",
"Bash(python run_mdx03_pipeline.py --phase-z2 --run-id mvp1.5b_test7)",
"Bash(git worktree *)",
"Bash(python -m pytest -q tests/test_imp47b_step12_ai_wiring.py tests/test_imp47b_payload_apply.py tests/test_imp47b_cache_save_gate.py tests/test_imp47b_coverage_invariant.py tests/test_imp47b_end_to_end.py tests/test_imp47b_failure_surface.py tests/test_imp47b_mixed_reject_fill.py tests/test_imp47b_override_provisional.py)",
"Bash(python -m pytest -q tests/test_v4_fallback_policy_loader.py tests/test_dynamic_max_rank.py tests/test_phase_z2_max_rank_regression.py tests/test_catalog_invariant.py tests/test_phase_z2_v4_fallback.py tests/test_imp47b_step12_ai_wiring.py tests/test_imp47b_payload_apply.py tests/test_imp47b_cache_save_gate.py tests/test_imp47b_coverage_invariant.py tests/test_imp47b_end_to_end.py tests/test_imp47b_failure_surface.py tests/test_imp47b_mixed_reject_fill.py tests/test_imp47b_override_provisional.py tests/phase_z2_ai_fallback/test_cache.py tests/phase_z2_ai_fallback/test_step12.py tests/phase_z2_ai_fallback/test_ast_isolation.py tests/test_phase_z2_ai_fallback_config.py)",
"PowerShell($OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{Authorization = \"token $token\"}; $r = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/76/comments?limit=50&page=last\" -Headers $headers; $r | Sort-Object id | Where-Object { $_.id -gt 23469 } | ForEach-Object { Write-Output \"=== comment $\\($_.id\\) | $\\($_.created_at\\) ===\"; Write-Output $_.body; Write-Output \"\" })",
"PowerShell($OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{Authorization = \"token $token\"}; $r = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/76/comments?limit=50&page=last\" -Headers $headers; $r | Sort-Object id | Where-Object { $_.id -gt 23479 } | ForEach-Object { Write-Output \"=== comment $\\($_.id\\) | $\\($_.created_at\\) ===\"; Write-Output $_.body; Write-Output \"\" })",
"PowerShell($OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{Authorization = \"token $token\"}; $r = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/76/comments?limit=50&page=last\" -Headers $headers; $r | Sort-Object id | Where-Object { $_.id -gt 23538 } | ForEach-Object { Write-Output \"=== comment $\\($_.id\\) | $\\($_.created_at\\) ===\"; Write-Output $_.body; Write-Output \"\" })",
"Bash(python -m pytest -q tests/test_imp47b_cache_save_gate.py tests/test_imp47b_coverage_invariant.py tests/test_imp47b_end_to_end.py tests/test_imp47b_failure_surface.py tests/test_imp47b_mixed_reject_fill.py tests/test_imp47b_override_provisional.py tests/test_imp47b_payload_apply.py tests/test_imp47b_step12_ai_wiring.py tests/phase_z2_ai_fallback/ tests/test_phase_z2_ai_fallback_config.py tests/test_phase_z2_v4_fallback.py)",
"Bash(git commit -m ' *)",
"Bash(git rebase *)",
"Bash(python -m pytest tests/test_imp47b_*.py tests/phase_z2_ai_fallback/ tests/test_phase_z2_ai_fallback_config.py tests/test_phase_z2_v4_fallback.py -x --tb=short)",
"Bash(git checkout *)",
"PowerShell($OutputEncoding = [System.Text.Encoding]::UTF8; $token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $comments = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/76/comments?limit=10&sort=updated&order=desc\" -Headers $headers; $comments | Sort-Object -Property created_at -Descending | Select-Object -First 5 | ForEach-Object { Write-Output \\(\"=== id=\" + $_.id + \" user=\" + $_.user.login + \" created=\" + $_.created_at + \" ===\"\\); Write-Output $_.body; Write-Output \"\" })",
"PowerShell(cd \"D:\\\\ad-hoc\\\\kei\\\\design_agent_imp47b\\\\Front\"; if \\(-not \\(Test-Path \"node_modules\"\\)\\) { New-Item -ItemType SymbolicLink -Path \"node_modules\" -Target \"D:\\\\ad-hoc\\\\kei\\\\design_agent\\\\Front\\\\node_modules\" -ErrorAction Stop | Out-Null; Write-Output \"symlink created\" } else { Write-Output \"node_modules already exists\" })",
"PowerShell(cmd /c 'mklink /J \"D:\\\\ad-hoc\\\\kei\\\\design_agent_imp47b\\\\Front\\\\node_modules\" \"D:\\\\ad-hoc\\\\kei\\\\design_agent\\\\Front\\\\node_modules\"')",
"Bash(npx vitest *)",
"PowerShell($OutputEncoding = [System.Text.Encoding]::UTF8; $token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $resp = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/comments/23586\" -Headers $headers; Write-Output \\(\"user=\" + $resp.user.login + \" created=\" + $resp.created_at\\); Write-Output \"---BODY---\"; Write-Output $resp.body)",
"PowerShell($OutputEncoding = [System.Text.Encoding]::UTF8; $token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $ids = @\\(62, 64, 65, 66, 68, 69, 71, 72, 73, 74, 77, 78, 79, 80, 81\\); foreach \\($id in $ids\\) { try { $r = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/$id\" -Headers $headers; $body = $r.body; if \\($body.Length -gt 600\\) { $body = $body.Substring\\(0, 600\\) }; Write-Output \\(\"##### #$id [\" + $r.state + \"] \" + $r.title\\); Write-Output $body; Write-Output \"---\" } catch { Write-Output \\(\"#\" + $id + \" ERROR: \" + $_.Exception.Message\\) } })",
"Bash(python -c \"import json; d=json.load\\(open\\('data/runs/04__DX_______20260521160814/phase_z2/steps/step06_composition_plan.json'\\)\\); print\\('units:', len\\(d.get\\('units', []\\)\\)\\); print\\('keys:', list\\(d.keys\\(\\)\\)[:10]\\); print\\('abort_reason:', d.get\\('abort_reason'\\)\\); print\\('first unit:', json.dumps\\(d.get\\('units', [{}]\\)[0], ensure_ascii=False, indent=2\\)[:500] if d.get\\('units'\\) else 'NO UNITS'\\)\")",
"Bash(python -c \"import json; d=json.load\\(open\\('data/runs/04__DX_______20260521160814/phase_z2/steps/step06_composition_plan.json'\\)\\); data=d.get\\('data', {}\\); print\\('status:', d.get\\('step_status'\\)\\); print\\('data keys:', list\\(data.keys\\(\\)\\)[:15] if isinstance\\(data, dict\\) else type\\(data\\).__name__\\); print\\('units in data:', len\\(data.get\\('units', []\\)\\) if isinstance\\(data, dict\\) else 'N/A'\\); import sys; sys.stdout.write\\(json.dumps\\(data, ensure_ascii=False, indent=2\\)[:800]\\)\")",
"Bash(python -c \"import re; html=open\\('data/runs/05____________20260521160758/phase_z2/final.html'\\).read\\(\\); m=re.search\\(r'<div class=\\\\\"slide-body\\\\\"[^>]*>\\(.*?\\)</div>\\\\\\\\s*</div>\\\\\\\\s*</body>', html, re.DOTALL\\); body=m.group\\(1\\) if m else 'NO BODY MATCH'; print\\(f'slide-body length: {len\\(body\\)}'\\); print\\(body[:1500]\\)\")",
"Bash(timeout 90 python -m src.phase_z2_pipeline samples/mdx_batch/04.mdx --output-root data/runs_debug)",
"Bash(python -c \"from src.config import settings; print\\('ai_fallback_enabled:', settings.ai_fallback_enabled\\); print\\('ai_fallback_model:', settings.ai_fallback_model\\); print\\('api_key_set:', bool\\(settings.anthropic_api_key\\)\\)\")",
"Bash(python -c \"import os; import urllib.request as r; import urllib.error; import json; token=os.environ.get\\('GITEA_TOKEN'\\); req=r.Request\\('https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/77', headers={'Authorization':f'token {token}'}\\); data=json.loads\\(r.urlopen\\(req\\).read\\(\\)\\); print\\('state:', data['state'], 'closed_at:', data.get\\('closed_at'\\)\\)\")",
"Bash(python -c \"import os; import urllib.request as r; import json; token=os.environ.get\\('GITEA_TOKEN'\\); req=r.Request\\('https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/78', headers={'Authorization':f'token {token}'}\\); d=json.loads\\(r.urlopen\\(req\\).read\\(\\)\\); print\\('state:', d['state'], 'closed_at:', d.get\\('closed_at'\\)\\)\")",
"PowerShell($token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $headers = @{ \"Authorization\" = \"token $token\" }; $r = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/65\" -Headers $headers; Write-Output \\(\"state: \" + $r.state + \" closed_at: \" + $r.closed_at\\))",
"Bash(grep -n \"class=\\\\\"zone\\\\|class='zone\\\\|class=\\\\\\\\\\\\\\\\\\\\\"zone\" templates/phase_z2/blocks/*.html templates/blocks/*.html)",
"Bash(grep -rn \"\\\\.zone\\\\s*{\\\\|\\\\.zone\\\\s*,\\\\|\\\\\\\\bzone\\\\\\\\b\\\\\\\\s*{\" templates/styles/ templates/phase_z2/)",
"Bash(awk '/export function computeZonePositions|^function computeZonePositions/,/^}/' Front/client/src/services/designAgentApi.ts)",
"Bash(sed -n '460,520p' Front/client/src/components/SlideCanvas.tsx)",
"Bash(sed -n '525,585p' Front/client/src/components/SlideCanvas.tsx)",
"Bash(awk '/export function applyLayout|^function applyLayout/,/^}/' Front/client/src/utils/slidePlanUtils.ts)",
"Bash(python -c \"from src.config import settings; print\\('model:', settings.ai_fallback_model\\); print\\('enabled:', settings.ai_fallback_enabled\\); print\\('api_key set:', bool\\(settings.anthropic_api_key\\)\\); print\\('key prefix:', settings.anthropic_api_key[:15] if settings.anthropic_api_key else 'NONE'\\)\")",
"Bash(grep -n \"application_plan\\\\|step09_application_plan\\\\|appPlan\\\\|appUnits\\\\|units:.*\\\\\\\\[\" Front/client/src/services/designAgentApi.ts)",
"Bash(python -c \"from src.config import settings; print\\('ai_fallback_enabled:', settings.ai_fallback_enabled\\); print\\('model:', settings.ai_fallback_model\\); print\\('api_key:', bool\\(settings.anthropic_api_key\\)\\)\")",
"Bash(grep -B2 -A15 \"def.*summary\\\\|\\\\\"summary\\\\\":\\\\|'summary':\\\\|mdx.*summary\\\\|summary.*=.*\\\\\\\\[\" tests/matching/template_fit.py tests/matching/pipeline_*.py)",
"PowerShell($OutputEncoding = [System.Text.Encoding]::UTF8; $token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $h = @{ \"Authorization\" = \"token $token\" }; $cur = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/84\" -Headers $h; Write-Output \"=== #84 body 상단 \\(첫 1500 chars\\) ===\"; Write-Output $cur.body.Substring\\(0, [Math]::Min\\(1500, $cur.body.Length\\)\\))",
"PowerShell($OutputEncoding = [System.Text.Encoding]::UTF8; $token = [Environment]::GetEnvironmentVariable\\(\"GITEA_TOKEN\", \"User\"\\); $h = @{ \"Authorization\" = \"token $token\" }; $cur = Invoke-RestMethod -Uri \"https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/84\" -Headers $h; Write-Output \"=== '제거 2' 영역 \\(1500-2400 chars\\) ===\"; Write-Output $cur.body.Substring\\(1500, [Math]::Min\\(900, $cur.body.Length - 1500\\)\\))"
"Bash(python run_mdx03_pipeline.py --phase-z2 --run-id mvp1.5b_test7)"
],
"additionalDirectories": [
"d:\\ad-hoc\\kei\\design_agent\\templates\\blocks\\new",
@@ -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
+143 -42
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 { Button } from "@/components/ui/button";
import { toast } from "sonner";
import type { SlidePlan, UserSelection } from "../types/designAgent";
import { serializeSlidePlan } from "../utils/slidePlanUtils";
import type { SlidePlan } from "../types/designAgent";
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 {
slidePlan: SlidePlan | null;
userSelection: UserSelection;
runMeta: RunMeta | null;
uploadedFile: File | null;
isLoading: boolean;
onGenerate: () => void;
}
export default function BottomActions({
slidePlan,
userSelection,
runMeta,
uploadedFile,
isLoading,
onGenerate,
}: BottomActionsProps) {
const handleDownload = () => {
if (!slidePlan) {
toast.error("슬라이드 플랜이 없습니다. 먼저 생성하기를 눌러주세요.");
const [isConnecting, setIsConnecting] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const runReady = !!runMeta && !!slidePlan;
const handleExport = async () => {
if (!runMeta) {
toast.error("Run 산출물이 없습니다. 먼저 생성하기를 눌러주세요.");
return;
}
const json = serializeSlidePlan(slidePlan, userSelection);
console.log("[Download] SlidePlan JSON:", json);
// JSON 파일 다운로드
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `slide-plan-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
toast.success("SlidePlan JSON이 다운로드되었습니다.");
setIsExporting(true);
try {
const exportReq = buildExportRequest(runMeta.run_id);
const resp = await fetch(exportReq.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
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");
a.href = objectUrl;
a.download = buildDownloadFilename(runMeta.run_id);
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
toast.success(`standalone HTML 다운로드 — ${runMeta.run_id}.html`);
} catch (err) {
toast.error(`Export 네트워크 오류: ${(err as Error).message}`);
} finally {
setIsExporting(false);
}
};
const handleConnect = () => {
toast.info("연동하기 기능은 파이프라인 연결 후 활성화됩니다.");
const handleConnect = async () => {
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 (
<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-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>
{/* 생성하기 */}
<div className="flex items-center gap-3">
<Button
onClick={onGenerate}
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"
>
{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
variant="outline"
onClick={handleDownload}
disabled={!slidePlan || isLoading}
className="gap-2 min-w-[120px]"
onClick={handleExport}
disabled={!runReady || isExporting || isLoading}
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"
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
variant="outline"
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"
>
<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>
</div>
+15 -10
View File
@@ -20,6 +20,19 @@ interface FramePanelProps {
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({
slidePlan,
selectedZone,
@@ -49,17 +62,9 @@ export default function FramePanel({
const handleFrameSelect = React.useCallback(
(candidate: FrameCandidate) => {
const isReject = candidate.label === "reject";
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);
applyFrameSelection(candidate, onFrameSelect);
},
[currentFrameId, onFrameSelect],
[onFrameSelect],
);
if (!selectedZone) {
+301 -61
View File
@@ -28,7 +28,12 @@ import {
crossedDragThreshold,
type ImageDragDirection,
} from "./slideCanvasDragMath";
import type { ImageOverridesOverride } from "../services/userOverridesApi";
import type {
ImageOverridesOverride,
StructureOverridesOverride,
StructureOverridePerZone,
} from "../services/userOverridesApi";
import StructureEditOverlay from "./StructureEditOverlay";
interface SlideCanvasProps {
slidePlan: SlidePlan | null;
@@ -36,10 +41,6 @@ interface SlideCanvasProps {
userSelection: UserSelection;
/** Phase Z 가 만든 final.html URL (iframe 으로 표시). */
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). */
isPipelineRunning?: boolean;
/** Phase 2 : pending layout 모드 — final.html iframe 숨기고 빈 layout zone 만 표시. */
@@ -77,16 +78,116 @@ interface SlideCanvasProps {
imageId: string,
geometry: { x: number; y: number; w: number; h: number }
) => 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_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({
slidePlan,
userSelection,
finalHtmlUrl,
slideOverrideCss,
isPipelineRunning,
isPendingLayout,
pendingLayoutId,
@@ -98,6 +199,9 @@ export default function SlideCanvas({
onZoneResize,
imageOverrides,
onImageResize,
onTextEdit,
structureOverrides,
onStructureEdit,
}: SlideCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
@@ -140,7 +244,15 @@ export default function SlideCanvas({
// HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용.
// 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업.
// 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);
// 편집 모드 toggle 시 iframe contentDocument 에 글벗 패턴 적용 / 해제.
@@ -164,11 +276,22 @@ export default function SlideCanvas({
const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"];
let inputHandler: ((e: Event) => void) | null = null;
// IMP-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
// inside the iframe contentDocument. Tracked here so the cleanup
// callback can remove them when edit mode exits (or iframe reloads).
const imageClickBindings: Array<{ el: HTMLImageElement; handler: (e: Event) => void; prevCursor: string; prevOutline: string }> = [];
if (isEditMode) {
// 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.querySelectorAll(".slide *").forEach((el) => {
if (editableTags.includes((el as HTMLElement).tagName)) {
@@ -181,11 +304,28 @@ export default function SlideCanvas({
};
doc.addEventListener("input", inputHandler);
// IMP-51 (#79) u8 — wire click → selectedImageId on every stamped
// user-content image. Selector mirrors USER_CONTENT_IMAGE_SELECTOR
// in src/image_id_stamper.py (+ requires data-image-id which the
// stamper always emits). Decorative / frame imgs lacking the role
// attribute are intentionally NOT clickable here.
textEditCaptureHandler = (ev: Event) => {
const cap = deriveTextEditCapture(
ev.target as unknown as TextEditCaptureTarget | null
);
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>(
'.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 });
});
} 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);
}
@@ -218,19 +352,60 @@ export default function SlideCanvas({
if (inputHandler && doc) {
doc.removeEventListener("input", inputHandler);
}
if (textEditCaptureHandler && doc) {
doc.removeEventListener("focusout", textEditCaptureHandler);
}
imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => {
el.removeEventListener("click", handler);
el.style.cursor = prevCursor;
el.style.outline = prevOutline;
});
};
}, [isEditMode, finalHtmlUrl, onContentEdit]);
}, [editGates.textEditing, editGates.imageSelection, finalHtmlUrl, onContentEdit, onTextEdit]);
// pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지).
useEffect(() => {
if (isPendingLayout && isEditMode) setIsEditMode(false);
if (isPendingLayout && isEditMode) setEditMode("off");
}, [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.
// 새 iframe 의 onLoad 가 발화하면서 measuredZones 다시 채움.
useEffect(() => {
@@ -337,28 +512,50 @@ export default function SlideCanvas({
</button>
)}
{/* 편집 모드 toggle 버튼 — normal mode + final.html 있을 때만.
글벗 패턴 차용 — designMode + contentEditable. backend 반영은 별 작업. */}
{/* IMP-90 (#90) u11 — discriminated edit-mode toolbar.
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 && (
<button
onClick={(e) => {
e.stopPropagation();
setIsEditMode((p) => !p);
}}
className={`absolute top-2 right-2 z-30 text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
isEditMode
? "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"
}`}
<div
data-testid="edit-mode-toolbar"
className="absolute top-2 right-2 z-30 flex gap-1"
style={{ pointerEvents: "auto" }}
title={
isEditMode
? "편집 모드 — 텍스트 클릭하여 수정. 다시 클릭하여 종료. (변경은 frontend 만, backend 반영 미구현)"
: "텍스트 직접 편집 모드 진입"
}
>
{isEditMode ? "✏ 편집 중 (클릭 종료)" : "✏ 편집"}
</button>
{EDIT_MODES.map((mode) => {
const active = editMode === mode;
const label =
mode === "text" ? "✏ 텍스트" : mode === "structure" ? "▦ 구조" : "🖼 이미지/존";
const title =
mode === "text"
? "텍스트 편집 — 텍스트 클릭하여 직접 수정"
: mode === "structure"
? "구조 편집 — slot 순서 / 숨김 변경 (u14 펜딩)"
: "이미지/존 편집 — 이미지 드래그·리사이즈 + 존 리사이즈";
return (
<button
key={mode}
type="button"
data-testid={`edit-mode-${mode}`}
aria-pressed={active}
onClick={(e) => {
e.stopPropagation();
setEditMode((prev) => nextEditMode(prev, mode));
}}
className={`text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
active
? "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"
}`}
title={title}
>
{label}
</button>
);
})}
</div>
)}
<div
@@ -380,7 +577,13 @@ export default function SlideCanvas({
className="w-full h-full border-0 block"
scrolling="no"
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) => {
// IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend
// slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상
@@ -391,15 +594,6 @@ export default function SlideCanvas({
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
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 측정 ──
// backend final.html 의 .zone[data-zone-position="..."] 요소를
// 찾아서 boundingClientRect 측정 → 1280×720 기준 정규화.
@@ -578,9 +772,11 @@ export default function SlideCanvas({
const makeResizeHandler = (
direction: ResizeDir
) => (ev: React.MouseEvent<HTMLDivElement>) => {
// resize 는 pendingLayout OR 편집 모드 활성. 2026-05-22 demo hot-fix —
// frame partial 에 @container aspect-ratio 회전이 들어가서 fixed px 제약 사라짐.
if ((!isPendingLayout && !isEditMode) || !onZoneResize) return;
// resize 는 pendingLayout OR image-zone 편집 모드 활성. 2026-05-22
// demo hot-fix — frame partial 에 @container aspect-ratio 회전이
// 들어가서 fixed px 제약 사라짐. IMP-90 u12: text/structure 모드
// 에서는 zone resize 비활성 (mutually exclusive per editGates).
if ((!isPendingLayout && !editGates.zoneGestures) || !onZoneResize) return;
if (!measuredSlideBody) return;
ev.preventDefault();
ev.stopPropagation();
@@ -651,7 +847,10 @@ export default function SlideCanvas({
ev: React.MouseEvent<HTMLDivElement>
) => {
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 startMouseY = ev.clientY;
const startGeom = { ...localGeom };
@@ -870,12 +1069,14 @@ export default function SlideCanvas({
</div>
)}
{/* Step C : zone resize handles — 8 방향. pendingLayout OR 편집 모드 활성.
2026-05-22 demo hot-fix — frame partial 에 @container aspect-ratio 회전
들어간 후 fixed px 제약 사라져 편집 모드 resize 도 의미 있음.
{/* Step C : zone resize handles — 8 방향. pendingLayout OR image-zone
편집 모드 활성. 2026-05-22 demo hot-fix — frame partial 에 @container
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 이동
corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
{(isPendingLayout || isEditMode) && onZoneResize && (
{(isPendingLayout || editGates.zoneGestures) && onZoneResize && (
<>
{/* top edge */}
<div
@@ -955,8 +1156,10 @@ export default function SlideCanvas({
strips intercept the perimeter while the un-covered iframe
interior keeps text-edit reachability intact.
pendingLayout mode already has wrapper pointerEvents:auto,
so these surfaces are only needed in edit mode. */}
{isEditMode && !isPendingLayout && onZoneResize && (
so these surfaces are only needed in edit mode.
IMP-90 u12: image-zone-mode-only — text / structure 모드는
zone drag 안 함 (editGates.zoneGestures = false 두 모드 모두). */}
{editGates.zoneGestures && !isPendingLayout && onZoneResize && (
<>
<div
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 ──
Activates only in edit mode when an image_id appears in either
`imageOverrides` (u11-fed persisted axis) or `measuredImages`
@@ -1028,7 +1264,11 @@ export default function SlideCanvas({
`slideCanvasDragMath.ts` so the boundary contract Codex #16
verified is exercised directly by vitest (mirror of how IMP-54
u3 split the zone math out of SlideCanvas). */}
{!isPendingLayout && isEditMode && finalHtmlUrl && onImageResize &&
{/* 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(
([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>
);
}
+204 -62
View File
@@ -16,9 +16,12 @@ import {
moveSectionToZone,
saveZoneSizes,
saveImageOverride,
saveTextOverride,
saveStructureOverride,
deriveUserOverridesKey,
applyPersistedNonFrameOverrides,
remapPersistedFramesToZoneFrames,
validateZoneGeometriesAgainstLayout,
} from "../utils/slidePlanUtils";
import {
parseMdxFile,
@@ -40,8 +43,9 @@ import LeftMdxPanel from "../components/LeftMdxPanel";
import SlideCanvas from "../components/SlideCanvas";
import LayoutPanel from "../components/LayoutPanel";
import FramePanel from "../components/FramePanel";
import BottomActions from "../components/BottomActions";
import {
Sparkles, Download, Link2, Loader2,
Sparkles, Loader2,
CheckCircle2, HelpCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -154,6 +158,31 @@ export default function Home() {
}
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 {
...p,
userSelection: {
@@ -162,6 +191,18 @@ export default function Home() {
...p.userSelection.overrides,
layout_preset: layoutId,
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,
selectedRegionId: null,
@@ -176,10 +217,27 @@ export default function Home() {
// pending 모드 취소 → 평소 (final.html iframe) 모드 복귀.
const handleCancelPendingLayout = useCallback(() => {
setPendingLayout(null);
setState((p) => ({
...p,
userSelection: createInitialUserSelection(p.slidePlan),
}));
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,
// 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),
};
});
setHasPendingChanges(false);
}, []);
@@ -225,22 +283,6 @@ export default function Home() {
// 호출되는 단일 callback. handleFileUpload 가 자동 분석 trigger.
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") => {
try {
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 /
// 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;
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) 복귀.
// 시연 안정성 우선. section swap 은 별 path (수동 drag detection) 로 풀어야 함.
// 임시 over-aggressive fix 가 default flow 깨뜨려 PARTIAL_COVERAGE 발생했음.
const userZoneSections = state.userSelection.overrides.zone_sections;
if (userZoneSections) {
const defaultByZone = new Map<string, string[]>();
sourcePlan.zones.forEach((z) => {
defaultByZone.set(z.zone_id, z.section_ids);
});
const zoneSectionsDiff: Record<string, string[]> = {};
for (const [zoneId, sids] of Object.entries(userZoneSections)) {
if (!Array.isArray(sids)) continue;
const cleaned = sids.filter((s) => typeof s === "string" && s.trim());
const defaults = defaultByZone.get(zoneId) ?? [];
const sameAsDefault =
cleaned.length === defaults.length &&
cleaned.every((sid, i) => sid === defaults[i]);
if (!sameAsDefault) {
zoneSectionsDiff[zoneId] = cleaned;
// IMP-55 (#93) u7 — Replace the IMP-08 B-3 self-compare with the bool
// `manual_section_assignment` intent marker gate. The prior code built
// `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;
if (userZoneSections) {
const validZoneIds = new Set(
sourcePlan.zones.map((z) => z.zone_id),
);
const zoneSectionsForward: Record<string, string[]> = {};
for (const [zoneId, sids] of Object.entries(userZoneSections)) {
if (!validZoneIds.has(zoneId)) continue;
if (!Array.isArray(sids)) continue;
const cleaned = sids.filter(
(s) => typeof s === "string" && s.trim(),
);
zoneSectionsForward[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
// fallback could then load a stale persisted document.
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);
if (!result.success || !result.final_html_exists) {
@@ -450,7 +535,21 @@ export default function Home() {
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
setState((p) => {
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
// schema axis (`zone_sections`) shares the in-memory shape (zone_id →
// 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 —
// the demo-mode initial render path would otherwise PUT to the empty
// 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) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, {
zone_sections: finalSelection.overrides.zone_sections,
manual_section_assignment: true,
});
}
return { ...p, userSelection: finalSelection };
@@ -579,6 +683,51 @@ export default function Home() {
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.
// 그 외 = state.slidePlan. 모든 zone / region lookup 이 일관되게 이걸 사용 →
// pending mode 의 region.id ("pending-region-N") 가 zone_frames key 로 들어가
@@ -590,17 +739,6 @@ export default function Home() {
return state.slidePlan;
}, [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 선택 ──
const handleFrameSelect = useCallback((frameId: string) => {
const zone = getSelectedZone(effectiveSlidePlan, state.userSelection);
@@ -761,7 +899,6 @@ export default function Home() {
normalizedContent={state.normalizedContent}
userSelection={state.userSelection}
finalHtmlUrl={runMeta?.final_html_url}
slideOverrideCss={slideOverrideCss}
isPipelineRunning={state.isLoading}
isPendingLayout={!!pendingLayout}
pendingLayoutId={pendingLayout}
@@ -779,6 +916,9 @@ export default function Home() {
onZoneResize={handleZoneResize}
imageOverrides={state.userSelection.overrides.image_overrides}
onImageResize={handleImageResize}
onTextEdit={handleTextEdit}
structureOverrides={state.userSelection.overrides.structure_overrides}
onStructureEdit={handleStructureEdit}
/>
</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>
</div>
</div>
<div className="flex items-center gap-3">
<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>
<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>
<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>
</div>
<BottomActions
slidePlan={state.slidePlan}
runMeta={runMeta}
uploadedFile={state.uploadedFile}
isLoading={state.isLoading}
onGenerate={handleGenerate}
/>
</footer>
</div>
);
+79 -14
View File
@@ -345,13 +345,25 @@ export interface PipelineOverrides {
export async function runPipeline(
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> {
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", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, content, overrides }),
body: JSON.stringify(body),
});
const data = (await res.json()) as RunPipelineResult;
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.
// 모두 reject 인 경우 confidence desc 만 적용 (사용자 명시).
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> = {
use_as_is: 0,
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)
// 3) unit.v4_candidates (legacy minimal)
// 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 pushCandidate = (c: any) => {
if (!c) return;
@@ -586,15 +602,64 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
if (!key) return;
if (!candidateMap.has(key)) candidateMap.set(key, c);
};
candidateEvidence.forEach(pushCandidate);
(unit.v4_all_judgments ?? []).forEach(pushCandidate);
(unit.v4_candidates ?? []).forEach(pushCandidate);
const rawSource = Array.from(candidateMap.values());
const v4Source = [...rawSource].sort((a: any, b: any) => {
const lp = (LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99);
if (lp !== 0) return lp;
return (b.confidence ?? 0) - (a.confidence ?? 0);
});
// 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);
(unit.v4_all_judgments ?? []).forEach(pushCandidate);
(unit.v4_candidates ?? []).forEach(pushCandidate);
const rawSource = Array.from(candidateMap.values());
v4Source = [...rawSource].sort((a: any, b: any) => {
const lp =
(LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99);
if (lp !== 0) return lp;
return (b.confidence ?? 0) - (a.confidence ?? 0);
});
}
// ─── IMP-41 u4 — application_candidates enrichment (issue #70) ───────────
// Backend Step 9 emits `unit.application_candidates[]` (src/phase_z2_pipeline.py
// _application_candidates_for_unit, :3071-3092) one entry per v4 candidate with
@@ -65,6 +65,47 @@ export type 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. */
export interface UserOverrides {
layout: string;
@@ -72,6 +113,9 @@ export interface UserOverrides {
zone_geometries: ZoneGeometriesOverride;
zone_sections: ZoneSectionsOverride;
image_overrides: ImageOverridesOverride;
manual_section_assignment: ManualSectionAssignmentOverride;
text_overrides: TextOverridesOverride;
structure_overrides: StructureOverridesOverride;
}
/** 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
// the typed-client `ImageOverridesOverride` (services/userOverridesApi.ts u3).
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 { 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) ────
// These helpers compose persisted `user_overrides.json` payloads (typed by
@@ -84,9 +91,88 @@ export function applyPersistedNonFrameOverrides(
) {
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 };
}
// ─── 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
* `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
// are seeded on reopen via `applyPersistedNonFrameOverrides`.
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 {
return {
...selection,
@@ -320,3 +474,77 @@ export function getEffectiveLayoutId(slidePlan: SlidePlan | null, selection: Use
if (selection.overrides.layout_preset) return selection.overrides.layout_preset;
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
// ---------------------------------------------------------------------------
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", () => {
// The on-disk schema is shared with backend pipeline fallback (u2).
// Any drift here means a PUT could write an axis that the Python
// load() ignores, or vice-versa, silently losing user overrides.
// 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([
"layout",
"zone_geometries",
"zone_sections",
"frames",
"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)", () => {
@@ -35,6 +35,8 @@ import {
deriveUserOverridesKey,
remapPersistedFramesToZoneFrames,
saveImageOverride,
saveTextOverride,
saveStructureOverride,
} from "../src/utils/slidePlanUtils";
// ─── Fixtures ───────────────────────────────────────────────────────────────
@@ -54,6 +56,16 @@ function makeSelection(overrides?: Partial<UserSelection["overrides"]>): UserSel
// axis declared on `UserSelection.overrides`. Empty by default so the
// existing IMP-52 cases remain unchanged in shape.
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,
},
};
@@ -460,3 +472,235 @@ describe("image_overrides axis — saveImageOverride (IMP-51 u11)", () => {
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);
}
/**
* 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", () => {
it("handleSectionDrop persists zone_sections behind uploadedFile gate", () => {
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({});
});
});
// ─── 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_.\-]*$/;
// The five in-scope axes — exact mirror of KNOWN_AXES in
// src/user_overrides_io.py. Any payload key outside this allowlist is
// silently dropped by the PUT handler (u4) so the on-disk schema cannot
// drift from the backend pipeline (u2) contract. Foreign top-level keys
// already on disk are preserved verbatim (see mergeUserOverrides).
// The nine in-scope axes — full mirror of KNOWN_AXES in
// src/user_overrides_io.py. Order matches the Python tuple verbatim so
// a side-by-side audit reads as a no-op. Any payload key outside this
// allowlist is silently dropped by the PUT handler (u4) so the on-disk
// schema cannot drift from the backend pipeline (u2) contract. Foreign
// top-level keys already on disk are preserved verbatim (see
// mergeUserOverrides).
// IMP-51 (#79) u2: added `image_overrides` (image_id → {x,y,w,h}
// percent-of-slide coordinates).
// 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 = [
"layout",
"zone_geometries",
"zone_sections",
"frames",
"image_overrides",
"slide_css",
"manual_section_assignment",
"text_overrides",
"structure_overrides",
] as const;
export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number];
@@ -500,6 +517,211 @@ export function handlePutUserOverrides(
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 업로드 → 파이프라인 실행 → 결과 노출
//
@@ -508,14 +730,19 @@ export function handlePutUserOverrides(
// GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path}
// GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3)
// PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4)
// 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
// CEL_PROJECT_ROOT cel astro dev repo root. default = D:/ad-hoc/cel
// =============================================================================
function vitePluginPhaseZApi(): Plugin {
const DESIGN_AGENT_ROOT =
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 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.
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 {
payload = JSON.parse(body);
@@ -554,7 +788,7 @@ function vitePluginPhaseZApi(): Plugin {
return;
}
const { filename, content, overrides } = payload;
const { filename, content, overrides, reuseFromRunId } = payload;
if (!filename || typeof content !== "string") {
res.writeHead(400, { "Content-Type": "application/json" });
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(
`[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}`
);
@@ -775,6 +1022,27 @@ function vitePluginPhaseZApi(): Plugin {
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} ──
server.middlewares.use("/data/runs", (req, res, 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 5070% / 1020s → 38s 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 를 바꾸는 작업인지 식별
+134 -6
View File
@@ -1009,6 +1009,9 @@ def build_context_pack(n, title, body, sid, agent, rnd, start_cnt, compact=None)
# 검증 실패 보고서 (rewind 시 이전 실패 맥락 전달).
# 2026-05-16 — issue state 의 failure_report_path 를 source-of-truth 로.
# 모든 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 = ""
ist_fc = get_issue_state(n)
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)
if fail_path.exists():
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 = (
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"
)
@@ -1447,10 +1483,22 @@ def run_stage(n, title, body, sid):
return (False, "unit with `tests: []` (forbidden — implementation + tests = same unit)")
return (True, "")
ok, reason = _iu_valid(last)
if not ok:
# current stage 의 comments 만 검색 (start_cnt 이후)
# P7 (2026-05-26) — fallback skip when last YES body itself is invalid.
# 이전: 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:]:
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:
ok = True; break
if not ok:
@@ -1548,6 +1596,45 @@ def run_stage(n, title, body, sid):
except: pass
# 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)")
# stage 완료 = unit counter + remaining tracker 모두 reset
update_issue_state(n, continue_same_count=0, last_remaining_units=None)
@@ -1765,8 +1852,49 @@ def run_issue(n, until=None):
continue_same_count=0, last_remaining_units=None)
if s["id"] == "final-close":
try: gitea(f"issues/{n}", "PATCH", {"state": "closed"}); log("Closed")
except: pass
# 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")
except: pass
i += 1
+2
View File
@@ -22,6 +22,7 @@ dependencies = [
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
"pytest-json-report>=1.5",
"ruff>=0.8",
]
@@ -33,4 +34,5 @@ target-version = "py310"
asyncio_mode = "auto"
markers = [
"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 지연 요인
sidebar:
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에 대한 인식
+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 5070% / 1020s → 38s 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 5070% / 1020s → 38s "
"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]:
"""MDX 원본을 4-Layer 파서로 정규화.
@@ -405,11 +431,13 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
"popups": [{"title": str, "content": str}],
"tables": [{"headers": list, "rows": list}],
"sections": [{"level": int, "title": str, "content": str}],
"slide_overrides": {"css": str, ...} | {},
}
"""
# ── Layer 1: frontmatter 분리 ──
metadata, body = frontmatter.parse(raw_mdx)
title = metadata.get("title", "")
slide_overrides = _extract_slide_overrides(metadata)
logger.info(f"[Layer 1] title='{title}', metadata keys={list(metadata.keys())}")
# ── Layer 2: 코드블록 보호 → MDX 패턴 처리 ──
@@ -437,6 +465,7 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
"popups": popups,
"tables": tables,
"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)
↓ 그래도 안 되면
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)
↓ 그래도 안 되면
@@ -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
`src/phase_z2_router.py` (u3 surface), so this module advertises the cascade
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
@@ -85,6 +114,28 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
"'frame_reselect' AND passed=False AND post_salvage_overflow present. "
"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
# that routes this onto details_popup_escalation.
"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.
"frame_reselect_insufficient": "details_popup_escalation",
"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] = {
@@ -161,6 +232,22 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
"not_attempted": (
"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 들의 *현재 코드* 구현 상태
@@ -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
"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
"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",
# IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The
# 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
# surface.
"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",
}
+28 -4
View File
@@ -579,12 +579,23 @@ def _build_compare_table_2col(section, units, contract) -> dict:
builder_options :
item_parser : ITEM_PARSERS key (예: `compare_row_2col_item`)
col_a_label_default : col_a header (MDX 미명시 시 fallback. F1-a fix)
col_b_label_default : col_b header (MDX 미명시 시 fallback)
col_a_label_default : col_a header literal in catalog.
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 (Codex round 43 §F1-b — narrow alias).
예 : ["BIM", "DX"]. default [] (no stripping).
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"]
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."
)
col_a_label = options.get("col_a_label_default", "")
col_b_label = options.get("col_b_label_default", "")
def _resolve_label_default(col_key: str) -> str:
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 []
max_rows = options.get("max_rows", 999)
+2609 -729
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 (f'[data-zone-position="{zone_position}"] {{\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) ──
# 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] = {
"minor_overflow": "zone_ratio_retry",
"moderate_overflow": "layout_adjust",
"structural_minor_overflow": "zone_ratio_retry",
"structural_major_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",
"hard_visual_fail": "abort",
}
@@ -48,8 +62,13 @@ ACTION_RATIONALE: dict[str, str] = {
"1+ structural unit 완전 잘림 → 의미 손실, popup 으로 escalate",
"tabular_overflow":
"표는 행 단위로 잘리면 의미 손실 → 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":
"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":
"frame root 자체 overflow → layout preset 변경 또는 zone 키움",
"hard_visual_fail":
@@ -61,7 +80,21 @@ ACTION_RATIONALE: dict[str, str] = {
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
"layout_adjust": "MISSING",
# 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.
# `plan_details_popup_escalation` (below) provides the deterministic stub
# 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)
+40 -10
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
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>,
"zone_geometries": {<zone_id>: {"x": float, "y": float, "w": float, "h": float}},
"zone_sections": {<zone_id>: [<section_id>, ...]},
"frames": {<unit_id>: <template_id>},
"image_overrides": {<image_id>: {"x": float, "y": float, "w": float, "h": float}}
"layout": <string|null>,
"zone_geometries": {<zone_id>: {"x": float, "y": float, "w": float, "h": float}},
"zone_sections": {<zone_id>: [<section_id>, ...]},
"frames": {<unit_id>: <template_id>},
"image_overrides": {<image_id>: {"x": float, "y": float, "w": float, "h": float}},
"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
@@ -53,16 +68,31 @@ from typing import Any, Optional
_PKG_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
# The five in-scope axes (IMP-51 #79 u1 added ``image_overrides``). Any
# other top-level key in the file is preserved but ignored by callers —
# keeps the file forward-compatible with future axes (e.g., zone_sizes)
# without a schema bump here.
# The nine in-scope axes (IMP-51 #79 u1 added ``image_overrides``; IMP-45
# #74 u1 added ``slide_css``; IMP-55 #93 u1 added
# ``manual_section_assignment`` — bool intent marker that gates whether
# 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, ...] = (
"layout",
"zone_geometries",
"zone_sections",
"frames",
"image_overrides",
"slide_css",
"manual_section_assignment",
"text_overrides",
"structure_overrides",
)
# Key validation — MDX stem must be safe for filesystem use. Allow
@@ -474,7 +474,9 @@ bim_dx_comparison_table:
builder_options:
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_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_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
- "BIM"
- "DX"
@@ -1785,8 +1787,11 @@ industry_current_status_three_col:
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.
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_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_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).
@@ -1847,6 +1852,9 @@ industry_characteristics_three_col:
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.
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_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_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).
@@ -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;
}
/* ── IMP-30 u5 : provisional zone marker (first-render invariant) ──
When V4 rank-1 candidate falls outside MVP1_ALLOWED_STATUSES (chain_exhausted)
the pipeline still renders the rank-1 frame so the first-render invariant
holds, but the zone is tagged `provisional` so the user/AI can adapt later
(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);
}
/* IMP-84: provisional zone visual treatment removed (silent-automation
policy). `data-provisional="1"` attribute is still emitted on the
zone div as silent telemetry for downstream selectors / inspection;
no user-visible outline, wash, or badge. */
/* ── Frame-family text layout contract (shared, reusable) ──
feedback-1 (mvp1.5b_test7): visible improvement 강화.
@@ -355,6 +323,38 @@
line-height: 1.5;
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>
</head>
<body>
@@ -366,8 +366,7 @@
<div class="slide-body">
<div class="layout-{{ layout_preset }}">
{% for zone in zones %}
<div class="zone{% if zone.provisional %} zone--provisional{% endif %}" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}"{% if zone.provisional %} data-provisional="1"{% endif %}{% 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 %}
<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 }};">
{{ zone.partial_html | safe }}
{% if zone.has_popup %}
{% set _popup_trigger = (zone.popup_binding.detail_trigger if zone.popup_binding else None) or {} %}
@@ -389,5 +388,22 @@
</div>
{% endif %}
</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>
</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 len(result["heights_px"]) == 2 # R rows
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["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["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
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):
"""(c) All three cascade actions are infeasible (no CSS emitted by any
planner) run_overflow_check is never invoked, salvage_passed=False,
salvage_steps has three failed entries, and out_path is unchanged
(original final.html intact (b)-revert preserved)."""
"""(c) All cascade actions are infeasible (no CSS / no candidate emitted
by any planner) run_overflow_check is never invoked, salvage_passed=
False, out_path is unchanged (original final.html intact (b)-revert
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.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_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"]]
assert actions == [
"cross_zone_redistribute",
"glue_compression",
"font_step_compression",
"layout_adjust",
"frame_internal_fit_candidate",
]
for step in trace["salvage_steps"]:
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"]
# No CSS emitted anywhere → no render_slide calls either.
# No CSS / candidate emitted anywhere → no render_slide calls either.
assert render_counter["n"] == 0
# (b) revert: out_path is untouched.
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": {
"mdx_file": "04.mdx",
"run_id": "89a_baseline_04",
"final_html_size_bytes": 27707,
"sha256": "2bce45041cdcca6518cd92586c1be9e051a5c98f5a0ad61fdde02604618a1d80",
"final_html_size_bytes": 28042,
"sha256": "ddb6bf2f8d76ca1f56588a50dd4af5aeb5f45e0a83d5241b83b5932d0c66d41c",
"pipeline_exit_code": null
},
"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_section_assignments=None,
override_image_overrides=None,
override_slide_css=None,
reuse_from=None,
):
captured["mdx_path"] = mdx_path
captured["run_id"] = run_id
@@ -64,6 +66,8 @@ def _exec_main_block(
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)
@@ -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_zone_geometries"] 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 ───
def test_u5_provisional_zone_renders_class_and_badge():
"""Opt-in path. zones[i].provisional=True must:
1. Append `zone--provisional` class to the zone div.
2. Set `data-provisional="1"` data attribute (for downstream selectors).
3. Render a `<span class="zone__needs-adaptation-badge">` element with
the literal text "needs adaptation" (aria-label included for a11y).
def test_imp84_provisional_zone_silent_no_class_no_badge():
"""IMP-84 silent-automation inversion of the prior IMP-30 u5 contract.
Under the silent contract, zones[i].provisional=True must:
1. NOT append `zone--provisional` class to the zone div (no user-visible
outline / striped wash).
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 = [
{
@@ -694,21 +702,26 @@ def test_u5_provisional_zone_renders_class_and_badge():
}
]
html = _render_slide_base(zones)
# zone--provisional class must appear on the zone div for position=single.
assert "zone--provisional" in html
# data-provisional="1" attribute must be present.
assert 'data-provisional="1"' in html
# Badge element with the required label text.
assert 'class="zone__needs-adaptation-badge"' in html
assert "needs adaptation" in html
assert 'aria-label="needs user or AI adaptation"' in html
zone_divs = _all_zone_div_openings(html)
assert len(zone_divs) == 1
# No zone--provisional class on the zone div (visual removed).
assert "zone--provisional" not in zone_divs[0]
# data-provisional="1" attribute still present as silent telemetry.
assert 'data-provisional="1"' in zone_divs[0]
# No badge <span> element and no badge label text anywhere in the body.
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():
"""Mixed-zone slide: one provisional zone + one normal zone. The badge
+ class must appear ONLY in the provisional zone, not bleed into the
normal one (CSS-level isolation should already prevent this, but the
template must not emit the badge for both)."""
def test_imp84_provisional_badge_never_rendered_in_mixed_zones():
"""IMP-84 silent-automation inversion of the prior IMP-30 u5 mixed-zone
contract. Mixed-zone slide: one provisional zone + one normal zone. The
silent contract requires that NO badge span and NO `zone--provisional`
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 = [
{
"position": "top",
@@ -735,21 +748,21 @@ def test_u5_provisional_badge_appears_inside_provisional_zone_only():
html = _render_slide_base(
zones, layout_preset="vertical-2", layout_css=layout_css
)
# Exactly one badge span element should be present in the rendered body
# (CSS selector in <style> excluded by the helper).
assert len(_all_badge_spans(html)) == 1
# zone--provisional must appear on exactly one zone div (CSS selector
# in <style> excluded by the helper).
# No badge <span> element should be rendered anywhere in the body
# (silent-automation policy).
assert _all_badge_spans(html) == []
# No zone div should carry the zone--provisional class (visual removed).
zone_divs = _all_zone_div_openings(html)
assert len(zone_divs) == 2
provisional_zone_divs = [d for d in zone_divs if "zone--provisional" in d]
assert len(provisional_zone_divs) == 1
# The provisional class must be associated with the bottom zone.
assert all("zone--provisional" not in d for d in zone_divs)
# data-provisional="1" telemetry must be present on the bottom (provisional)
# zone only — never on the top (non-provisional) zone.
bottom_zone_open = _zone_div_for_position(html, "bottom")
assert "zone--provisional" in bottom_zone_open
assert "zone__needs-adaptation-badge" in bottom_zone_open
# The top zone must NOT carry the provisional class.
assert 'data-provisional="1"' in bottom_zone_open
assert "zone--provisional" not in bottom_zone_open
assert "zone__needs-adaptation-badge" not in bottom_zone_open
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__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) == []
def test_u5_slide_base_css_carries_provisional_marker_styles():
"""The provisional visual contract (dashed outline + striped wash + badge)
is defined in slide_base.html <style>. Pin that the relevant CSS class
selectors exist in the rendered HTML so a refactor that removes them
breaks this test rather than silently rendering an unstyled badge.
def test_imp84_slide_base_css_strips_provisional_visual_selectors():
"""IMP-84 silent-automation inversion of the prior IMP-30 u5 CSS-presence
contract. The provisional visual treatment (dashed outline + striped wash
+ badge) was deleted from `slide_base.html <style>` by IMP-84 u2. Pin
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
specific color / dash pattern, which is a design decision intentionally
left malleable (e.g., palette swap for a different theme)."""
Scope: the assertion targets the entire rendered HTML (style block plus
body). Since the body badge span is also gone (covered separately above),
any occurrence of these strings in the rendered output would only come
from a regressed style block."""
zones = [
{
"position": "single",
@@ -799,9 +816,9 @@ def test_u5_slide_base_css_carries_provisional_marker_styles():
}
]
html = _render_slide_base(zones)
# Style block must define .zone--provisional and the badge selector.
assert ".zone--provisional" in html
assert ".zone__needs-adaptation-badge" in html
# Style block must NOT define .zone--provisional or the badge selector.
assert ".zone--provisional" not 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

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