Compare commits

Author SHA1 Message Date
Kyeongmin 97b7833a1b docs(#95): IMP-95 u11 status-board markers + idempotence/regex tests (docs+test only)
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
- Add section 9 to PHASE-Z-PIPELINE-STATUS-BOARD.md carving section 3 item (j)
  into 8 IMP-95 sub-axes (j1-j8). j1-j5 = trace-only, j6-j8 = guarded.
- Marker grammar: <!-- IMP-95:<axis> -->VALUE<!-- /IMP-95 --> (distinct from
  IMP-91 grammar so scripts/update_status_board.py MARKER_RE cannot rewrite
  IMP-95 cells).
- Allowed value enum: {pending, trace-only, guarded, active}.
- tests/scripts/test_update_status_board.py: +1 import, +4 module-level
  constants, +3 test functions verifying marker presence/count (8), value
  domain enum, IMP-91 updater isolation against IMP-95 cells, and IMP-95
  regex rewrite idempotence. IMP-91 tests untouched.
- No production-code touched. Default-OFF flag posture preserved; all cells
  trace-only or guarded.
2026-05-27 18:18:53 +09:00
KyeongminandClaude Opus 4.7 6e9e3ee1fb fix(#94): IMP-94 u7 regression-harness SHA parity normalization for additive Layer A markers
Strip the two additive IMP-94 attributes (data-region-id,
data-content-unit-id) symmetrically at both the 89-a fixture capture
script and the b4 mapper source SHA parity test before SHA-256 hashing,
honoring the issue body guardrail "mdx 01-05 의 final.html SHA =
byte-equivalent except for new data-* attrs" without recapturing the
pre-89-a baseline. The strip regex is anchored on the leading-space +
attr-token shape emitted by src/region_marker_stamper.py:131-135 so the
#96 data-frame-slot-id axis stays disjoint.

The marker-parity cross-axis tests for emergency_p4b_verbatim_code and
emergency_p4_ai_inline append sites are converted from pytest.skip to
vacuous-truth early return when the Emergency P4/P4b anchors are absent
in HEAD — the assertion target does not exist in IMP-94 scope, but the
contract still locks placement_markers=[] when the Emergency axis lands
later. Refreshed 89a_pre_baseline_sha.json (2026-05-27T04:19:30Z) holds
the normalized sizes/SHAs for mdx 01-05 post-stamper.

Scope: regression harness + fixture only; zero src/ edits. Verified
35/35 marker-parity + 18/18 SHA parity in a clean detached worktree at
HEAD 2afedfc with these four files applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:09:26 +09:00
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
KyeongminandClaude Opus 4.7 6aa7564509 feat(#91): IMP-91 u1 non-VP subprocess smoke mdx01/02 parametrize
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 02:18:17 +09:00
KyeongminandClaude Opus 4.7 b1bbe27c38 feat(#89): IMP-89 89-a u1~u5 Layer A render path activation (B4→mapper source-of-truth switch, default-OFF flag)
PHASE_Z_B4_MAPPER_SOURCE env flag (default OFF) switches slot_payload
source-of-truth from legacy mapper-only / V4 rank-1 to B4 PlacementPlan
.selected_template_id at the single switch site in the runtime loop.
OFF preserves final.html SHA byte-equivalence (u4 parity guard, mdx 01-05).
ON requires Layer A render-active path; BLOCKED exits on B4 no-cover
and on B4-selected FitError (IMP-87 honesty gate pattern — NO silent
fallback). Distinct from PHASE_Z_B4_GATEKEEPER (mismatch render-skip).

Units (1 commit = 1 axis per Stage 1 scope_lock):
  u1 — _b4_mapper_source_enabled() flag reader (default OFF)
  u2 — _select_mapper_template_id() selector wired at the switch site
  u3 — _b4_mapper_source_blocked_exit() for b4_no_cover / b4_selected_fit_error
  u4 — render SHA parity regression (tests/regression/ baseline mdx 01-05)
  u5 — slot_payload byte-equivalence (matches_mapper=True axis, mdx 01-05)

Targeted 89-a suite 63 PASS; Phase Z regression 323 PASS; IMP-87 mirror
20 PASS. Demo activation via .env only (no vite.config hardcoding).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:33:28 +09:00
KyeongminandClaude Opus 4.7 896f273ffa feat(#92): IMP-92 u1~u5 AI fallback config validation (model ping + operational error classification)
Replaces #84 UI-noise removal plan with positive operational-alert contract.
Five-axis stack lands together: (1) default model literal moved to current
Opus-family ID, (2) Anthropic SDK error classifier mapping exceptions to
quota/billing/auth/other, (3) api_error_kind plumbed through ai_repair_status
summary + per-record retention, (4) Step 0 preflight ping gated under
ai_fallback_enabled (default OFF preserved) with fail-fast on invalid
model/key, (5) frontend formatter rewritten to surface only operational
quota/billing/auth toasts (non-operational paths return null per
feedback_auto_pipeline_first silent-pipeline policy).

u1 - default model literal claude-opus-4-6-20250415 -> claude-opus-4-7
     (src/config.py + tests/test_phase_z2_ai_fallback_config.py lock mirror)
u2 - classify_operational_error type+status_code dispatch + Step 12
     api_error_kind stamp on except path (src/phase_z2_ai_fallback/client.py
     + src/phase_z2_ai_fallback/step12.py + tests/phase_z2_ai_fallback/test_step12.py)
u3 - _summarize_ai_repair_status aggregates api_error_kinds {quota,billing,
     auth,other}; error_records[i].api_error_kind retained per-record
     (src/phase_z2_pipeline.py + tests/test_imp47b_failure_surface.py)
u4 - _run_step0_ai_preflight + Step0PreflightError; preflight only fires
     when ai_fallback_enabled=true; one-token ping; invalid key/model =>
     setup failure before Step 1 (src/phase_z2_pipeline.py +
     tests/phase_z2/test_pipeline_step0_preflight.py NEW)
u5 - AiRepairStatus.api_error_kinds? interface + formatAiRepairHumanReview
     Message rewritten: operational quota/billing/auth -> Korean copy
     verbatim from issue body (tie-break quota -> billing -> auth);
     validation/coverage_violated/unsupported_kind/generic-other/legacy
     payload -> null (Front/client/src/services/designAgentApi.ts +
     Front/client/tests/imp47b_human_review_toast.test.tsx)

Guardrails respected:
- feedback_demo_env_toggle_policy: default OFF preserved; preflight skipped
  when ai_fallback_enabled=false (test_preflight_skipped_when_disabled
  asserts anthropic.Anthropic() not called).
- feedback_auto_pipeline_first: non-operational AI failures stay silent;
  only quota/billing/auth reach user toast.
- feedback_ai_isolation_contract: AI remains fallback-only; no normal-path
  migration; MDX preserved.
- project_imp46_carveout_caveat: cache_key/fingerprints fields untouched on
  every record; no overlap with #62 cache region.
- feedback_no_hardcoding: zero MDX-sample-specific literals; classifier
  dispatch by SDK type, not by string parsing.
- feedback_artifact_status_naming: operational toast scoped to alert axis,
  not overall PASS signal.

Tests:
- Targeted u1+u2+u3+u4: 63 passed
- u5 vitest (Front/): 10/10 passed
- tests/phase_z2_ai_fallback dir regression: 240 passed
- tests/phase_z2 dir regression: 323 passed
- IMP-92-adjacent (-k "imp47b or ai_fallback or preflight or step12 or step0"): 299 passed (808 deselected)
- u1 baseline lock (test_client_mock.py): 8 passed
Zero failures, zero regressions outside scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:07:25 +09:00
KyeongminandClaude Opus 4.7 842a46144c feat(#87): IMP-87 u1~u5 empty_shell honesty gate + BLOCKED exit
EMPTY_SHELL_NO_CONTENT overall enum + 3-marker detection (frame_template_id="__empty__"
OR label="empty_shell" OR merge_type="empty_shell") routes empty-placeholder-only
slides to BLOCKED CLI exit 1 + red final_status.html, blocking fake PASS reports
(feedback_artifact_status_naming). Coverage accounting split: legacy covered_section_ids
preserved + new content_rendered_section_ids / empty_shell_section_ids. mdx05 Case B
(zero V4 evidence) honestly classified instead of synthesizing fabricated rank-1 reject
frames. IMP-30 u6/u7 stale empty-shell PASS assertions inverted (29 tests). IMP-85 smoke
parametrize: mdx05 removed from exit-0 list + dedicated BLOCKED exit test added (4 tests).
No production behavior change for chain_exhausted Case A; no AI route activation; no
mdx-id hardcoding. 53 targeted + 76 adjacent Phase Z tests PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:40:54 +09:00
125 changed files with 25743 additions and 1211 deletions
@@ -0,0 +1,71 @@
name: Multi-MDX Regression (IMP-91)
# IMP-#91 u13 — auto-gate the mdx 01-05 acceptance set on every push to main
# and on PRs targeting main. Failure of any integration test blocks the
# commit. JSON report is emitted via pytest-json-report (u12 dep) and
# uploaded as an artifact for u14/u15 status-board updater consumption.
#
# [[feedback_validation_first_for_closed_issues]] — fresh subprocess per CI run.
# [[feedback_auto_pipeline_first]] — no manual review queue; deterministic gate.
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
multi-mdx-regression:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- name: Install Chrome and ChromeDriver
uses: browser-actions/setup-chrome@v1
with:
install-chromedriver: true
- name: Install project (dev extras + selenium)
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
python -m pip install "selenium>=4.20"
- name: Run multi-mdx regression tests
run: |
python -m pytest -q -m integration \
tests/integration/test_multi_mdx_regression.py \
--json-report \
--json-report-file=imp91-report.json \
--json-report-omit keywords streams
- name: Upload pytest JSON report
if: always()
uses: actions/upload-artifact@v4
with:
name: imp91-multi-mdx-report
path: imp91-report.json
if-no-files-found: warn
- name: Update status-board markers (IMP-91 u15)
if: always()
run: |
python scripts/update_status_board.py \
--report imp91-report.json \
--board docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md
- name: Upload updated status board
if: always()
uses: actions/upload-artifact@v4
with:
name: imp91-status-board
path: docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md
if-no-files-found: warn
+141 -40
View File
@@ -1,99 +1,200 @@
/**
* BottomActions - 하단 액션 버튼 영역
* BottomActions — Step 22 footer wire-up (IMP-56 #90 u20).
*
* 생성하기, 다운로드, 연동하기 버튼 컴포넌트
* Two real endpoints replace the prior placeholder toasts:
* • POST /api/connect (u18 / Front/vite.config.ts) — copies
* data/runs/<run_id>/phase_z2/final.html + assets/ into the cel mirror
* (`<CEL_PROJECT_ROOT>/public/slides/<slug>.html`).
* • POST /api/export (u19 / Front/vite.config.ts) — returns a standalone
* text/html body with every `url(assets/...)` ref inlined as base64
* data URLs. Response is piped into a Blob → a[download] click chain so
* the user receives `<run_id>.html` portable for file:// or any host.
*
* The prior `serializeSlidePlan` JSON-download path was a dead reference
* (the export never existed in slidePlanUtils) and is removed here — the
* "다운로드" button now means standalone HTML download via /api/export.
* Both buttons disable when no run is loaded (runMeta == null) so the
* UI cannot fire requests with an undefined run_id.
*/
import { useState } from "react";
import { Sparkles, Download, Link2, Loader2 } from "lucide-react";
import { 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);
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 = url;
a.download = `slide-plan-${Date.now()}.json`;
a.href = objectUrl;
a.download = buildDownloadFilename(runMeta.run_id);
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
toast.success("SlidePlan JSON이 다운로드되었습니다.");
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) {
+294 -54
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 && (
<div
data-testid="edit-mode-toolbar"
className="absolute top-2 right-2 z-30 flex gap-1"
style={{ pointerEvents: "auto" }}
>
{EDIT_MODES.map((mode) => {
const active = editMode === mode;
const label =
mode === "text" ? "✏ 텍스트" : mode === "structure" ? "▦ 구조" : "🖼 이미지/존";
const title =
mode === "text"
? "텍스트 편집 — 텍스트 클릭하여 직접 수정"
: mode === "structure"
? "구조 편집 — slot 순서 / 숨김 변경 (u14 펜딩)"
: "이미지/존 편집 — 이미지 드래그·리사이즈 + 존 리사이즈";
return (
<button
key={mode}
type="button"
data-testid={`edit-mode-${mode}`}
aria-pressed={active}
onClick={(e) => {
e.stopPropagation();
setIsEditMode((p) => !p);
setEditMode((prev) => nextEditMode(prev, mode));
}}
className={`absolute top-2 right-2 z-30 text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
isEditMode
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"
}`}
style={{ pointerEvents: "auto" }}
title={
isEditMode
? "편집 모드 — 텍스트 클릭하여 수정. 다시 클릭하여 종료. (변경은 frontend 만, backend 반영 미구현)"
: "텍스트 직접 편집 모드 진입"
}
title={title}
>
{isEditMode ? "✏ 편집 중 (클릭 종료)" : "✏ 편집"}
{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>
);
}
+197 -55
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) => ({
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 발생했음.
// 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 defaultByZone = new Map<string, string[]>();
sourcePlan.zones.forEach((z) => {
defaultByZone.set(z.zone_id, z.section_ids);
});
const zoneSectionsDiff: Record<string, string[]> = {};
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());
const defaults = defaultByZone.get(zoneId) ?? [];
const sameAsDefault =
cleaned.length === defaults.length &&
cleaned.every((sid, i) => sid === defaults[i]);
if (!sameAsDefault) {
zoneSectionsDiff[zoneId] = cleaned;
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>
);
+103 -18
View File
@@ -235,6 +235,15 @@ export interface AiRepairStatus {
unsupported_kind: number;
error: number;
};
// IMP-92 u3 — per-kind operational error aggregates plumbed from Step 12
// (u2 classify_operational_error). Optional for backward compatibility
// with pre-u3 payloads — u5 formatter treats absence as silent.
api_error_kinds?: {
quota: number;
billing: number;
auth: number;
other: number;
};
unsupported_kind_records: Array<{
unit_index?: number | null;
source_section_ids: string[];
@@ -244,6 +253,8 @@ export interface AiRepairStatus {
unit_index?: number | null;
source_section_ids: string[];
error: string;
// IMP-92 u3 — per-record operational error kind (quota|billing|auth|other|null).
api_error_kind?: string | null;
}>;
coverage_status: string;
dropped_section_ids: string[];
@@ -267,23 +278,32 @@ export interface RunMeta {
ai_repair_status: AiRepairStatus | null;
}
// IMP-92 u5 — Operational-only AI repair message formatter.
//
// Per the #84 operational-vs-non-operational replacement-plan contract, this
// returns a user-visible toast string ONLY when ai_repair_status carries one
// of the three actionable Anthropic API error kinds plumbed by u3
// (quota / billing / auth). Non-operational AI failures (validation,
// coverage_violated, unsupported_kind, or generic "other" API errors) return
// null so the auto-pipeline stays silent per feedback_auto_pipeline_first.
// Messages mirror the issue body copy contract exactly (429/402/401 →
// quota/billing/auth Korean strings).
export function formatAiRepairHumanReviewMessage(
ai: AiRepairStatus | null | undefined,
): string | null {
if (!ai || !ai.human_review_required) return null;
if (ai.status === "error") {
const n = ai.counts?.error ?? ai.error_records?.length ?? 0;
return `AI 재구성 호출 실패 (${n}건) — 다른 frame 선택 또는 수동 편집 필요`;
if (!ai) return null;
const kinds = ai.api_error_kinds;
if (!kinds) return null;
if (kinds.quota > 0) {
return `API quota 부족 — 충전 필요 (${kinds.quota}건)`;
}
if (ai.status === "coverage_violated") {
const dropped = (ai.dropped_section_ids || []).join(", ");
return `AI 재구성 후 콘텐츠 누락 (dropped: ${dropped || "?"}) — 다른 frame 선택 또는 수동 편집 필요`;
if (kinds.billing > 0) {
return `API billing 문제 — 결제 정보 확인 (${kinds.billing}건)`;
}
if (ai.status === "unsupported_kind") {
const n = ai.counts?.unsupported_kind ?? ai.unsupported_kind_records?.length ?? 0;
return `AI 제안 형식 미지원 (${n}건) — 다른 frame 선택 또는 수동 편집 필요`;
if (kinds.auth > 0) {
return `API key 무효 — .env 확인 (${kinds.auth}건)`;
}
return `AI 재구성 human_review 필요 (status: ${ai.status})`;
return null;
}
export interface LoadRunResult {
@@ -325,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) {
@@ -545,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,
@@ -556,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;
@@ -566,15 +602,64 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
if (!key) return;
if (!candidateMap.has(key)) candidateMap.set(key, c);
};
// IMP-39 u4 (issue #68) — primary path: consume the backend Step 9
// payload as the single source of ordering truth.
// • ``unit.sorted_candidate_evidence`` = policy-sorted selector trace
// (src/phase_z2_pipeline.py :4163, alias of selection_trace[
// "candidates"] sorted by u2 at :1186-1196). Same IMP-05 L2 schema
// consumed below (template_id, label, confidence, frame_number,
// frame_id, rank, catalog_registered, capacity_fit, route_hint, ...).
// • ``unit.ranking_sort_policy`` = full single-source policy dict
// (policy_type / label_priority / unknown_label_priority /
// tie_break_axes) forwarded for telemetry + fallback parity check.
// When both are present we feed sorted_candidate_evidence through the
// existing dedup map (first occurrence wins, mirrors backend
// ``seen_template_ids`` semantics at :1204-1236) and SKIP the local
// re-sort — backend "rank 1" then equals frontend frame_candidates[0]
// by construction (Stage 1 root-cause fix).
const sortedCandidateEvidence: any[] | null = Array.isArray(
unit.sorted_candidate_evidence,
)
? unit.sorted_candidate_evidence
: null;
const rankingSortPolicy = unit.ranking_sort_policy ?? null;
const backendPolicyPayloadPresent =
sortedCandidateEvidence !== null &&
sortedCandidateEvidence.length > 0 &&
rankingSortPolicy !== null;
let v4Source: any[];
if (backendPolicyPayloadPresent) {
sortedCandidateEvidence!.forEach(pushCandidate);
v4Source = Array.from(candidateMap.values());
} else {
// IMP-39 u4 — warn-fallback path. Legacy fixtures predating u3 (or
// any code path that strips the payload) lack the backend-sorted
// evidence; ordering then derives from local LABEL_PRIORITY mirror.
// Warning surfaces drift in dev console without hard-failing the UI
// (graceful: production sample audit deck remains renderable).
if (typeof console !== "undefined" && typeof console.warn === "function") {
console.warn(
`[IMP-39 u4] unit ${unit.unit_id ?? "<unknown>"}: backend payload ` +
"missing ranking_sort_policy / sorted_candidate_evidence — " +
"falling back to local LABEL_PRIORITY (legacy fixture path).",
);
}
const candidateEvidence = Array.isArray(unit.candidate_evidence)
? unit.candidate_evidence
: [];
candidateEvidence.forEach(pushCandidate);
(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);
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);
});
});
@@ -1,20 +1,28 @@
// IMP-47B u11 — Frontend ai_repair_status notification surfacing.
// IMP-92 u5 — Frontend AI repair operational-only formatter test surface.
//
// Scope (Stage 2 unit u11 contract):
// 1) loadRun → RunMeta.ai_repair_status exposes the u8 step20 payload.
// 2) formatAiRepairHumanReviewMessage(...) returns user-facing notification
// text on the three failure axes (error / coverage_violated /
// unsupported_kind) and returns null on success / no-AI paths.
// Scope (Stage 2 unit u5 contract):
// 1) formatAiRepairHumanReviewMessage(...) surfaces a user-facing toast
// ONLY on the three operational Anthropic API error kinds (quota /
// billing / auth) classified by Step 12 u2
// (classify_operational_error) and aggregated through u3
// ai_repair_status.api_error_kinds.
// 2) Non-operational AI failures (validation / coverage_violated /
// unsupported_kind / generic "other") return null so the
// auto-pipeline stays silent per feedback_auto_pipeline_first and
// the #84 operational-vs-non-operational replacement-plan contract.
// 3) Replaces the prior IMP-47B u11 surface — previously rendered toasts
// for error / coverage_violated / unsupported_kind. After IMP-92 the
// ONLY operational reaches the user; non-operational stays silent.
//
// Pure-function unit test (no React Testing Library required — vitest is
// already in devDependencies; @testing-library/* is NOT installed). The
// Home.tsx wiring is a 2-line site that calls this helper after
// setRunMeta(...); covering the helper covers the user-visible message text
// directly without DOM rendering.
// Home.tsx wiring is a 2-line site (`Home.tsx:438`) that calls this helper
// after `setRunMeta(...)`; covering the helper covers the user-visible
// message text directly without DOM rendering.
//
// File extension is `.tsx` per Stage 2 unit contract path; no JSX is required
// for these assertions but the extension allows future RTL-based tests to
// land here without renaming.
// The test file path is preserved from IMP-47B u11 (Stage 2 plan
// `Front/client/tests/imp47b_human_review_toast.test.tsx`); the assertions
// inside reflect the IMP-92 u5 operational-only contract.
import { describe, it, expect } from "vitest";
import {
@@ -23,7 +31,7 @@ import {
} from "../src/services/designAgentApi";
const baseCounts = {
total: 1,
total: 0,
applied: 0,
no_proposal: 0,
no_zone_match: 0,
@@ -31,16 +39,19 @@ const baseCounts = {
error: 0,
};
describe("formatAiRepairHumanReviewMessage (IMP-47B u11)", () => {
it("returns null when ai_repair_status is null (legacy / pre-Step12 abort)", () => {
const zeroKinds = { quota: 0, billing: 0, auth: 0, other: 0 };
describe("formatAiRepairHumanReviewMessage (IMP-92 u5 — operational-only)", () => {
it("returns null when ai_repair_status is null / undefined", () => {
expect(formatAiRepairHumanReviewMessage(null)).toBeNull();
expect(formatAiRepairHumanReviewMessage(undefined)).toBeNull();
});
it("returns null when human_review_required=false (success / no-AI path)", () => {
it("returns null on success / no-AI path (no operational kind present)", () => {
const ok: AiRepairStatus = {
status: "ok",
counts: { ...baseCounts, total: 0 },
counts: { ...baseCounts },
api_error_kinds: { ...zeroKinds },
unsupported_kind_records: [],
error_records: [],
coverage_status: "ok",
@@ -57,47 +68,127 @@ describe("formatAiRepairHumanReviewMessage (IMP-47B u11)", () => {
expect(formatAiRepairHumanReviewMessage(applied)).toBeNull();
});
it("surfaces AI call failures with count + frame/manual guidance", () => {
const errored: AiRepairStatus = {
it("surfaces quota operational alert (Anthropic 429 / RateLimitError)", () => {
const ai: AiRepairStatus = {
status: "error",
counts: { ...baseCounts, total: 2, error: 2 },
api_error_kinds: { quota: 2, billing: 0, auth: 0, other: 0 },
unsupported_kind_records: [],
error_records: [
{ unit_index: 0, source_section_ids: ["03-1"], error: "timeout" },
{ unit_index: 1, source_section_ids: ["03-2"], error: "validation" },
{
unit_index: 0,
source_section_ids: ["03-1"],
error: "RateLimitError: rate_limit_exceeded",
api_error_kind: "quota",
},
{
unit_index: 1,
source_section_ids: ["03-2"],
error: "RateLimitError: rate_limit_exceeded",
api_error_kind: "quota",
},
],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(errored);
const msg = formatAiRepairHumanReviewMessage(ai);
expect(msg).not.toBeNull();
expect(msg).toContain("AI 재구성 호출 실패");
expect(msg).toContain("API quota");
expect(msg).toContain("충전 필요");
expect(msg).toContain("2");
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
});
it("surfaces coverage violations with the dropped section ids", () => {
const dropped: AiRepairStatus = {
it("surfaces billing operational alert (Anthropic 402 / PermissionDeniedError)", () => {
const ai: AiRepairStatus = {
status: "error",
counts: { ...baseCounts, total: 1, error: 1 },
api_error_kinds: { quota: 0, billing: 1, auth: 0, other: 0 },
unsupported_kind_records: [],
error_records: [
{
unit_index: 0,
source_section_ids: ["03-1"],
error: "PermissionDeniedError: insufficient credits",
api_error_kind: "billing",
},
],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(ai);
expect(msg).not.toBeNull();
expect(msg).toContain("API billing");
expect(msg).toContain("결제 정보 확인");
expect(msg).toContain("1");
});
it("surfaces auth operational alert (Anthropic 401 / AuthenticationError)", () => {
const ai: AiRepairStatus = {
status: "error",
counts: { ...baseCounts, total: 1, error: 1 },
api_error_kinds: { quota: 0, billing: 0, auth: 1, other: 0 },
unsupported_kind_records: [],
error_records: [
{
unit_index: 0,
source_section_ids: ["03-1"],
error: "AuthenticationError: invalid x-api-key",
api_error_kind: "auth",
},
],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(ai);
expect(msg).not.toBeNull();
expect(msg).toContain("API key 무효");
expect(msg).toContain(".env");
expect(msg).toContain("1");
});
it("returns null on generic non-operational 'other' API error (silent)", () => {
const ai: AiRepairStatus = {
status: "error",
counts: { ...baseCounts, total: 1, error: 1 },
api_error_kinds: { quota: 0, billing: 0, auth: 0, other: 1 },
unsupported_kind_records: [],
error_records: [
{
unit_index: 0,
source_section_ids: ["03-1"],
error: "ValidationError: proposal failed schema",
api_error_kind: "other",
},
],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
expect(formatAiRepairHumanReviewMessage(ai)).toBeNull();
});
it("returns null on coverage_violated (non-operational, silent)", () => {
const ai: AiRepairStatus = {
status: "coverage_violated",
counts: { ...baseCounts, total: 1, applied: 1 },
api_error_kinds: { ...zeroKinds },
unsupported_kind_records: [],
error_records: [],
coverage_status: "violated",
dropped_section_ids: ["03-2"],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(dropped);
expect(msg).not.toBeNull();
expect(msg).toContain("콘텐츠 누락");
expect(msg).toContain("03-2");
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
expect(formatAiRepairHumanReviewMessage(ai)).toBeNull();
});
it("surfaces unsupported proposal kinds with the unsupported count", () => {
const unsupported: AiRepairStatus = {
it("returns null on unsupported_kind (non-operational, silent)", () => {
const ai: AiRepairStatus = {
status: "unsupported_kind",
counts: { ...baseCounts, total: 1, unsupported_kind: 1 },
api_error_kinds: { ...zeroKinds },
unsupported_kind_records: [
{
unit_index: 0,
@@ -110,26 +201,57 @@ describe("formatAiRepairHumanReviewMessage (IMP-47B u11)", () => {
dropped_section_ids: [],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(unsupported);
expect(msg).not.toBeNull();
expect(msg).toContain("AI 제안 형식 미지원");
expect(msg).toContain("1");
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
expect(formatAiRepairHumanReviewMessage(ai)).toBeNull();
});
it("falls back to a generic human_review message on unknown status enums", () => {
const future: AiRepairStatus = {
status: "future_axis_not_yet_mapped",
counts: { ...baseCounts, total: 0 },
it("returns null on legacy ai_repair_status without api_error_kinds (pre-u3 runs)", () => {
// Backward-compat: payloads emitted before u3 plumbing landed don't
// carry api_error_kinds. Operational-only contract treats the absence
// as "no operational signal" → silent (no toast).
const legacy: AiRepairStatus = {
status: "error",
counts: { ...baseCounts, total: 1, error: 1 },
// api_error_kinds intentionally omitted
unsupported_kind_records: [],
error_records: [],
error_records: [
{ unit_index: 0, source_section_ids: ["03-1"], error: "timeout" },
],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(future);
expect(formatAiRepairHumanReviewMessage(legacy)).toBeNull();
});
it("prioritises quota when multiple operational kinds co-occur", () => {
// Defensive: a run that accumulated quota + billing errors across
// multiple AI repair attempts surfaces the quota line first (the
// most-frequently actionable per the issue body ordering).
const ai: AiRepairStatus = {
status: "error",
counts: { ...baseCounts, total: 2, error: 2 },
api_error_kinds: { quota: 1, billing: 1, auth: 0, other: 0 },
unsupported_kind_records: [],
error_records: [
{
unit_index: 0,
source_section_ids: ["03-1"],
error: "RateLimitError",
api_error_kind: "quota",
},
{
unit_index: 1,
source_section_ids: ["03-2"],
error: "PermissionDeniedError",
api_error_kind: "billing",
},
],
coverage_status: "ok",
dropped_section_ids: [],
human_review_required: true,
};
const msg = formatAiRepairHumanReviewMessage(ai);
expect(msg).not.toBeNull();
expect(msg).toContain("human_review");
expect(msg).toContain("future_axis_not_yet_mapped");
expect(msg).toContain("API quota");
});
});
@@ -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,66 @@ 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).
---
## 9. IMP-95 (V4 evidence → B4 `_select_frame` integration) sub-axis markers
> Sub-axis carve-out of section 3 item (j) for IMP-95. Pair-comment markers
> `<!-- IMP-95:<axis> -->VALUE<!-- /IMP-95 -->`. Closing tag `<!-- /IMP-95 -->`
> is intentionally distinct from IMP-91's `<!-- /IMP-91 -->` so the IMP-91
> updater (`scripts/update_status_board.py`) cannot rewrite IMP-95 cells.
> Allowed values: `pending` (not implemented), `trace-only` (default-OFF flag
> `PHASE_Z_B4_V4_EVIDENCE`, additive telemetry only — no render-path change),
> `guarded` (default-OFF regression harness landed and runs locally), `active`
> (default-ON — not the current IMP-95 target).
| sub-axis | status |
|---|---|
| j1 V4-aware selector under `accepted_content_types ⊇` (u2) | <!-- IMP-95:j1 -->trace-only<!-- /IMP-95 --> |
| j2 `plan_placement` v4_candidates kwarg + selection_trace (u3) | <!-- IMP-95:j2 -->trace-only<!-- /IMP-95 --> |
| j3 Step 11 `placement_trace` hoist (u4) | <!-- IMP-95:j3 -->trace-only<!-- /IMP-95 --> |
| j4 Gatekeeper `v4_short_circuit` telemetry (u5) | <!-- IMP-95:j4 -->trace-only<!-- /IMP-95 --> |
| j5 `partial_exists` precheck (u6) | <!-- IMP-95:j5 -->trace-only<!-- /IMP-95 --> |
| j6 Flag-OFF SHA parity regression on mdx 01/02/04/05 (u8) | <!-- IMP-95:j6 -->guarded<!-- /IMP-95 --> |
| j7 Flag-ON adapter_needed monotone regression (u9) | <!-- IMP-95:j7 -->guarded<!-- /IMP-95 --> |
| j8 Flag-ON `placement_trace` field presence regression (u10) | <!-- IMP-95:j8 -->guarded<!-- /IMP-95 --> |
---
## 사용 방법
- 새 작업 들어오면 → 본 board 의 *어느 step* 의 status 를 바꾸는 작업인지 식별
+132 -4
View File
@@ -1009,6 +1009,9 @@ def build_context_pack(n, title, body, sid, agent, rnd, start_cnt, compact=None)
# 검증 실패 보고서 (rewind 시 이전 실패 맥락 전달).
# 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,6 +1852,47 @@ def run_issue(n, until=None):
continue_same_count=0, last_remaining_units=None)
if s["id"] == "final-close":
# P7 (2026-05-26) — KEEP_OPEN guard. Stage 6 exit body / last YES body 가 명시적
# keep-open / no-close 신호 내면 close PATCH skip. body-level lock 이 있는 umbrella
# anchor (#83 IMP-83 등) 보호 — Stage 6 성공 = "올바른 disposition 확정" 이며,
# 그 disposition 이 KEEP_OPEN 일 수 있음.
keep_open_patterns = [
r"KEEP_OPEN_AS_UMBRELLA_ANCHOR",
r"DO\s*NOT\s*CLOSE",
r"disposition\s*:\s*KEEP_OPEN",
r"^\s*action\s*:\s*NONE",
r"^\s*state_after\s*:\s*open",
r"NO\s+close\s+signal",
]
keep_open = False
# P7a (2026-05-26) — fetch comments fresh; `comments` is loop-local in stage block
# and not in scope at run_issue post-stage update. NameError fix.
try:
_cs = get_comments(n)
last_body = _cs[-1].get("body", "") if _cs else ""
except: last_body = ""
for p in keep_open_patterns:
if re.search(p, last_body, re.IGNORECASE | re.MULTILINE):
keep_open = True; break
if not keep_open:
exit_path = _erp(n, "final-close")
if exit_path.exists():
try:
exit_body = exit_path.read_text(encoding="utf-8", errors="ignore")
for p in keep_open_patterns:
if re.search(p, exit_body, re.IGNORECASE | re.MULTILINE):
keep_open = True; break
except: pass
if keep_open:
log(f"Stage 6 KEEP_OPEN signal — issue #{n} NOT closed (umbrella/governance anchor honored)")
try: gitea(f"issues/{n}/comments", "POST", {"body":
"️ **[Orchestrator]** Stage 6 KEEP_OPEN signal honored — issue not closed.\n\n"
"Detected one of: `KEEP_OPEN_AS_UMBRELLA_ANCHOR`, `DO NOT CLOSE`, "
"`disposition: KEEP_OPEN`, `action: NONE`, `state_after: open`, `NO close signal`.\n\n"
"Orchestrator abstains from `PATCH state=closed` per user-decision-first lock. "
"Final-close stage marked done; issue state preserved as `open`."})
except: pass
else:
try: gitea(f"issues/{n}", "PATCH", {"state": "closed"}); log("Closed")
except: pass
+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())
+1 -1
View File
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
# IMP-33 u1 — AI fallback policy. Fallback-path only; normal path AI=0.
# Defaults locked by Stage 2 plan; do NOT inline literals downstream.
ai_fallback_enabled: bool = False
ai_fallback_model: str = "claude-opus-4-6-20250415"
ai_fallback_model: str = "claude-opus-4-7"
ai_fallback_timeout_s: float = 60.0
ai_fallback_max_retries: int = 3
ai_fallback_backoff_base_s: float = 1.0
+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,
}
+49
View File
@@ -31,6 +31,55 @@ _TRANSIENT_ERRORS: tuple[type[BaseException], ...] = (
# Output cap is an Anthropic API requirement, not a policy knob (u1).
_MAX_OUTPUT_TOKENS = 4096
# IMP-92 u2 — Anthropic SDK exception → operational error kind classifier.
# Stamped onto Step 12 AI repair records (api_error_kind) so the frontend
# operational alert formatter can surface quota / billing / auth to users
# while keeping non-operational ("other") failures silent. The classifier
# is type-based (not string parsing) and the four kinds are the only
# values frontend operational formatter is allowed to render.
_OPERATIONAL_ERROR_KIND_QUOTA = "quota"
_OPERATIONAL_ERROR_KIND_BILLING = "billing"
_OPERATIONAL_ERROR_KIND_AUTH = "auth"
_OPERATIONAL_ERROR_KIND_OTHER = "other"
def classify_operational_error(exc: BaseException) -> str:
"""Return the operational error kind for an Anthropic SDK exception.
Dispatch combines SDK exception type with the HTTP status code so the
issue body's explicit operational contract (429 quota / 402 billing /
401 auth) is honoured even when the SDK surfaces a 402 as the generic
``anthropic.APIStatusError`` rather than a typed subclass:
* ``anthropic.RateLimitError`` OR HTTP 429 → ``"quota"``
* ``anthropic.PermissionDeniedError`` OR HTTP 402 → ``"billing"``
(Anthropic Payment Required surfaces as 402; PermissionDenied/403
is the SDK-typed billing/permission surface)
* ``anthropic.AuthenticationError`` OR HTTP 401 → ``"auth"``
* everything else → ``"other"`` (silent on UI)
The frontend formatter renders quota / billing / auth and returns
``None`` for ``"other"`` so non-operational AI failures stay silent
per the #84 replacement-plan contract.
"""
if isinstance(exc, anthropic.RateLimitError):
return _OPERATIONAL_ERROR_KIND_QUOTA
if isinstance(exc, anthropic.PermissionDeniedError):
return _OPERATIONAL_ERROR_KIND_BILLING
if isinstance(exc, anthropic.AuthenticationError):
return _OPERATIONAL_ERROR_KIND_AUTH
if isinstance(exc, anthropic.APIStatusError):
status_code = getattr(exc, "status_code", None)
if status_code is None:
status_code = getattr(getattr(exc, "response", None), "status_code", None)
if status_code == 429:
return _OPERATIONAL_ERROR_KIND_QUOTA
if status_code == 402:
return _OPERATIONAL_ERROR_KIND_BILLING
if status_code == 401:
return _OPERATIONAL_ERROR_KIND_AUTH
return _OPERATIONAL_ERROR_KIND_OTHER
class AiFallbackBudgetExceeded(RuntimeError):
"""Per-run AI call budget (u1 ai_fallback_budget_per_run) exhausted."""
+4
View File
@@ -56,6 +56,7 @@ import hashlib
import json
from typing import Any, Callable, Iterable
from src.phase_z2_ai_fallback.client import classify_operational_error
from src.phase_z2_ai_fallback.router import route_ai_fallback
from src.phase_z2_ai_fallback.signature import bucket_char_count, build_signature
@@ -96,6 +97,7 @@ def gather_step12_ai_repair_proposals(
"skip_reason": str | None,
"proposal": dict | None,
"error": str | None,
"api_error_kind": str | None, # IMP-92 u2 (quota|billing|auth|other)
"cache_key": str | None, # IMP-46 u4
"fingerprints": dict | None, # IMP-46 u4
}
@@ -130,6 +132,7 @@ def gather_step12_ai_repair_proposals(
"skip_reason": None,
"proposal": None,
"error": None,
"api_error_kind": None,
"cache_key": None,
"fingerprints": None,
}
@@ -205,6 +208,7 @@ def gather_step12_ai_repair_proposals(
except Exception as exc: # noqa: BLE001 — record + continue, no AI re-raise
record["ai_called"] = True
record["error"] = f"{type(exc).__name__}: {exc}"
record["api_error_kind"] = classify_operational_error(exc)
records.append(record)
continue
if proposal is None:
+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)
+2296 -111
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)
+36 -6
View File
@@ -5,14 +5,29 @@ auto-restores user choices without re-clicking. Source of truth = MDX-keyed
file (stem of the MDX path), NOT ``data/runs/<run_id>/`` which mints a fresh
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}}
"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
@@ -0,0 +1,157 @@
"""IMP-89 89-a u3 — BLOCKED exit unit tests for Layer A render path.
Stage 2 plan (u3): when PHASE_Z_B4_MAPPER_SOURCE=ON and the Layer A render
path cannot resolve a covering frame, the runtime MUST sys.exit(1) instead of
silently degrading to adapter_needed or to the legacy V4 rank-1 mapper input.
Locked semantics (Stage 1 Q2 lock; IMP-87 honesty gate pattern):
flag OFF legacy adapter_needed path
(silent fallback preserved)
flag ON + B4 no-cover BLOCKED (sys.exit 1)
flag ON + FitError on B4-selected BLOCKED (sys.exit 1)
flag ON + matches_mapper + FitError BLOCKED (explicit no-silent
fallback even when V4 rank-1
equals B4 pick)
These tests target the `_b4_mapper_source_blocked_exit()` helper directly
plus contract-level assertions of its stderr output. The runtime call-sites
inside `run_phase_z2_mvp1` are guarded by `_b4_mapper_source_enabled()`
checks; u3 changes ZERO behavior under the default-OFF path.
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import (
_b4_mapper_source_blocked_exit,
_b4_mapper_source_enabled,
)
FLAG = "PHASE_Z_B4_MAPPER_SOURCE"
def test_blocked_exit_no_cover_exits_with_code_1(
capsys: pytest.CaptureFixture[str],
) -> None:
"""b4_no_cover reason → SystemExit(1), no silent fallback."""
with pytest.raises(SystemExit) as exc:
_b4_mapper_source_blocked_exit(
"b4_no_cover",
position="top",
context={
"unit": "source_section_ids=['01-1'] merge_type=raw",
"v4_rank1": "F13",
"b4_pick": None,
},
)
assert exc.value.code == 1
def test_blocked_exit_fit_error_exits_with_code_1(
capsys: pytest.CaptureFixture[str],
) -> None:
"""b4_selected_fit_error reason → SystemExit(1)."""
with pytest.raises(SystemExit) as exc:
_b4_mapper_source_blocked_exit(
"b4_selected_fit_error",
position="bottom_l",
context={
"template": "F29 (B4 selected)",
"unit": "source_section_ids=['02-2']",
"v4_rank1": "F13",
"fit_error": "slot 'title' missing",
},
)
assert exc.value.code == 1
def test_blocked_exit_stderr_carries_reason_and_position(
capsys: pytest.CaptureFixture[str],
) -> None:
"""Header line surfaces the locked reason enum + zone position."""
with pytest.raises(SystemExit):
_b4_mapper_source_blocked_exit(
"b4_no_cover",
position="bottom_r",
context={"v4_rank1": "F13"},
)
err = capsys.readouterr().err
assert "[Phase Z-2 IMP-89 89-a u3] BLOCKED" in err
assert "b4_no_cover" in err
assert "zone--bottom_r" in err
def test_blocked_exit_stderr_carries_honesty_policy_line(
capsys: pytest.CaptureFixture[str],
) -> None:
"""Policy banner names PHASE_Z_B4_MAPPER_SOURCE + IMP-87 honesty pattern."""
with pytest.raises(SystemExit):
_b4_mapper_source_blocked_exit(
"b4_selected_fit_error",
position="top",
context={"fit_error": "x"},
)
err = capsys.readouterr().err
assert "PHASE_Z_B4_MAPPER_SOURCE=ON" in err
assert "NO silent fallback" in err
assert "IMP-87 honesty gate pattern" in err
def test_blocked_exit_stderr_carries_all_context_fields(
capsys: pytest.CaptureFixture[str],
) -> None:
"""Each context dict entry surfaces on its own stderr line."""
with pytest.raises(SystemExit):
_b4_mapper_source_blocked_exit(
"b4_selected_fit_error",
position="top",
context={
"template": "F29 (B4 selected)",
"unit": "source_section_ids=['02-2']",
"v4_rank1": "F13",
"fit_error": "slot 'title' missing",
},
)
err = capsys.readouterr().err
assert "template" in err
assert "F29 (B4 selected)" in err
assert "unit" in err
assert "source_section_ids=['02-2']" in err
assert "v4_rank1" in err
assert "F13" in err
assert "fit_error" in err
assert "slot 'title' missing" in err
def test_blocked_exit_ignores_flag_state(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Helper is unconditional — flag-gating is the call-site's responsibility.
The runtime checks `_b4_mapper_source_enabled()` BEFORE invoking this
helper, so once invoked the helper always exits. This keeps the helper
behavior orthogonal to env state and makes the call-sites the
single-source-of-truth for ON/OFF policy.
"""
monkeypatch.delenv(FLAG, raising=False)
with pytest.raises(SystemExit) as exc:
_b4_mapper_source_blocked_exit(
"b4_no_cover",
position="top",
context={"v4_rank1": "F13"},
)
assert exc.value.code == 1
def test_default_off_flag_state_does_not_invoke_blocked_helper(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Under default-OFF, `_b4_mapper_source_enabled()` is False, which is
the precondition the runtime checks before calling the helper. This test
locks the contract that the flag reader returns False by default any
accidental flip would break the byte-identity guarantee of the legacy
adapter_needed path.
"""
monkeypatch.delenv(FLAG, raising=False)
assert _b4_mapper_source_enabled() is False
@@ -0,0 +1,426 @@
"""IMP-89 89-a u5 — slot_payload byte-equivalence when B4 matches mapper.
Stage 2 u5 contract (verbatim)::
slot_payload byte-equivalent (PHASE_Z_B4_MAPPER_SOURCE ON + matches_mapper=True)
vs OFF, across mdx 01-05
Why this is load-bearing
========================
u4 freezes the FULL pipeline ``final.html`` SHA under flag OFF. u5 isolates
the *mapper-input* axis: when B4 ``PlacementPlan.selected_template_id``
equals the legacy mapper input (``unit.frame_template_id`` V4 rank-1),
the selector at ``src/phase_z2_pipeline.py:223-242`` returns the same
template id under either flag state. The mapper is a pure function of
``(MdxSection, template_id)`` (deterministic dispatch via
``map_with_contract`` named ``PAYLOAD_BUILDERS`` verified at
``src/phase_z2_mapper.py:894-919``), so identical inputs identical
``slot_payload`` dicts identical JSON-canonical bytes.
This is the *cross-axis* proof complementing u4:
* u4 = on-disk ``final.html`` SHA parity, default-OFF only (legacy
preservation guard).
* u5 = ``slot_payload`` byte equivalence, *flag ON flag OFF* (Layer A
render-active behavior-preserving proof under matches_mapper).
The negative case (``test_slot_payload_diverges_when_b4_mismatches_under_flag_on``)
locks the fact that ``slot_payload`` actually *depends* on the
``template_id`` selector output without it, the equivalence test could
trivially pass even if the selector were a no-op.
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Optional
import pytest
from src.phase_z2_mapper import (
FitError,
get_contract,
load_frame_contracts,
map_with_contract,
)
from src.phase_z2_pipeline import (
_b4_mapper_source_enabled,
_select_mapper_template_id,
extract_content_objects,
parse_mdx,
)
from src.phase_z2_placement_planner import plan_placement
@dataclass
class _StubPlan:
"""Minimal placement-plan stand-in for selector unit checks.
``_select_mapper_template_id`` reads ONLY ``selected_template_id``
(verified at ``src/phase_z2_pipeline.py:240-242``). Constructing the
real ``PlacementPlan`` with placeholder slot/region lists would force
the test to track schema drift on fields the selector never touches.
"""
selected_template_id: Optional[str]
FLAG = "PHASE_Z_B4_MAPPER_SOURCE"
_REPO_ROOT = Path(__file__).resolve().parents[2]
_SAMPLES_DIR = _REPO_ROOT / "samples" / "mdx_batch"
_MDX_BATCH = ("01.mdx", "02.mdx", "03.mdx", "04.mdx", "05.mdx")
def _canonical_bytes(payload: dict) -> bytes:
"""Stable JSON canonical encoding for byte-level dict comparison.
``sort_keys`` removes dict-ordering noise; ``ensure_ascii=False`` keeps
Korean text from being mangled into ``\\uXXXX`` escapes (which would
still compare equal but would silently mask any encoding regression in
the mapper).
"""
return json.dumps(payload, sort_keys=True, ensure_ascii=False).encode(
"utf-8"
)
def _matches_mapper_cases() -> list[tuple[str, str, object, str]]:
"""Enumerate (mdx_file, section_id, section, template_id) tuples where
the matches_mapper scenario is reachable.
"matches_mapper=True" in production is the predicate
``placement_plan.selected_template_id == unit.frame_template_id``. To
cover it at the unit-test level without driving the full Type B
coordinator, we treat each B4-selected template as the *simulated*
legacy mapper input i.e. we force matches_mapper=True by construction
via ``mapper_template_id := plan.selected_template_id``.
Only sections where (a) B4 finds a covering frame AND (b) the mapper
accepts that frame (no FitError) are byte-equivalence-eligible. Under
flag ON the BLOCKED u3 path would otherwise fire that axis is
covered by ``test_b4_mapper_source_blocked.py`` and is out of scope
here.
"""
frame_contracts = list(load_frame_contracts().values())
cases: list[tuple[str, str, object, str]] = []
for mdx_file in _MDX_BATCH:
mdx_path = _SAMPLES_DIR / mdx_file
_title, sections, _footer = parse_mdx(mdx_path)
for section in sections:
content_objects = extract_content_objects(
section, source_shape=None
)
plan = plan_placement(
content_objects=content_objects,
frame_contracts=frame_contracts,
section_id=section.section_id,
)
template_id = plan.selected_template_id
if template_id is None:
continue
contract = get_contract(template_id)
if contract is None:
continue
try:
map_with_contract(section, contract)
except FitError:
continue
cases.append((mdx_file, section.section_id, section, template_id))
return cases
# Frozen at collection time so a parametrize zero-iteration cannot silently
# pass the byte-equivalence assertion (additional coverage lock below).
_MATCHES_CASES = _matches_mapper_cases()
def _slot_payload_via_selector(
section, plan, mapper_input: str
) -> tuple[dict, str]:
"""Compose ``_select_mapper_template_id → map_mdx_to_slots`` once.
Mirrors the exact runtime path at
``src/phase_z2_pipeline.py:4771-4797`` minus the BLOCKED u3 gate
(which is out of scope for u5 byte equivalence covered by u3).
Returns ``(slot_payload, resolved_template_id)`` so per-case asserts
can verify *both* axes (input + output) match.
"""
resolved = _select_mapper_template_id(plan, mapper_input)
assert resolved is not None, (
"u5 fixture invariant violated: resolved template_id is None even "
"though the case was pre-filtered for B4 cover. Re-check "
"_matches_mapper_cases()."
)
contract = get_contract(resolved)
assert contract is not None, (
f"u5 fixture invariant violated: no contract for resolved="
f"{resolved!r} (case was pre-filtered for catalog membership)."
)
return map_with_contract(section, contract), resolved
# ─── algebraic precondition (no pipeline / no mapper run) ──────────────
def test_selector_returns_same_value_under_flag_flip_when_matches_mapper(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Pure-function property: when ``plan.selected_template_id == T`` the
selector returns ``T`` under either flag state.
This is the algebra that makes the end-to-end byte equivalence below
hold mathematically. If this property breaks, every parametrized
equivalence assertion would also break this test localizes the
failure to the selector helper itself.
"""
plan = _StubPlan(selected_template_id="F13")
legacy_input = "F13" # matches_mapper=True by construction
monkeypatch.setenv(FLAG, "1")
assert _b4_mapper_source_enabled() is True
on_value = _select_mapper_template_id(plan, legacy_input)
monkeypatch.delenv(FLAG, raising=False)
assert _b4_mapper_source_enabled() is False
off_value = _select_mapper_template_id(plan, legacy_input)
assert on_value == off_value == "F13"
# ─── end-to-end byte equivalence (parametrized over real mdx data) ────
@pytest.mark.integration
@pytest.mark.parametrize(
("mdx_file", "section_id", "section", "template_id"),
_MATCHES_CASES,
ids=lambda case: (
case if isinstance(case, str) else getattr(case, "section_id", "_")
),
)
def test_slot_payload_byte_equivalent_when_matches_mapper(
monkeypatch: pytest.MonkeyPatch,
mdx_file: str,
section_id: str,
section,
template_id: str,
) -> None:
"""Per-section byte equivalence proof under matches_mapper=True.
Recomputes ``PlacementPlan`` from scratch inside the test (fixture
enumeration cached only the section + B4 pick) and asserts that the
mapper output is JSON-canonical-byte-identical between flag ON and
flag OFF, given the same mapper input.
"""
frame_contracts = list(load_frame_contracts().values())
content_objects = extract_content_objects(section, source_shape=None)
plan = plan_placement(
content_objects=content_objects,
frame_contracts=frame_contracts,
section_id=section.section_id,
)
assert plan.selected_template_id == template_id, (
f"u5 invariant: B4 selection drifted between enumeration and "
f"test execution for {mdx_file} {section_id}: enumerated="
f"{template_id!r} live={plan.selected_template_id!r}"
)
# Under matches_mapper=True the legacy mapper input equals plan pick.
legacy_mapper_input = template_id
monkeypatch.delenv(FLAG, raising=False)
plan_snapshot_off = asdict(plan) # type: ignore[call-overload]
payload_off, resolved_off = _slot_payload_via_selector(
section, plan, legacy_mapper_input
)
plan_after_off = asdict(plan) # type: ignore[call-overload]
monkeypatch.setenv(FLAG, "1")
payload_on, resolved_on = _slot_payload_via_selector(
section, plan, legacy_mapper_input
)
plan_after_on = asdict(plan) # type: ignore[call-overload]
assert resolved_off == resolved_on == template_id, (
f"selector returned different template_id under matches_mapper for "
f"{mdx_file} {section_id}: off={resolved_off!r} on={resolved_on!r}"
)
assert _canonical_bytes(payload_off) == _canonical_bytes(payload_on), (
f"slot_payload byte equivalence broken for {mdx_file} {section_id} "
f"(template_id={template_id}): mapper output diverged between "
f"flag OFF and flag ON despite identical mapper input. This means "
f"either map_with_contract gained nondeterminism or a hidden "
f"selector-side effect crept in."
)
assert plan_snapshot_off == plan_after_off == plan_after_on, (
f"PlacementPlan mutated by selector / mapper call for {mdx_file} "
f"{section_id} — u5 byte equivalence relies on the selector being "
f"a pure read of plan.selected_template_id."
)
@pytest.mark.integration
def test_matches_mapper_corpus_coverage_is_non_empty() -> None:
"""Lock: the parametrized equivalence test above must have iterated at
least once.
Without this guard a pytest parametrize zero-iteration (e.g. all
sections rejected by B4 or all FitError-raising) would let the byte
equivalence test silently pass with zero work. mdx 01-05 is rich
enough that at least one matches_mapper case is always reachable.
"""
assert _MATCHES_CASES, (
"u5 byte equivalence had zero matches_mapper cases — every section "
"across mdx 01-05 was either B4-uncovered or raised FitError. "
"Either the corpus shrank, B4 algorithm regressed, or the mapper "
"now rejects every B4 pick. Investigate before re-locking."
)
seen_files = {case[0] for case in _MATCHES_CASES}
assert len(seen_files) >= 1, (
f"u5 coverage too narrow: {seen_files} — at least one mdx file "
f"must yield a matches_mapper case for the equivalence proof to "
f"be load-bearing."
)
# ─── negative case — bytes MUST diverge when B4 mismatches ─────────────
@pytest.mark.integration
def test_slot_payload_diverges_when_b4_mismatches_under_flag_on(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Anti-vacuous proof: when B4 picks a template DIFFERENT from the
legacy mapper input AND flag ON, the resulting ``slot_payload``
differs from the flag-OFF case.
Without this assertion the equivalence test would pass even if the
selector were a no-op that always returned the legacy input i.e.
the equivalence test would be load-bearing in the wrong direction.
This test proves the mapper output genuinely depends on the selector's
template_id choice, so equivalence under matches_mapper is a real
behavioral guarantee rather than a tautology.
Strategy: find a section where the mapper accepts *both* the B4 pick
AND a distinct alternative template (a frame the mapper also covers
with a different builder/source_shape). Compare slot_payload bytes
across the two they MUST differ.
"""
frame_contracts = list(load_frame_contracts().values())
diverging_case: tuple | None = None
for mdx_file in _MDX_BATCH:
mdx_path = _SAMPLES_DIR / mdx_file
_title, sections, _footer = parse_mdx(mdx_path)
for section in sections:
content_objects = extract_content_objects(
section, source_shape=None
)
plan = plan_placement(
content_objects=content_objects,
frame_contracts=frame_contracts,
section_id=section.section_id,
)
b4_pick = plan.selected_template_id
if b4_pick is None:
continue
b4_contract = get_contract(b4_pick)
if b4_contract is None:
continue
try:
b4_payload = map_with_contract(section, b4_contract)
except FitError:
continue
# Hunt for a *different* template the mapper also accepts on
# this same section. Iterate the catalog in declaration order
# so the search is deterministic.
for alt in frame_contracts:
alt_id = alt.get("template_id")
if not alt_id or alt_id == b4_pick:
continue
try:
alt_payload = map_with_contract(section, alt)
except FitError:
continue
if _canonical_bytes(b4_payload) != _canonical_bytes(
alt_payload
):
diverging_case = (
mdx_file,
section.section_id,
b4_pick,
alt_id,
b4_payload,
alt_payload,
)
break
if diverging_case is not None:
break
if diverging_case is not None:
break
assert diverging_case is not None, (
"Could not find a section across mdx 01-05 where the mapper "
"accepts two distinct templates with divergent slot_payload. "
"Without such a case the equivalence test above is tautological."
)
(
mdx_file,
section_id,
b4_pick,
alt_id,
b4_payload,
alt_payload,
) = diverging_case
# Now drive the selector path under flag ON with B4 picking ``b4_pick``
# while the legacy mapper input is ``alt_id`` — i.e. B4 mismatches the
# legacy input. Flag ON → selector returns b4_pick → mapper produces
# b4_payload. Flag OFF → selector returns alt_id → mapper produces
# alt_payload. The two MUST differ.
plan = _StubPlan(selected_template_id=b4_pick)
mdx_path = _SAMPLES_DIR / mdx_file
_title, sections, _footer = parse_mdx(mdx_path)
section = next(s for s in sections if s.section_id == section_id)
monkeypatch.setenv(FLAG, "1")
on_payload, on_resolved = _slot_payload_via_selector(
section, plan, alt_id
)
monkeypatch.delenv(FLAG, raising=False)
off_payload, off_resolved = _slot_payload_via_selector(
section, plan, alt_id
)
assert on_resolved == b4_pick
assert off_resolved == alt_id
assert _canonical_bytes(on_payload) != _canonical_bytes(off_payload), (
f"Negative case failed: selector flip from {alt_id} (OFF) to "
f"{b4_pick} (ON) produced byte-identical slot_payload for "
f"{mdx_file} {section_id}. The mapper appears to ignore "
f"template_id, which would make the equivalence test tautological."
)
# ─── selector default-state lock (mirror of u4 sanity check) ───────────
def test_selector_default_state_returns_legacy_under_b4_mismatch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Final sanity: even when B4 would pick something different, the
flag-OFF default selector returns the legacy mapper input verbatim.
This is the property that makes u4 SHA parity hold and the negative
test above meaningful. Repeated here at the u5 axis so a single test
file change cannot accidentally hide the regression signal across
both u4 and u5.
"""
plan = _StubPlan(selected_template_id="F29")
monkeypatch.delenv(FLAG, raising=False)
assert _b4_mapper_source_enabled() is False
assert _select_mapper_template_id(plan, "F13") == "F13"
@@ -0,0 +1,54 @@
"""IMP-89 89-a u1 — PHASE_Z_B4_MAPPER_SOURCE flag reader unit tests.
Stage 2 plan (u1): adds an env flag reader helper (default OFF) distinct
from PHASE_Z_B4_GATEKEEPER. u1 only locks reader semantics u2 wires it
into the slot_payload source-of-truth switch and u3 layers BLOCKED exits
for B4 no-cover and B4-selected FitError under flag ON.
Truthy contract (mirrors PHASE_Z_B4_GATEKEEPER /
PHASE_Z_B4_SOURCE_SHAPE_ENABLED at src/phase_z2_pipeline.py:4625,4662):
case-insensitive + leading/trailing whitespace stripped; truthy set
= {'1', 'true', 'yes'}. Everything else (including '0', '', 'no',
'false', missing env var) is OFF.
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import _b4_mapper_source_enabled
FLAG = "PHASE_Z_B4_MAPPER_SOURCE"
def test_default_off_when_env_unset(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(FLAG, raising=False)
assert _b4_mapper_source_enabled() is False
@pytest.mark.parametrize("value", ["1", "true", "yes", "TRUE", "Yes", " true ", " 1\t"])
def test_truthy_values_enable_flag(
monkeypatch: pytest.MonkeyPatch, value: str
) -> None:
monkeypatch.setenv(FLAG, value)
assert _b4_mapper_source_enabled() is True
@pytest.mark.parametrize("value", ["", "0", "no", "false", "off", "2", "on", "y"])
def test_non_truthy_values_keep_flag_off(
monkeypatch: pytest.MonkeyPatch, value: str
) -> None:
monkeypatch.setenv(FLAG, value)
assert _b4_mapper_source_enabled() is False
def test_flag_distinct_from_gatekeeper(monkeypatch: pytest.MonkeyPatch) -> None:
"""PHASE_Z_B4_GATEKEEPER ON must not flip the mapper-source flag.
Locks Stage 2 design decision (Stage 1 Q1 resolution): the new flag
governs slot_payload source-of-truth; PHASE_Z_B4_GATEKEEPER retains
its mismatch render-skip semantics. They must be independently
toggleable.
"""
monkeypatch.setenv("PHASE_Z_B4_GATEKEEPER", "1")
monkeypatch.delenv(FLAG, raising=False)
assert _b4_mapper_source_enabled() is False
@@ -0,0 +1,96 @@
"""IMP-89 89-a u2 — slot_payload source-of-truth switch unit tests.
Stage 2 plan (u2): wires the u1 PHASE_Z_B4_MAPPER_SOURCE flag into the
single slot_payload construction site at src/phase_z2_pipeline.py:4702
via the _select_mapper_template_id() selector helper.
Locked semantics (Stage 1 Q1 / Stage 2 u2):
flag ON mapper input = placement_plan.selected_template_id (B4)
flag OFF mapper input = unit.frame_template_id (legacy mapper-only)
u3 will add BLOCKED exits for (selected_template_id is None OR FitError
on B4-selected) under flag ON NO silent fallback. u4 guards default-OFF
final.html SHA parity for mdx 01-05.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import pytest
from src.phase_z2_pipeline import _select_mapper_template_id
FLAG = "PHASE_Z_B4_MAPPER_SOURCE"
@dataclass
class _StubPlan:
"""Minimal PlacementPlan stand-in — only selected_template_id is read."""
selected_template_id: Optional[str]
def test_flag_off_returns_unit_frame_template_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Default-OFF preserves legacy mapper input (V4 rank-1)."""
monkeypatch.delenv(FLAG, raising=False)
plan = _StubPlan(selected_template_id="B4_PICK")
assert _select_mapper_template_id(plan, "V4_PICK") == "V4_PICK"
def test_flag_on_returns_placement_plan_selected_template_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Flag ON routes mapper input to B4 PlacementPlan."""
monkeypatch.setenv(FLAG, "1")
plan = _StubPlan(selected_template_id="B4_PICK")
assert _select_mapper_template_id(plan, "V4_PICK") == "B4_PICK"
def test_flag_on_with_matching_b4_returns_same_value(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When B4-selected == mapper, switch is behavior-preserving."""
monkeypatch.setenv(FLAG, "true")
plan = _StubPlan(selected_template_id="F13")
assert _select_mapper_template_id(plan, "F13") == "F13"
def test_flag_on_with_no_b4_cover_returns_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Flag ON + B4 no-cover surfaces None — u3 will BLOCK on this signal."""
monkeypatch.setenv(FLAG, "yes")
plan = _StubPlan(selected_template_id=None)
assert _select_mapper_template_id(plan, "V4_PICK") is None
def test_flag_off_with_no_b4_cover_still_returns_legacy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Default-OFF ignores B4 None — legacy mapper input always honored."""
monkeypatch.delenv(FLAG, raising=False)
plan = _StubPlan(selected_template_id=None)
assert _select_mapper_template_id(plan, "V4_PICK") == "V4_PICK"
@pytest.mark.parametrize("non_truthy", ["", "0", "no", "false", "off", "2"])
def test_non_truthy_env_values_keep_legacy_source(
monkeypatch: pytest.MonkeyPatch, non_truthy: str
) -> None:
"""Non-truthy env values mirror u1 flag-reader contract — legacy source."""
monkeypatch.setenv(FLAG, non_truthy)
plan = _StubPlan(selected_template_id="B4_PICK")
assert _select_mapper_template_id(plan, "V4_PICK") == "V4_PICK"
def test_gatekeeper_flag_does_not_flip_mapper_source(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""PHASE_Z_B4_GATEKEEPER ON alone must NOT route mapper to B4 (Stage 1 Q1)."""
monkeypatch.setenv("PHASE_Z_B4_GATEKEEPER", "1")
monkeypatch.delenv(FLAG, raising=False)
plan = _StubPlan(selected_template_id="B4_PICK")
assert _select_mapper_template_id(plan, "V4_PICK") == "V4_PICK"
+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"
@@ -0,0 +1,214 @@
"""IMP-92 u4 — Step 0 AI preflight unit tests.
Scope (Stage 2 plan, u4):
- ``settings.ai_fallback_enabled=False`` preflight short-circuits to
``"skipped"`` without instantiating ``anthropic.Anthropic`` (PZ-1
AI=0 normal path + ``feedback_demo_env_toggle_policy`` default-OFF).
- ``settings.ai_fallback_enabled=True`` + valid (key, model) preflight
returns ``"passed"`` after a 1-token ``messages.create`` ping.
- Persistent setup errors (Authentication / PermissionDenied /
NotFound) raise ``Step0PreflightError`` so boot fails fast.
- Transient errors (RateLimit / InternalServer) are recorded as
``"transient"`` without failing boot.
Cross-references:
- u1 default model literal: ``src/config.py:20``
+ ``tests/test_phase_z2_ai_fallback_config.py:5,31``
- u2 SDK operational classifier:
``src/phase_z2_ai_fallback/client.py:46``
+ ``tests/phase_z2_ai_fallback/test_step12.py``
- u3 ``api_error_kind`` summary plumbing:
``src/phase_z2_pipeline.py:_summarize_ai_repair_status``
+ ``tests/test_imp47b_failure_surface.py``
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import anthropic
import httpx
import pytest
from src import phase_z2_pipeline as pipeline_mod
from src.config import settings
@pytest.fixture(autouse=True)
def _restore_settings():
snapshot = settings.model_dump()
yield
for key, value in snapshot.items():
setattr(settings, key, value)
def _ok_response() -> SimpleNamespace:
return SimpleNamespace(content=[SimpleNamespace(text="")])
def _status_error(
cls: type[anthropic.APIStatusError],
status_code: int,
message: str,
) -> anthropic.APIStatusError:
req = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
return cls(
message=message,
response=httpx.Response(status_code, request=req),
body=None,
)
def test_preflight_skipped_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "ai_fallback_enabled", False)
spy = MagicMock()
monkeypatch.setattr(anthropic, "Anthropic", spy)
result = pipeline_mod._run_step0_ai_preflight()
assert result["status"] == "skipped"
assert result["reason"] == "ai_fallback_disabled"
assert result["model"] == settings.ai_fallback_model
spy.assert_not_called()
def test_preflight_passed_when_enabled_with_valid_credentials(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "ai_fallback_enabled", True)
fake_client = MagicMock()
fake_client.messages.create.return_value = _ok_response()
monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client)
result = pipeline_mod._run_step0_ai_preflight()
assert result == {
"status": "passed",
"model": settings.ai_fallback_model,
}
fake_client.messages.create.assert_called_once()
kwargs = fake_client.messages.create.call_args.kwargs
assert kwargs["model"] == settings.ai_fallback_model
assert kwargs["max_tokens"] == 1
def test_preflight_fail_fast_on_invalid_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "ai_fallback_enabled", True)
fake_client = MagicMock()
fake_client.messages.create.side_effect = _status_error(
anthropic.AuthenticationError, 401, "invalid x-api-key"
)
monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client)
with pytest.raises(pipeline_mod.Step0PreflightError) as ei:
pipeline_mod._run_step0_ai_preflight()
assert "AuthenticationError" in str(ei.value)
def test_preflight_fail_fast_on_invalid_model(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "ai_fallback_enabled", True)
fake_client = MagicMock()
fake_client.messages.create.side_effect = _status_error(
anthropic.NotFoundError, 404, "model not found"
)
monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client)
with pytest.raises(pipeline_mod.Step0PreflightError) as ei:
pipeline_mod._run_step0_ai_preflight()
msg = str(ei.value)
assert "NotFoundError" in msg
assert settings.ai_fallback_model in msg
def test_preflight_fail_fast_on_billing_permission_denied(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "ai_fallback_enabled", True)
fake_client = MagicMock()
fake_client.messages.create.side_effect = _status_error(
anthropic.PermissionDeniedError, 403, "billing required"
)
monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client)
with pytest.raises(pipeline_mod.Step0PreflightError) as ei:
pipeline_mod._run_step0_ai_preflight()
assert "PermissionDeniedError" in str(ei.value)
def test_preflight_transient_rate_limit_does_not_fail_boot(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "ai_fallback_enabled", True)
fake_client = MagicMock()
fake_client.messages.create.side_effect = _status_error(
anthropic.RateLimitError, 429, "rate limited"
)
monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client)
result = pipeline_mod._run_step0_ai_preflight()
assert result["status"] == "transient"
assert result["model"] == settings.ai_fallback_model
assert "RateLimitError" in result["transient_error"]
def test_preflight_transient_internal_server_error_does_not_fail_boot(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "ai_fallback_enabled", True)
fake_client = MagicMock()
fake_client.messages.create.side_effect = _status_error(
anthropic.InternalServerError, 500, "upstream 500"
)
monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client)
result = pipeline_mod._run_step0_ai_preflight()
assert result["status"] == "transient"
assert "InternalServerError" in result["transient_error"]
def test_preflight_fail_fast_on_generic_billing_402(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""IMP-92 u4 — HTTP 402 (Payment Required) surfaces as the generic
``anthropic.APIStatusError`` (no typed subclass). The preflight MUST
dispatch by status code and raise ``Step0PreflightError`` so a
billing setup problem fails boot fast, matching the issue body's
operational contract.
"""
monkeypatch.setattr(settings, "ai_fallback_enabled", True)
fake_client = MagicMock()
fake_client.messages.create.side_effect = _status_error(
anthropic.APIStatusError, 402, "payment required"
)
monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client)
with pytest.raises(pipeline_mod.Step0PreflightError) as ei:
pipeline_mod._run_step0_ai_preflight()
msg = str(ei.value)
assert "402" in msg
assert settings.ai_fallback_model in msg
assert "Check ANTHROPIC_API_KEY / ai_fallback_model in .env." in msg
def test_preflight_generic_status_429_treated_as_transient(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""IMP-92 u4 — a generic ``APIStatusError`` with HTTP 429 must follow
the same transient policy as the typed ``RateLimitError`` branch.
"""
monkeypatch.setattr(settings, "ai_fallback_enabled", True)
fake_client = MagicMock()
fake_client.messages.create.side_effect = _status_error(
anthropic.APIStatusError, 429, "rate limited (generic)"
)
monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client)
result = pipeline_mod._run_step0_ai_preflight()
assert result["status"] == "transient"
assert "APIStatusError" in result["transient_error"]
def test_preflight_generic_status_5xx_treated_as_transient(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""IMP-92 u4 — a generic ``APIStatusError`` with HTTP 5xx must follow
the same transient policy as the typed ``InternalServerError`` branch.
"""
monkeypatch.setattr(settings, "ai_fallback_enabled", True)
fake_client = MagicMock()
fake_client.messages.create.side_effect = _status_error(
anthropic.APIStatusError, 503, "upstream 503 (generic)"
)
monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client)
result = pipeline_mod._run_step0_ai_preflight()
assert result["status"] == "transient"
assert "APIStatusError" in result["transient_error"]
+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
+150
View File
@@ -17,6 +17,9 @@ from dataclasses import dataclass, field
from typing import Any
from unittest.mock import MagicMock
import anthropic
import httpx
from src.phase_z2_ai_fallback import step12 as step12_mod
from src.phase_z2_ai_fallback.schema import AiFallbackProposal, ProposalKind
@@ -176,6 +179,9 @@ def test_router_exception_is_captured_per_record(monkeypatch):
assert rec["ai_called"] is True
assert rec["proposal"] is None
assert rec["error"] == "RuntimeError: transient_boom"
# IMP-92 u2 — generic (non-Anthropic) exceptions classify as "other"
# so the frontend operational formatter stays silent for them.
assert rec["api_error_kind"] == "other"
router.assert_called_once()
@@ -405,6 +411,7 @@ def test_record_shape_contract_is_stable_with_u4_fields(monkeypatch):
"skip_reason",
"proposal",
"error",
"api_error_kind",
"cache_key",
"fingerprints",
}
@@ -602,3 +609,146 @@ def test_mixed_units_router_receives_fingerprints_only_for_ai_eligible(monkeypat
# Skipped records carry None.
assert recs[0]["fingerprints"] is None
assert recs[1]["fingerprints"] is None
# ---------------------------------------------------------------------------
# IMP-92 u2 — Anthropic SDK exception → api_error_kind classification
# ---------------------------------------------------------------------------
# Step 12 stamps each AI-called record with api_error_kind so the frontend
# operational alert formatter can render quota / billing / auth surfaces
# while keeping "other" failures silent (the #84 replacement-plan contract).
# Classification is type-based (no string parsing); only AI-eligible units
# that actually hit ``route_ai_fallback`` and raise can produce a non-None
# api_error_kind. Skipped units (not_provisional / non-AI route) retain
# api_error_kind=None alongside cache_key/fingerprints=None.
def _anthropic_status_error(
error_cls: type[anthropic.APIStatusError], status_code: int
) -> anthropic.APIStatusError:
"""Construct an Anthropic SDK status error suitable for side_effect.
The SDK error constructors require ``response`` and ``body`` kwargs; an
``httpx.Response`` bound to a stub request is the minimum that satisfies
isinstance dispatch in ``classify_operational_error``.
"""
request = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
response = httpx.Response(status_code, request=request)
return error_cls("simulated", response=response, body=None)
def test_router_rate_limit_error_classifies_as_quota(monkeypatch):
"""RateLimitError (HTTP 429) → api_error_kind='quota'."""
err = _anthropic_status_error(anthropic.RateLimitError, 429)
router = MagicMock(side_effect=err)
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
recs = _call([_ai_unit()])
rec = recs[0]
assert rec["ai_called"] is True
assert rec["api_error_kind"] == "quota"
assert rec["error"].startswith("RateLimitError: ")
def test_router_permission_denied_classifies_as_billing(monkeypatch):
"""PermissionDeniedError (HTTP 403) → api_error_kind='billing'."""
err = _anthropic_status_error(anthropic.PermissionDeniedError, 403)
router = MagicMock(side_effect=err)
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
recs = _call([_ai_unit()])
rec = recs[0]
assert rec["ai_called"] is True
assert rec["api_error_kind"] == "billing"
assert rec["error"].startswith("PermissionDeniedError: ")
def test_router_payment_required_classifies_as_billing(monkeypatch):
"""Generic APIStatusError with HTTP 402 → api_error_kind='billing'.
The Anthropic SDK has no dedicated PaymentRequired subclass; a 402
response surfaces as the base ``APIStatusError``. The issue body's
explicit operational contract requires 402 to render as billing,
so the classifier must fall through to ``status_code`` dispatch when
the typed subclass branches miss.
"""
err = _anthropic_status_error(anthropic.APIStatusError, 402)
router = MagicMock(side_effect=err)
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
recs = _call([_ai_unit()])
rec = recs[0]
assert rec["ai_called"] is True
assert rec["api_error_kind"] == "billing"
assert rec["error"].startswith("APIStatusError: ")
def test_router_authentication_error_classifies_as_auth(monkeypatch):
"""AuthenticationError (HTTP 401) → api_error_kind='auth'."""
err = _anthropic_status_error(anthropic.AuthenticationError, 401)
router = MagicMock(side_effect=err)
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
recs = _call([_ai_unit()])
rec = recs[0]
assert rec["ai_called"] is True
assert rec["api_error_kind"] == "auth"
assert rec["error"].startswith("AuthenticationError: ")
def test_router_bad_request_classifies_as_other(monkeypatch):
"""BadRequestError (HTTP 400) is non-operational → api_error_kind='other'."""
err = _anthropic_status_error(anthropic.BadRequestError, 400)
router = MagicMock(side_effect=err)
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
recs = _call([_ai_unit()])
rec = recs[0]
assert rec["ai_called"] is True
assert rec["api_error_kind"] == "other"
def test_router_internal_server_error_classifies_as_other(monkeypatch):
"""InternalServerError (HTTP 5xx) is non-operational → api_error_kind='other'."""
err = _anthropic_status_error(anthropic.InternalServerError, 500)
router = MagicMock(side_effect=err)
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
recs = _call([_ai_unit()])
rec = recs[0]
assert rec["ai_called"] is True
assert rec["api_error_kind"] == "other"
def test_router_success_leaves_api_error_kind_none(monkeypatch):
"""Successful proposal record keeps api_error_kind=None (no error to classify)."""
proposal = AiFallbackProposal(
proposal_kind=ProposalKind.PARTIAL_OVERRIDES,
payload={"slots": {"s": "x"}},
rationale="r",
)
router = MagicMock(return_value=proposal)
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
recs = _call([_ai_unit()])
rec = recs[0]
assert rec["ai_called"] is True
assert rec["error"] is None
assert rec["api_error_kind"] is None
def test_skipped_records_keep_api_error_kind_none(monkeypatch):
"""Non-AI-eligible records never see the router, so api_error_kind stays None."""
monkeypatch.setattr(step12_mod, "route_ai_fallback", MagicMock(return_value=None))
units = [
FakeUnit(label="restructure", provisional=False), # not_provisional
FakeUnit(label="light_edit", provisional=True), # non-AI route
FakeUnit(label="reject", provisional=True), # legacy non-AI route
]
recs = _call(units)
for rec in recs:
assert rec["api_error_kind"] is None
assert rec["error"] is None
def test_router_short_circuit_keeps_api_error_kind_none(monkeypatch):
"""Router short-circuit (None return) is not an error path → api_error_kind=None."""
router = MagicMock(return_value=None)
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
recs = _call([_ai_unit()])
rec = recs[0]
assert rec["skip_reason"] == "router_short_circuit"
assert rec["api_error_kind"] is None
View File
@@ -0,0 +1,56 @@
{
"schema_version": 2,
"axis": "IMP-89 89-a u4 — final.html SHA baseline captured via FULL run_phase_z2_mvp1 pipeline (flag OFF / default)",
"description": "Frozen SHA-256 of `final.html` bytes (the artifact written to disk at src/phase_z2_pipeline.py:5994-5996) captured by running the full Phase Z pipeline end-to-end for each mdx 01-05 under PHASE_Z_B4_MAPPER_SOURCE=OFF. Under flag OFF the 89-a selector `_select_mapper_template_id(plan, T)` returns `T` verbatim, so the mapper input is byte-identical to the pre-89-a legacy call shape `map_mdx_to_slots(section, unit.frame_template_id)` — the rendered HTML and therefore the final.html SHA match the pre-89-a baseline. The u4 regression test runs the same pipeline shape under flag OFF and asserts SHA equality. Regenerate only when an upstream mapper/render/template delta is deliberately reviewed and accepted.",
"captured_at_utc": "2026-05-27T04:19:30Z",
"renderer": {
"entrypoint": "src.phase_z2_pipeline.run_phase_z2_mvp1",
"write_site": "src/phase_z2_pipeline.py:5994-5996",
"artifact_relpath": "<RUNS_DIR>/<run_id>/phase_z2/final.html"
},
"mdx_batch": [
"01.mdx",
"02.mdx",
"03.mdx",
"04.mdx",
"05.mdx"
],
"mdx_files": {
"01.mdx": {
"mdx_file": "01.mdx",
"run_id": "89a_baseline_01",
"final_html_size_bytes": 29959,
"sha256": "62d793166ca4cdd8d8d1d134d8659b43dce95bb958e7efd6ce0e72619b3c335d",
"pipeline_exit_code": null
},
"02.mdx": {
"mdx_file": "02.mdx",
"run_id": "89a_baseline_02",
"final_html_size_bytes": 25789,
"sha256": "333defb22f5da940b0bb3b3563730d12bd901d7b5d870a2aaef3f0c929d2dad1",
"pipeline_exit_code": null
},
"03.mdx": {
"mdx_file": "03.mdx",
"run_id": "89a_baseline_03",
"final_html_size_bytes": 40550,
"sha256": "d84e32b402cfbdcab6025b584b5e2657766f11b3fc110c217be0551e39f3f516",
"pipeline_exit_code": null
},
"04.mdx": {
"mdx_file": "04.mdx",
"run_id": "89a_baseline_04",
"final_html_size_bytes": 28725,
"sha256": "da3668d76fc582a1a26ad930b54a8abb7079969ebf91e79cfe7dd273ec4a0e95",
"pipeline_exit_code": null
},
"05.mdx": {
"mdx_file": "05.mdx",
"run_id": "89a_baseline_05",
"final_html_size_bytes": 21739,
"sha256": "5baa8478a1316403dd3e7b7bccaaaee20fe6ac7738ea7ddd2af2f19b4b294f2c",
"pipeline_exit_code": 1
}
},
"total_files": 5
}
@@ -0,0 +1,206 @@
"""IMP-89 89-a u4 — capture final.html SHA baseline via the FULL Phase Z pipeline.
Runs ``src.phase_z2_pipeline.run_phase_z2_mvp1`` end-to-end for every mdx file
in ``samples/mdx_batch/`` (01-05) under PHASE_Z_B4_MAPPER_SOURCE=OFF (default).
Each run writes a real ``final.html`` to disk at
``<RUNS_DIR>/<run_id>/phase_z2/final.html`` exactly the production write
site at ``src/phase_z2_pipeline.py:5994-5996``. The bytes of that on-disk
artifact are normalized (IMP-94 marker strip see below) and SHA-256 hashed,
then stored in ``tests/regression/fixtures/89a_pre_baseline_sha.json``.
The u4 regression test in ``tests/regression/test_b4_mapper_source_sha_parity.py``
runs the same pipeline shape under flag OFF, reads the on-disk ``final.html``,
applies the same IMP-94 normalization, hashes the result, and asserts SHA
equality with each frozen value. The mathematical chain that makes this a
genuine "pre-89-a baseline" guard:
* Under flag OFF, ``_select_mapper_template_id(plan, T) == T`` for every
``(plan, T)`` pair (locked by u2 + u4 algebraic precondition tests).
* Therefore the mapper input is byte-identical to the legacy pre-89-a call
shape ``map_mdx_to_slots(section, unit.frame_template_id)``.
* Therefore the rendered HTML is byte-identical to pre-89-a output.
* Therefore the on-disk ``final.html`` is byte-identical SHA matches.
Any future drift in the selector, mapper, render_slide, slide_base.html,
or any upstream code path produces a divergent SHA and breaks the test.
IMP-94 Layer A marker normalization (additive-only delta)
=========================================================
IMP-94 (issue #94) injected ``data-region-id`` + ``data-content-unit-id``
attributes on family-partial root divs via
``src/region_marker_stamper.py``. Per the issue body guardrail
(``byte-equivalent except for new data-* attrs``) and to keep the captured
baseline stable across deterministic stamps of evolving region/content IDs,
both the capture script and the regression test strip those two attributes
(with their leading space, matching the exact emission shape at
``src/region_marker_stamper.py:131-135``) before SHA-256 hashing. The strip
is disjoint from the #96 ``data-frame-slot-id`` axis by attribute name.
Run from repo root::
python tests/regression/scripts/capture_89a_pre_baseline.py
The capture script is idempotent and meant to be re-run only when an
upstream mapper/render/template delta is reviewed and accepted. It refuses
to run with PHASE_Z_B4_MAPPER_SOURCE enabled (the post-89-a flag-ON state
is NOT the baseline axis).
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(_REPO_ROOT))
sys.path.insert(0, str(_REPO_ROOT / "src"))
import src.phase_z2_pipeline as pz2 # noqa: E402
_SAMPLES_DIR = _REPO_ROOT / "samples" / "mdx_batch"
_MDX_BATCH = ("01.mdx", "02.mdx", "03.mdx", "04.mdx", "05.mdx")
_OUT_PATH = (
_REPO_ROOT / "tests" / "regression" / "fixtures" / "89a_pre_baseline_sha.json"
)
# IMP-94 additive marker strip patterns (mirror of
# tests/regression/test_b4_mapper_source_sha_parity.py — keep both in sync).
# Anchored on `(leading space + attr token)` shape from
# src/region_marker_stamper.py:131-135. Disjoint from #96 data-frame-slot-id.
_STRIP_REGION_ID_RE = re.compile(rb' data-region-id="[^"]*"')
_STRIP_CONTENT_UNIT_ID_RE = re.compile(rb' data-content-unit-id="[^"]*"')
def _strip_imp94_markers(raw_bytes: bytes) -> bytes:
"""Return ``raw_bytes`` with IMP-94 ``data-region-id`` and
``data-content-unit-id`` attribute tokens removed (additive-only
normalization see module docstring).
"""
stripped = _STRIP_REGION_ID_RE.sub(b"", raw_bytes)
stripped = _STRIP_CONTENT_UNIT_ID_RE.sub(b"", stripped)
return stripped
def _capture_one(mdx_file: str, runs_root: Path) -> dict:
"""Run the full pipeline once and hash the on-disk final.html.
``pz2.RUNS_DIR`` MUST be pinned to ``runs_root`` by the caller before
invocation; ``run_phase_z2_mvp1`` writes final.html to
``<pz2.RUNS_DIR>/<run_id>/phase_z2/final.html``.
``SystemExit`` from the pipeline (e.g. IMP-87 EMPTY_SHELL_NO_CONTENT
BLOCKED exit on mdx 05) is caught: the BLOCKED exit fires AFTER the
final.html write at ``src/phase_z2_pipeline.py:5994-5996``, so the
artifact still exists on disk and the SHA is captured. The exit code
is recorded on the entry so the test can assert the same terminal
state under flag OFF. If final.html is missing post-exit, that is a
genuine pipeline failure and the script aborts.
IMP-94 markers are stripped from the captured bytes before hashing
(see module docstring); ``final_html_size_bytes`` reflects the size
of the normalized bytes that were actually hashed (the same shape
the regression test produces).
"""
mdx_path = _SAMPLES_DIR / mdx_file
assert mdx_path.exists(), f"sample missing: {mdx_path}"
run_id = f"89a_baseline_{mdx_path.stem}"
pipeline_exit_code: int | None = None
try:
pz2.run_phase_z2_mvp1(mdx_path, run_id=run_id)
except SystemExit as exc:
pipeline_exit_code = (
int(exc.code) if isinstance(exc.code, int) else 1
)
final_html_path = runs_root / run_id / "phase_z2" / "final.html"
assert final_html_path.exists(), (
f"final.html not written by pipeline: {final_html_path} "
f"(pipeline_exit_code={pipeline_exit_code})"
)
raw_bytes = final_html_path.read_bytes()
assert len(raw_bytes) > 0, f"final.html is empty: {final_html_path}"
normalized_bytes = _strip_imp94_markers(raw_bytes)
return {
"mdx_file": mdx_file,
"run_id": run_id,
"final_html_size_bytes": len(normalized_bytes),
"sha256": hashlib.sha256(normalized_bytes).hexdigest(),
"pipeline_exit_code": pipeline_exit_code,
}
def capture() -> dict:
assert os.environ.get("PHASE_Z_B4_MAPPER_SOURCE", "") == "", (
"PHASE_Z_B4_MAPPER_SOURCE must be unset when capturing baseline "
"(default-OFF state is the production-equivalent axis for u4). "
"Refusing to run with the flag enabled."
)
_OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="89a_baseline_") as tmp:
runs_root = Path(tmp)
original_runs_dir = pz2.RUNS_DIR
pz2.RUNS_DIR = runs_root
try:
entries = [_capture_one(mf, runs_root) for mf in _MDX_BATCH]
finally:
pz2.RUNS_DIR = original_runs_dir
return {
"schema_version": 2,
"axis": (
"IMP-89 89-a u4 — final.html SHA baseline captured via FULL "
"run_phase_z2_mvp1 pipeline (flag OFF / default)"
),
"description": (
"Frozen SHA-256 of `final.html` bytes (the artifact written to "
"disk at src/phase_z2_pipeline.py:5994-5996) captured by running "
"the full Phase Z pipeline end-to-end for each mdx 01-05 under "
"PHASE_Z_B4_MAPPER_SOURCE=OFF. Under flag OFF the 89-a selector "
"`_select_mapper_template_id(plan, T)` returns `T` verbatim, so "
"the mapper input is byte-identical to the pre-89-a legacy call "
"shape `map_mdx_to_slots(section, unit.frame_template_id)` — "
"the rendered HTML and therefore the final.html SHA match the "
"pre-89-a baseline. The u4 regression test runs the same "
"pipeline shape under flag OFF and asserts SHA equality. "
"Regenerate only when an upstream mapper/render/template delta "
"is deliberately reviewed and accepted."
),
"captured_at_utc": (
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
),
"renderer": {
"entrypoint": "src.phase_z2_pipeline.run_phase_z2_mvp1",
"write_site": "src/phase_z2_pipeline.py:5994-5996",
"artifact_relpath": "<RUNS_DIR>/<run_id>/phase_z2/final.html",
},
"mdx_batch": list(_MDX_BATCH),
"mdx_files": {entry["mdx_file"]: entry for entry in entries},
"total_files": len(entries),
}
def main() -> None:
data = capture()
_OUT_PATH.write_text(
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(
f"wrote {_OUT_PATH} ({data['total_files']} files: "
f"{', '.join(data['mdx_files'].keys())})"
)
if __name__ == "__main__":
main()
@@ -0,0 +1,410 @@
"""IMP-89 89-a u4 — final.html SHA parity guard for mdx 01-05 under flag OFF.
Stage 2 u4 contract (verbatim)::
mdx 01-05 final.html SHA == pre-89-a baseline under
PHASE_Z_B4_MAPPER_SOURCE=OFF (default)
How this guard is wired
=======================
The baseline is captured ONCE by
``tests/regression/scripts/capture_89a_pre_baseline.py``, which runs the
FULL Phase Z pipeline (``src.phase_z2_pipeline.run_phase_z2_mvp1``) for
every mdx file in ``samples/mdx_batch/`` (01-05) under flag OFF, reads
the on-disk ``final.html`` artifact from
``<RUNS_DIR>/<run_id>/phase_z2/final.html``
(the production write site at ``src/phase_z2_pipeline.py:5994-5996``),
and SHA-256 hashes its bytes. The hash is frozen at::
tests/regression/fixtures/89a_pre_baseline_sha.json
This test re-runs the SAME pipeline shape (full ``run_phase_z2_mvp1``,
flag OFF, isolated tmp RUNS_DIR), reads the live on-disk ``final.html``,
hashes its bytes, and asserts SHA equality with the frozen value.
Why this is a genuine "pre-89-a baseline" guard
================================================
Under flag OFF the 89-a switch at ``src/phase_z2_pipeline.py:4771-4773``
calls ``_select_mapper_template_id(plan, unit.frame_template_id)``, which
returns ``unit.frame_template_id`` verbatim (locked by u2 unit tests +
``test_flag_off_selector_returns_legacy_input_per_section`` below). The
mapper input is therefore byte-identical to the pre-89-a call shape
``map_mdx_to_slots(section, unit.frame_template_id)`` (no selector
indirection), so:
* mapper output (slot_payload) is byte-identical
* render_slide output is byte-identical
* on-disk ``final.html`` is byte-identical (modulo the additive-only
IMP-94 normalization described below)
* SHA-256 of normalized bytes matches the frozen baseline
Any future drift in ``_select_mapper_template_id``, ``map_mdx_to_slots``,
``render_slide``, ``slide_base.html``, family partials, or any upstream
pipeline step produces a divergent SHA and breaks this test.
Crucially the baseline is NOT recomputed in this process it is read
from the on-disk JSON fixture. This makes the test a genuine regression
guard rather than a paired identity check.
IMP-94 Layer A marker normalization (additive-only delta)
=========================================================
IMP-94 (issue #94) injected two additive ``data-*`` attributes onto each
family-partial root ``<div>`` in the live pipeline output:
* ``data-region-id="..."``
* ``data-content-unit-id="..."``
These are stamped by ``src/region_marker_stamper.py`` after the
``_stamp_zone_html`` (IMP-56) call in ``render_slide`` and are not
present in the frozen pre-89-a baseline captured 2026-05-23. The issue
body guardrail mandates::
mdx 01-05 final.html SHA = byte-equivalent except for new
data-* attrs
To honor that guardrail without recapturing the baseline (which would
silently erase pre-IMP-94 regression coverage), ``_live_pipeline_final_html_sha``
strips the two IMP-94 attribute tokens (with their leading single space,
matching the exact emission shape at
``src/region_marker_stamper.py:131-135``) from the live ``final.html``
bytes before SHA-256 hashing. After this purely-additive strip, the
remaining bytes are byte-equivalent to the pre-stamper state, so SHA
parity with the frozen baseline holds mathematically.
The normalization is anchored on the leading-space + attr-token pattern,
so it is disjoint from the #96 (89-d) ``data-frame-slot-id`` axis (which
this guard MUST NOT strip any drift in ``data-frame-slot-id`` counts
or values is a real regression and must surface here).
"""
from __future__ import annotations
import hashlib
import json
import os
import re
from pathlib import Path
import pytest
import src.phase_z2_pipeline as pz2
from src.phase_z2_pipeline import (
_b4_mapper_source_enabled,
_select_mapper_template_id,
extract_content_objects,
load_frame_contracts,
parse_mdx,
)
from src.phase_z2_placement_planner import plan_placement
FLAG = "PHASE_Z_B4_MAPPER_SOURCE"
_REPO_ROOT = Path(__file__).resolve().parents[2]
_SAMPLES_DIR = _REPO_ROOT / "samples" / "mdx_batch"
_MDX_BATCH = ("01.mdx", "02.mdx", "03.mdx", "04.mdx", "05.mdx")
_BASELINE_PATH = (
Path(__file__).parent / "fixtures" / "89a_pre_baseline_sha.json"
)
# IMP-94 additive marker strip patterns (byte-level, leading single space
# matches the exact emission shape at src/region_marker_stamper.py:131-135).
# Disjoint from #96 data-frame-slot-id axis by attribute name.
_STRIP_REGION_ID_RE = re.compile(rb' data-region-id="[^"]*"')
_STRIP_CONTENT_UNIT_ID_RE = re.compile(rb' data-content-unit-id="[^"]*"')
def _strip_imp94_markers(raw_bytes: bytes) -> bytes:
"""Return ``raw_bytes`` with IMP-94 ``data-region-id`` and
``data-content-unit-id`` attribute tokens removed (additive-only
normalization for pre-stamper baseline SHA parity).
The strip is anchored on the ``(leading space + attr token)`` shape
emitted by the u1 stamper so any token not stamped by IMP-94 (e.g.
``data-frame-slot-id`` from the #96 axis) is preserved verbatim.
Empty/None and pre-stamper baselines pass through unchanged.
"""
stripped = _STRIP_REGION_ID_RE.sub(b"", raw_bytes)
stripped = _STRIP_CONTENT_UNIT_ID_RE.sub(b"", stripped)
return stripped
@pytest.fixture(scope="module")
def baseline() -> dict:
"""Load the frozen final.html SHA baseline from disk.
A missing file is a hard failure with a one-shot regen hint the
baseline cannot be silently regenerated inside the test process
(that would defeat the regression-guard purpose).
"""
assert _BASELINE_PATH.exists(), (
f"baseline fixture missing: {_BASELINE_PATH}\n"
f"regenerate with: python tests/regression/scripts/"
f"capture_89a_pre_baseline.py"
)
with _BASELINE_PATH.open("r", encoding="utf-8") as f:
data = json.load(f)
assert data.get("schema_version") == 2, (
f"unexpected baseline schema_version: {data.get('schema_version')} "
f"(expected 2 — full-pipeline final.html SHA capture). Regenerate "
f"the fixture via capture_89a_pre_baseline.py."
)
assert data.get("total_files", 0) >= len(_MDX_BATCH), (
f"baseline has {data.get('total_files')} files — expected "
f">= {len(_MDX_BATCH)} (one per mdx 01-05)"
)
return data
def _live_pipeline_final_html_sha(
mdx_file: str, runs_root: Path
) -> tuple[str, int, int | None]:
"""Run the full pipeline once under flag OFF and SHA the on-disk final.html.
``pz2.RUNS_DIR`` MUST be pinned to ``runs_root`` by the caller (via
monkeypatch); ``run_phase_z2_mvp1`` writes final.html to
``<pz2.RUNS_DIR>/<run_id>/phase_z2/final.html``.
``SystemExit`` is caught (mirrors the capture script): the IMP-87
EMPTY_SHELL_NO_CONTENT BLOCKED exit on mdx 05 (and any analogous
terminal state) fires AFTER the final.html write site at
``src/phase_z2_pipeline.py:5994-5996``, so the artifact still exists
on disk and the SHA is the load-bearing parity axis. The exit code
is returned so the per-file test can assert the same terminal state
under flag OFF (any drift in exit semantics is a separate u3 axis
but surfaces here too).
The IMP-94 ``data-region-id`` / ``data-content-unit-id`` tokens are
stripped from the live bytes via ``_strip_imp94_markers`` before
SHA-256 hashing so the resulting hash matches the pre-stamper
frozen baseline (issue #94 guardrail: ``byte-equivalent except for
new data-* attrs``). ``size_bytes`` reports the size of the
normalized bytes that were actually hashed diagnostic surface for
SHA mismatch error messages.
Returns ``(sha256_hex, size_bytes, pipeline_exit_code)``.
"""
mdx_path = _SAMPLES_DIR / mdx_file
assert mdx_path.exists(), f"sample missing: {mdx_path}"
run_id = f"89a_live_{mdx_path.stem}"
pipeline_exit_code: int | None = None
try:
pz2.run_phase_z2_mvp1(mdx_path, run_id=run_id)
except SystemExit as exc:
pipeline_exit_code = (
int(exc.code) if isinstance(exc.code, int) else 1
)
final_html_path = runs_root / run_id / "phase_z2" / "final.html"
assert final_html_path.exists(), (
f"final.html not written by pipeline: {final_html_path} "
f"(pipeline_exit_code={pipeline_exit_code})"
)
raw_bytes = final_html_path.read_bytes()
normalized_bytes = _strip_imp94_markers(raw_bytes)
return (
hashlib.sha256(normalized_bytes).hexdigest(),
len(normalized_bytes),
pipeline_exit_code,
)
@pytest.mark.integration
@pytest.mark.parametrize("mdx_file", _MDX_BATCH)
def test_post_89a_flag_off_final_html_sha_matches_frozen_baseline(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
mdx_file: str,
baseline: dict,
) -> None:
"""Per-mdx-file final.html SHA parity guard via the FULL pipeline.
Runs ``run_phase_z2_mvp1`` end-to-end with ``PHASE_Z_B4_MAPPER_SOURCE``
unset, reads the on-disk ``final.html``, hashes its bytes, and asserts
SHA equality with the frozen baseline. Any drift in the selector,
mapper, render_slide, slide_base.html, family partials, or any
upstream step manifests here as a SHA mismatch.
"""
monkeypatch.delenv(FLAG, raising=False)
assert _b4_mapper_source_enabled() is False
runs_root = tmp_path / "runs"
runs_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(pz2, "RUNS_DIR", runs_root)
expected = baseline["mdx_files"][mdx_file]
expected_sha = expected["sha256"]
expected_size = expected["final_html_size_bytes"]
expected_exit = expected.get("pipeline_exit_code")
live_sha, live_size, live_exit = _live_pipeline_final_html_sha(
mdx_file, runs_root
)
assert live_sha == expected_sha, (
f"final.html SHA parity broken for {mdx_file}:\n"
f" frozen pre-89-a baseline : {expected_sha} ({expected_size} bytes)\n"
f" live post-89-a flag-OFF : {live_sha} ({live_size} bytes)\n"
f" -> upstream drift in selector / mapper / render_slide / "
f"slide_base.html / family partials / pipeline step. Re-capture "
f"the baseline ONLY if the delta is deliberate and reviewed."
)
assert live_exit == expected_exit, (
f"pipeline exit-code parity broken for {mdx_file}: "
f"baseline={expected_exit} live={live_exit} — SHA matches but "
f"the pipeline terminal state diverged (IMP-87 BLOCKED axis)."
)
@pytest.mark.integration
def test_post_89a_flag_off_final_html_sha_holistic_sweep(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
baseline: dict,
) -> None:
"""Single-pass holistic sweep across mdx 01-05.
Aggregates the per-file SHA parity into one sweep so an accidental
parametrize zero-iteration cannot silently pass. Asserts (a) env gate
stays False throughout, (b) every baseline file's live SHA matches the
frozen value, (c) sweep coverage equals the full _MDX_BATCH.
"""
monkeypatch.delenv(FLAG, raising=False)
assert _b4_mapper_source_enabled() is False
runs_root = tmp_path / "runs"
runs_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(pz2, "RUNS_DIR", runs_root)
matched = 0
for mdx_file in _MDX_BATCH:
expected = baseline["mdx_files"][mdx_file]
live_sha, live_size, _live_exit = _live_pipeline_final_html_sha(
mdx_file, runs_root
)
assert live_sha == expected["sha256"], (
f"sweep mismatch on {mdx_file}: baseline {expected['sha256']} "
f"≠ live {live_sha} (size baseline={expected['final_html_size_bytes']} "
f"live={live_size})"
)
matched += 1
assert matched == len(_MDX_BATCH), (
f"sweep covered only {matched}/{len(_MDX_BATCH)} mdx files — "
f"coverage too shallow to guard final.html SHA parity."
)
@pytest.mark.parametrize("mdx_file", _MDX_BATCH)
def test_flag_off_selector_returns_legacy_input_per_section(
monkeypatch: pytest.MonkeyPatch, mdx_file: str
) -> None:
"""Algebraic precondition: ``_select_mapper_template_id(plan, T) == T``
under flag OFF for every section parsed from each mdx 01-05 file.
This is the property that makes the full-pipeline SHA parity hold
mathematically. The on-disk SHA tests above are the load-bearing
proof; this localizes the failure axis when SHA parity breaks.
Pure Python, no pipeline execution fast unit-level check.
"""
monkeypatch.delenv(FLAG, raising=False)
assert _b4_mapper_source_enabled() is False
mdx_path = _SAMPLES_DIR / mdx_file
_title, sections, _footer = parse_mdx(mdx_path)
frame_contracts = list(load_frame_contracts().values())
legacy_pick = "LEGACY_V4_RANK_1_SENTINEL"
for section in sections:
content_objects = extract_content_objects(section, source_shape=None)
plan = plan_placement(
content_objects=content_objects,
frame_contracts=frame_contracts,
section_id=section.section_id,
)
resolved = _select_mapper_template_id(plan, legacy_pick)
assert resolved == legacy_pick, (
f"flag-OFF selector contract broken in {mdx_file} section "
f"{section.section_id}: B4 picked "
f"'{plan.selected_template_id}' but selector returned "
f"'{resolved}' (expected '{legacy_pick}')"
)
@pytest.mark.parametrize("mdx_file", _MDX_BATCH)
def test_flag_off_holds_when_b4_mismatches_legacy(
monkeypatch: pytest.MonkeyPatch, mdx_file: str
) -> None:
"""Highest-risk divergence scenario: B4 picks a template DIFFERENT
from the legacy V4 rank-1 input. Under flag OFF the selector MUST
still return the legacy input never the B4 pick. Catches the axis
that would otherwise be invisible in the matches_mapper case.
"""
monkeypatch.delenv(FLAG, raising=False)
mdx_path = _SAMPLES_DIR / mdx_file
_title, sections, _footer = parse_mdx(mdx_path)
frame_contracts = list(load_frame_contracts().values())
legacy_distinct = "__89A_U4_SENTINEL_LEGACY_DISTINCT_FROM_B4__"
for section in sections:
content_objects = extract_content_objects(section, source_shape=None)
plan = plan_placement(
content_objects=content_objects,
frame_contracts=frame_contracts,
section_id=section.section_id,
)
assert plan.selected_template_id != legacy_distinct
resolved = _select_mapper_template_id(plan, legacy_distinct)
assert resolved == legacy_distinct, (
f"flag-OFF selector must return legacy input even when B4 "
f"would pick differently ({mdx_file} {section.section_id}: "
f"b4_pick={plan.selected_template_id} resolved={resolved})"
)
def test_flag_off_default_state_locks_gate_to_false() -> None:
"""Pin the default-OFF contract at the gate level.
``_b4_mapper_source_enabled()`` returning False under default env is
the precondition for SHA parity: the u3 BLOCKED gates and the u2
switch are both gated on this returning True, so a False default
guarantees the legacy code path remains intact.
"""
assert os.environ.get(FLAG, "") == ""
assert _b4_mapper_source_enabled() is False
def test_baseline_fixture_is_load_bearing(baseline: dict) -> None:
"""Sanity-check the frozen baseline file shape so a corrupted /
half-written fixture does not silently pass the SHA tests with zero
iterations.
"""
assert baseline["axis"].startswith("IMP-89 89-a u4")
assert set(baseline["mdx_files"].keys()) == set(_MDX_BATCH), (
f"baseline mdx coverage drift: {baseline['mdx_files'].keys()} "
f"vs expected {_MDX_BATCH}"
)
for mdx_file, entry in baseline["mdx_files"].items():
assert "sha256" in entry, f"baseline {mdx_file} missing sha256 key"
assert "final_html_size_bytes" in entry, (
f"baseline {mdx_file} missing final_html_size_bytes key"
)
sha = entry["sha256"]
assert isinstance(sha, str) and len(sha) == 64, (
f"baseline {mdx_file} sha256 is not a 64-char hex string: {sha!r}"
)
size = entry["final_html_size_bytes"]
assert isinstance(size, int) and size > 0, (
f"baseline {mdx_file} final_html_size_bytes is not positive int: "
f"{size!r}"
)
assert baseline["renderer"]["entrypoint"] == (
"src.phase_z2_pipeline.run_phase_z2_mvp1"
), (
"baseline renderer.entrypoint must be the full pipeline entry — "
f"got {baseline['renderer'].get('entrypoint')!r}. The previous "
"synthetic render_slide shape produced a fragment, not final.html."
)
View File
+112
View File
@@ -0,0 +1,112 @@
"""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 re
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
IMP95_BOARD_PATH = (
REPO_ROOT / "docs" / "architecture" / "PHASE-Z-PIPELINE-STATUS-BOARD.md"
)
IMP95_MARKER_RE = re.compile(
r"(<!-- IMP-95:(j\d+) -->)(.*?)(<!-- /IMP-95 -->)", re.DOTALL
)
IMP95_EXPECTED_AXES = {"j1", "j2", "j3", "j4", "j5", "j6", "j7", "j8"}
IMP95_ALLOWED_VALUES = {"pending", "trace-only", "guarded", "active"}
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 -->"
def test_imp95_markers_present_and_well_formed() -> None:
"""IMP-95 sub-axis markers all present in section 9 with allowed values."""
board = IMP95_BOARD_PATH.read_text(encoding="utf-8")
matches = IMP95_MARKER_RE.findall(board)
axes = {axis for _, axis, _, _ in matches}
assert axes == IMP95_EXPECTED_AXES, (
f"IMP-95 axes drift: expected {IMP95_EXPECTED_AXES}, got {axes}"
)
for _, axis, value, _ in matches:
assert value in IMP95_ALLOWED_VALUES, (
f"IMP-95 {axis} value {value!r} outside allowed set {IMP95_ALLOWED_VALUES}"
)
def test_imp95_markers_isolated_from_imp91_updater() -> None:
"""`update_board_text` (IMP-91) MUST NOT rewrite IMP-95 markers — distinct closing tag."""
sample = (
"<!-- IMP-95:j1 -->trace-only<!-- /IMP-95 --> "
"<!-- IMP-91:F0:01 -->?<!-- /IMP-91 -->"
)
outcomes = {("F0", "01"): "PASS"}
rewritten = usb.update_board_text(sample, outcomes)
assert "<!-- IMP-95:j1 -->trace-only<!-- /IMP-95 -->" in rewritten
assert "<!-- IMP-91:F0:01 -->PASS<!-- /IMP-91 -->" in rewritten
def test_imp95_marker_rewrite_is_idempotent() -> None:
"""Regex-driven IMP-95 cell rewrite is byte-identical on second application."""
board = "<!-- IMP-95:j1 -->pending<!-- /IMP-95 -->"
def rewrite(text: str, value: str) -> str:
return IMP95_MARKER_RE.sub(
lambda m: f"{m.group(1)}{value}{m.group(4)}", text
)
once = rewrite(board, "trace-only")
twice = rewrite(once, "trace-only")
assert once == twice == "<!-- IMP-95:j1 -->trace-only<!-- /IMP-95 -->"
+89 -3
View File
@@ -26,13 +26,19 @@ def _record(
apply_status: str | None = None,
error: str | None = None,
source_section_ids: list[str] | None = None,
api_error_kind: str | None = None,
) -> dict:
"""Minimal Step 12 AI repair record stub — fields u8 reads."""
"""Minimal Step 12 AI repair record stub — fields u8 reads.
IMP-92 u3 ``api_error_kind`` is stamped by Step 12 (u2 classifier)
on the exception path; non-error paths leave it ``None``.
"""
return {
"unit_index": unit_index,
"source_section_ids": source_section_ids or [f"MOCK_S{unit_index}"],
"apply_status": apply_status,
"error": error,
"api_error_kind": api_error_kind,
}
@@ -45,7 +51,11 @@ _VIOLATED_COVERAGE = {"status": "violated", "dropped_section_ids": ["MOCK_S2"]}
def test_empty_records_returns_ok_no_human_review():
"""No AI work executed → status='ok', human_review_required=False.
The flag-off default (no provisional units) lands here."""
The flag-off default (no provisional units) lands here.
IMP-92 u3 ``api_error_kinds`` aggregation is always present with
every kind initialised to 0 so the frontend operational formatter
can read the bucket structure unconditionally."""
result = _summarize_ai_repair_status([], _OK_COVERAGE)
assert result["status"] == "ok"
assert result["human_review_required"] is False
@@ -53,6 +63,12 @@ def test_empty_records_returns_ok_no_human_review():
assert result["unsupported_kind_records"] == []
assert result["error_records"] == []
assert result["dropped_section_ids"] == []
assert result["api_error_kinds"] == {
"quota": 0,
"billing": 0,
"auth": 0,
"other": 0,
}
# ─── Case 2 : applied → status='applied', no human_review ───────────
@@ -102,7 +118,11 @@ def test_unsupported_kind_marks_human_review_required():
def test_gather_error_marks_status_error_with_records():
"""``record['error']`` set means ``gather_step12_ai_repair_proposals``
caught a router exception (AI call / validator). status='error'
is the highest-priority failure axis."""
is the highest-priority failure axis.
IMP-92 u3 non-Anthropic exception path leaves ``api_error_kind``
as ``None``; the summary retains ``None`` per-record and does not
increment any operational kind bucket."""
records = [_record(
unit_index=2,
error="ValueError: missing slot 'title'",
@@ -117,8 +137,74 @@ def test_gather_error_marks_status_error_with_records():
"unit_index": 2,
"source_section_ids": ["MOCK_S2"],
"error": "ValueError: missing slot 'title'",
"api_error_kind": None,
}
]
assert result["api_error_kinds"] == {
"quota": 0,
"billing": 0,
"auth": 0,
"other": 0,
}
# ─── IMP-92 u3 : api_error_kind propagation + aggregation ───────────
def test_api_error_kind_quota_propagates_to_summary_and_record():
"""Step 12 (u2) stamps ``api_error_kind='quota'`` on a 429
Anthropic exception path. u8 must surface that kind per-record
and increment the ``quota`` bucket in ``api_error_kinds``."""
records = [_record(
unit_index=3,
error="RateLimitError: 429",
source_section_ids=["MOCK_S3"],
api_error_kind="quota",
)]
result = _summarize_ai_repair_status(records, _OK_COVERAGE)
assert result["status"] == "error"
assert result["human_review_required"] is True
assert result["error_records"] == [
{
"unit_index": 3,
"source_section_ids": ["MOCK_S3"],
"error": "RateLimitError: 429",
"api_error_kind": "quota",
}
]
assert result["api_error_kinds"] == {
"quota": 1,
"billing": 0,
"auth": 0,
"other": 0,
}
def test_api_error_kinds_aggregate_across_all_operational_axes():
"""Mixed batch — one of each operational kind (quota / billing /
auth / other). Aggregation must count each axis exactly once and
keep per-record kinds intact (order preserved)."""
records = [
_record(unit_index=0, error="RateLimitError", api_error_kind="quota"),
_record(unit_index=1, error="PermissionDeniedError", api_error_kind="billing"),
_record(unit_index=2, error="AuthenticationError", api_error_kind="auth"),
_record(unit_index=3, error="BadRequestError", api_error_kind="other"),
]
result = _summarize_ai_repair_status(records, _OK_COVERAGE)
assert result["status"] == "error"
assert result["counts"]["error"] == 4
assert result["api_error_kinds"] == {
"quota": 1,
"billing": 1,
"auth": 1,
"other": 1,
}
assert [rec["api_error_kind"] for rec in result["error_records"]] == [
"quota",
"billing",
"auth",
"other",
]
# ─── Case 5 : coverage violated → status='coverage_violated' ────────
+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)",
}

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