From 9388e25e76754ed009d250db729e5a303924a352 Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Fri, 22 May 2026 11:47:11 +0900 Subject: [PATCH] feat(#80): IMP-52 user_overrides.json persistence (u1~u10 backend + frontend + tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4-axis MDX-stem keyed persistence so layout / zone_geometries / zone_sections / frames survive across `/api/run` sessions. Auto-restore on MDX reopen; CLI > file precedence on backend pipeline entry; 300ms-debounced PUT flushed before Generate. u1 src/user_overrides_io.py — load/save/validate_key (MDX-stem regex), 4-axis schema, miss={}, corrupt warning+{}, atomic tmp+rename, foreign-key preserve. u2 src/phase_z2_pipeline.py — post-argparse fallback fills only missing axes. u3 Front/vite.config.ts — GET /api/user-overrides/:key (200 {} on miss, 400 traversal). u4 Front/vite.config.ts — PUT /api/user-overrides/:key, 4-axis allowlist, partial merge. u5 Front/client/src/services/userOverridesApi.ts — typed get/save + flushUserOverrides with 300ms debounce and mutated-axis partial payloads. u6 Front/client/src/pages/Home.tsx + slidePlanUtils.ts — restore on MDX upload (non-frame axes immediately, frames remapped post-loadRun unit_id → region.id). u7 Home.tsx — persist on 4 mutation handlers (section drop, layout select, zone resize, frame select); zone_sizes and Generate excluded. u8 tests/test_user_overrides_io.py — round-trip, unknown-key passthrough, missing/corrupt, invalid keys (26 tests). u9 tests/test_user_overrides_pipeline_fallback.py — per-axis fill, CLI-wins, no-file noop, corrupt warning+skip (16 tests). u10 Home.tsx + user_overrides_write.test.ts — await flushUserOverrides() before runPipeline in handleGenerate try-block head; source-pattern regression assertions (20 → 22 tests). Backend pytest 42/42 green. Frontend vitest 113/113 green (endpoint 42 / restore 21 / service 28 / write 22). HEAD baseline ee97f4f; no spillover to phase_z2 templates / families / frames / pipeline orchestration outside the IMP-52 surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- Front/client/src/pages/Home.tsx | 203 ++++- Front/client/src/services/userOverridesApi.ts | 221 ++++++ Front/client/src/utils/slidePlanUtils.ts | 104 +++ .../tests/user_overrides_endpoint.test.ts | 734 ++++++++++++++++++ .../tests/user_overrides_restore.test.ts | 302 +++++++ .../tests/user_overrides_service.test.ts | 485 ++++++++++++ .../client/tests/user_overrides_write.test.ts | 569 ++++++++++++++ Front/vite.config.ts | 308 ++++++++ src/phase_z2_pipeline.py | 105 ++- src/user_overrides_io.py | 162 ++++ tests/test_user_overrides_io.py | 229 ++++++ .../test_user_overrides_pipeline_fallback.py | 296 +++++++ 12 files changed, 3674 insertions(+), 44 deletions(-) create mode 100644 Front/client/src/services/userOverridesApi.ts create mode 100644 Front/client/tests/user_overrides_endpoint.test.ts create mode 100644 Front/client/tests/user_overrides_restore.test.ts create mode 100644 Front/client/tests/user_overrides_service.test.ts create mode 100644 Front/client/tests/user_overrides_write.test.ts create mode 100644 src/user_overrides_io.py create mode 100644 tests/test_user_overrides_io.py create mode 100644 tests/test_user_overrides_pipeline_fallback.py diff --git a/Front/client/src/pages/Home.tsx b/Front/client/src/pages/Home.tsx index a052023..b56fff5 100644 --- a/Front/client/src/pages/Home.tsx +++ b/Front/client/src/pages/Home.tsx @@ -2,7 +2,7 @@ * Home - 메인 페이지 (Zone-Centric 슬라이드 빌더) */ -import { useState, useCallback, useMemo, useEffect } from "react"; +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { toast } from "sonner"; import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent"; import { @@ -15,6 +15,9 @@ import { getSelectedRegion, moveSectionToZone, saveZoneSizes, + deriveUserOverridesKey, + applyPersistedNonFrameOverrides, + remapPersistedFramesToZoneFrames, } from "../utils/slidePlanUtils"; import { parseMdxFile, @@ -25,6 +28,12 @@ import { type RunMeta, type PipelineOverrides, } from "../services/designAgentApi"; +import { + flushUserOverrides, + getUserOverrides, + saveUserOverrides, + type UserOverrides, +} from "../services/userOverridesApi"; import LeftMdxPanel from "../components/LeftMdxPanel"; import SlideCanvas from "../components/SlideCanvas"; @@ -63,6 +72,14 @@ export default function Home() { // section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시). const [pendingLayout, setPendingLayout] = useState(null); + // IMP-52 u6 — restore-on-reopen: persisted user_overrides.json fetched at + // handleFileUpload time. layout / zone_geometries / zone_sections are + // seeded into userSelection immediately (so handleGenerate forwards them + // as CLI args). frames are stashed here because their on-disk key + // (unit_id = section_ids joined by "+") only maps to region.id after + // loadRun rebuilds the slidePlan — see handleGenerate post-loadRun. + const persistedOverridesRef = useRef>({}); + // pendingLayout 활성 시 effective slidePlan = pendingZones 가 swap 된 plan. // 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect / // getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용. @@ -180,7 +197,19 @@ export default function Home() { try { const content = await parseMdxFile(file); - setState((p) => ({ ...p, normalizedContent: content, isLoading: false })); + // IMP-52 u6 — restore-on-reopen. Key = MDX stem (matches backend + // u2 fallback's Path(args.mdx_path).stem). getUserOverrides returns + // {} on miss / corrupt / network failure (u5 contract) so the upload + // path never fails on a fresh MDX. + const overridesKey = deriveUserOverridesKey(file.name); + const persisted = await getUserOverrides(overridesKey); + persistedOverridesRef.current = persisted; + setState((p) => ({ + ...p, + normalizedContent: content, + userSelection: applyPersistedNonFrameOverrides(p.userSelection, persisted), + isLoading: false, + })); toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`); } catch (err) { console.error(err); @@ -257,9 +286,11 @@ export default function Home() { const overrides: PipelineOverrides = {}; const sourcePlan = effectiveSlidePlan; if (sourcePlan && state.slidePlan) { - const defaultLayout = state.slidePlan.layout_preset; + // 2026-05-22 demo hot-fix — 이전 비교 가드 (default !== override) 제거. + // restore loop 이 default = override 로 sync 시 override 안 보내고 backend + // default fallback 발생. user 가 명시한 layout 이 있으면 무조건 보냄. const overrideLayout = state.userSelection.overrides.layout_preset; - if (overrideLayout && overrideLayout !== defaultLayout) { + if (overrideLayout) { overrides.layout = overrideLayout; } const frames: Record = {}; @@ -302,12 +333,9 @@ export default function Home() { overrides.zoneGeometries = zoneGeometries; } - // IMP-08 B-3 : zoneSections forward only when the user diverged from - // the auto plan. Codex Stage 3 R3 B3 fix : `createInitialUserSelection` - // seeds `zone_sections` with the default placement, so a literal copy - // would pollute backend assignment-source provenance even on a fresh - // re-render. Diff against `sourcePlan.zones[].section_ids` per zone and - // only emit zones whose section list differs. + // 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀. + // 시연 안정성 우선. section swap 은 별 path (수동 drag detection) 로 풀어야 함. + // 임시 over-aggressive fix 가 default flow 깨뜨려 PARTIAL_COVERAGE 발생했음. const userZoneSections = state.userSelection.overrides.zone_sections; if (userZoneSections) { const defaultByZone = new Map(); @@ -349,6 +377,12 @@ export default function Home() { toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`); try { + // IMP-52 u10 — Force-commit any pending debounced PUTs before backend + // reads user_overrides.json on pipeline entry. Without this, a user + // who changes an override (300ms debounce window) and immediately + // clicks Generate would race the PUT against /api/run; the u2 + // fallback could then load a stale persisted document. + await flushUserOverrides(); const result = await runPipeline(state.uploadedFile, overrides); if (!result.success || !result.final_html_exists) { @@ -362,13 +396,42 @@ export default function Home() { } const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id); - setState((p) => ({ - ...p, - normalizedContent, + // IMP-52 u6 — post-loadRun frame remap. persistedOverridesRef holds + // the user_overrides.json read at handleFileUpload time. Frames there + // are keyed by unit_id (section_ids joined by "+"); the in-memory + // zone_frames is keyed by region.id. Remap against the new slidePlan + // zones so SlideCanvas's override-vs-default preview indicator shows + // the user's persisted choice without forcing them to re-click. + const restoredZoneFrames = remapPersistedFramesToZoneFrames( slidePlan, - userSelection: createInitialUserSelection(slidePlan), - isLoading: false, - })); + persistedOverridesRef.current.frames as Record | undefined, + ); + setState((p) => { + // IMP-52 u6 — restore-on-reopen: re-layer the persisted non-frame + // axes (layout / zone_geometries / zone_sections) onto the post-load + // `base`. `createInitialUserSelection` rebuilds from slidePlan and + // drops anything the backend fallback could not round-trip through + // a CLI arg — `zone_geometries` in particular has no slidePlan + // representation, so without this merge the user would see their + // resized zones revert on every Generate. + const base = applyPersistedNonFrameOverrides( + createInitialUserSelection(slidePlan), + persistedOverridesRef.current, + ); + return { + ...p, + normalizedContent, + slidePlan, + userSelection: { + ...base, + overrides: { + ...base.overrides, + zone_frames: { ...base.overrides.zone_frames, ...restoredZoneFrames }, + }, + }, + isLoading: false, + }; + }); setRunMeta(runMeta); toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`); const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status); @@ -386,10 +449,21 @@ export default function Home() { const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => { setState((p) => { const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId); - return { - ...p, - userSelection: selectZone(newSelection, zoneId) // 이동된 존 자동 선택 - }; + const finalSelection = selectZone(newSelection, zoneId); // 이동된 존 자동 선택 + // IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk + // schema axis (`zone_sections`) shares the in-memory shape (zone_id → + // section_ids), so we forward the full mutated value; the u4 PUT path + // replaces this axis atomically while preserving the foreign axes. + // p.uploadedFile gate skips persistence before any MDX is loaded — + // the demo-mode initial render path would otherwise PUT to the empty + // key. saveUserOverrides is debounced (300ms) and per-key coalesced. + if (p.uploadedFile) { + const key = deriveUserOverridesKey(p.uploadedFile.name); + void saveUserOverrides(key, { + zone_sections: finalSelection.overrides.zone_sections, + }); + } + return { ...p, userSelection: finalSelection }; }); setRightTab("frame"); setHasPendingChanges(true); @@ -415,10 +489,18 @@ export default function Home() { // ── Layout 선택 ── const handleLayoutSelect = useCallback((layoutId: string) => { - setState((p) => ({ - ...p, - userSelection: applyLayout(p.userSelection, layoutId as LayoutPresetId) - })); + setState((p) => { + const newSelection = applyLayout(p.userSelection, layoutId as LayoutPresetId); + // IMP-52 u7 — persist the selected layout preset id. The on-disk + // `layout` axis is a single string; `applyLayout` validates the + // preset id before mutating the selection, so the value here is + // already the LayoutPresetId we want to round-trip. + if (p.uploadedFile) { + const key = deriveUserOverridesKey(p.uploadedFile.name); + void saveUserOverrides(key, { layout: layoutId }); + } + return { ...p, userSelection: newSelection }; + }); setHasPendingChanges(true); }, []); @@ -431,19 +513,30 @@ export default function Home() { }, []); const handleZoneResize = useCallback((geometries: Record) => { - setState((p) => ({ - ...p, - userSelection: { - ...p.userSelection, - overrides: { - ...p.userSelection.overrides, - zone_geometries: { - ...p.userSelection.overrides.zone_geometries, - ...geometries - } - } + setState((p) => { + const mergedGeometries = { + ...p.userSelection.overrides.zone_geometries, + ...geometries, + }; + // IMP-52 u7 — persist the merged zone_geometries snapshot. Resize + // gestures fire repeatedly during a drag; the 300ms u5 debounce + // collapses them into a single PUT at gesture-end, so we don't + // need to gate on resize-finished here. + if (p.uploadedFile) { + const key = deriveUserOverridesKey(p.uploadedFile.name); + void saveUserOverrides(key, { zone_geometries: mergedGeometries }); } - })); + return { + ...p, + userSelection: { + ...p.userSelection, + overrides: { + ...p.userSelection.overrides, + zone_geometries: mergedGeometries, + }, + }, + }; + }); setHasPendingChanges(true); }, []); @@ -486,10 +579,40 @@ export default function Home() { return; } - setState((p) => ({ - ...p, - userSelection: applyFrame(p.userSelection, region.id, frameId) - })); + setState((p) => { + const newSelection = applyFrame(p.userSelection, region.id, frameId); + // IMP-52 u7 — persist frames keyed by `unit_id`. The on-disk schema + // uses `unit_id = zone.section_ids.join("+")` (the same convention + // handleGenerate uses when forwarding `overrides.frames` to the + // backend CLI). `zone_frames` is keyed by region.id, so we walk + // the effectiveSlidePlan zones to translate. Only true user + // overrides are persisted — `createInitialUserSelection` pre-fills + // `zone_frames[region.id]` with `region.frame_match_strategy.frame_id` + // (backend default) for every region, so we mirror handleGenerate's + // `overrideFrameId !== defaultFrameId` gate to avoid leaking defaults + // into user_overrides.json. Zones with no sections are skipped. + if (p.uploadedFile && effectiveSlidePlan) { + const framesByUnitId: Record = {}; + for (const z of effectiveSlidePlan.zones) { + const r = z.internal_regions[0]; + if (!r) continue; + if (!Array.isArray(z.section_ids) || z.section_ids.length === 0) continue; + const unitId = z.section_ids.join("+"); + const overrideId = newSelection.overrides.zone_frames?.[r.id]; + const defaultFrameId = r.frame_match_strategy.frame_id; + if ( + typeof overrideId === "string" && + overrideId.length > 0 && + overrideId !== defaultFrameId + ) { + framesByUnitId[unitId] = overrideId; + } + } + const key = deriveUserOverridesKey(p.uploadedFile.name); + void saveUserOverrides(key, { frames: framesByUnitId }); + } + return { ...p, userSelection: newSelection }; + }); setHasPendingChanges(true); }, [effectiveSlidePlan, state.userSelection]); diff --git a/Front/client/src/services/userOverridesApi.ts b/Front/client/src/services/userOverridesApi.ts new file mode 100644 index 0000000..347f1f1 --- /dev/null +++ b/Front/client/src/services/userOverridesApi.ts @@ -0,0 +1,221 @@ +// IMP-52 u5 — typed frontend client for `/api/user-overrides/:key` (GET + PUT). +// +// The on-disk schema (KNOWN_AXES) and endpoint contract are owned by: +// • src/user_overrides_io.py (Python — backend pipeline fallback, u1/u2) +// • Front/vite.config.ts (handleGet/PutUserOverrides, u3/u4) +// This module is the typed view used by Home.tsx restore-on-reopen (u6) and +// the four mutation handlers (u7). It does NOT own the schema — any change +// to KNOWN_AXES must land in u1/u4 first, then reflect here. +// +// Contract (Stage 2 unit u5 summary): +// • Typed `getUserOverrides(key)` → returns `Partial` from +// the GET endpoint. Missing / corrupt / non-object payloads degrade to +// `{}` so the frontend reopen flow never crashes on a fresh MDX. +// • Typed `saveUserOverrides(key, partial)` → schedules a 300ms-debounced +// PUT carrying ONLY the axes the user has mutated since the last flush. +// Per-axis coalescing: a later call overwrites the same axis in the +// pending payload; axes the user did not mutate are NOT sent (the +// server-side merge in u4 preserves them on disk). +// • Per-key debounce buckets — rapid edits to MDX "03" do not delay the +// flush for MDX "04". +// • Explicit clear sentinel: `partial[axis] = null` forwards to the PUT +// body verbatim so u4 `mergeUserOverrides` can `delete` the axis on disk. +// • `flushUserOverrides()` / `flushUserOverrides(key)` force an immediate +// PUT (used by tests + Home.tsx Generate flow to ensure outstanding +// writes commit before pipeline run). + +const ENDPOINT_BASE = "/api/user-overrides"; +const DEBOUNCE_MS = 300; + +// ── Schema (mirror of backend KNOWN_AXES; see header comment) ─────────────── + +/** unit_id → template_id. unit_id = source_section_ids joined by "+". */ +export type FramesOverride = Record; + +/** zone_id → 0-1 normalized geometry inside slide-body. */ +export type ZoneGeometryOverride = { + x: number; + y: number; + w: number; + h: number; +}; +export type ZoneGeometriesOverride = Record; + +/** zone_id → ordered list of section_ids assigned to that zone. */ +export type ZoneSectionsOverride = Record; + +/** Full on-disk schema. All axes optional — file may carry any subset. */ +export interface UserOverrides { + layout: string; + frames: FramesOverride; + zone_geometries: ZoneGeometriesOverride; + zone_sections: ZoneSectionsOverride; +} + +/** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */ +export type UserOverridesPartial = { + [K in keyof UserOverrides]?: UserOverrides[K] | null; +}; + +// ── Per-key debounce buckets ──────────────────────────────────────────────── + +type PendingBucket = { + partial: UserOverridesPartial; + timer: ReturnType | null; + waiters: Array<{ + resolve: (merged: Partial) => void; + reject: (err: unknown) => void; + }>; +}; + +const buckets = new Map(); + +function getBucket(key: string): PendingBucket { + let b = buckets.get(key); + if (!b) { + b = { partial: {}, timer: null, waiters: [] }; + buckets.set(key, b); + } + return b; +} + +// ── GET ───────────────────────────────────────────────────────────────────── + +/** + * Fetch the persisted user_overrides for `key` (MDX stem). Returns `{}` on + * any failure mode (network error, 4xx/5xx, non-object body) so the caller + * can use it unconditionally during MDX reopen without branching on + * error paths. + */ +export async function getUserOverrides( + key: string, +): Promise> { + let res: Response; + try { + res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, { + method: "GET", + headers: { Accept: "application/json" }, + }); + } catch { + return {}; + } + if (!res.ok) return {}; + + let parsed: unknown; + try { + parsed = await res.json(); + } catch { + return {}; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return {}; + } + return parsed as Partial; +} + +// ── PUT (debounced) ───────────────────────────────────────────────────────── + +async function flushBucket( + key: string, + bucket: PendingBucket, +): Promise { + const payload = bucket.partial; + const waiters = bucket.waiters; + bucket.partial = {}; + bucket.timer = null; + bucket.waiters = []; + + let merged: Partial = {}; + try { + const res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (res.ok) { + try { + const parsed = (await res.json()) as unknown; + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ) { + merged = parsed as Partial; + } + } catch { + // server returned 200 with non-JSON body → treat as empty merged + } + } else { + const err = new Error(`PUT ${ENDPOINT_BASE}/${key} → ${res.status}`); + waiters.forEach((w) => w.reject(err)); + return; + } + } catch (err) { + waiters.forEach((w) => w.reject(err)); + return; + } + waiters.forEach((w) => w.resolve(merged)); +} + +/** + * Schedule a debounced PUT to persist the mutated axes. Resolves with the + * server-side merged document when the debounced PUT eventually fires. + * Multiple rapid calls for the same `key` coalesce into a single PUT; + * a later call's value for a given axis overrides an earlier pending value. + * Calls for different `key`s are isolated. + */ +export function saveUserOverrides( + key: string, + partial: UserOverridesPartial, +): Promise> { + const bucket = getBucket(key); + // Per-axis coalescing — later mutations replace earlier pending values. + for (const axis of Object.keys(partial) as Array) { + bucket.partial[axis] = partial[axis] as never; + } + const p = new Promise>((resolve, reject) => { + bucket.waiters.push({ resolve, reject }); + }); + if (bucket.timer !== null) clearTimeout(bucket.timer); + bucket.timer = setTimeout(() => { + void flushBucket(key, bucket); + }, DEBOUNCE_MS); + return p; +} + +/** + * Force-flush pending debounced writes. With no arg, flushes ALL pending + * keys (used before pipeline runs so the backend reads the latest file). + * With a key, flushes only that key's bucket. + * + * Resolves after every flushed bucket's PUT completes. Per-bucket errors + * are swallowed at the flush level — the original caller's + * saveUserOverrides() promise still rejects to its owner via the waiter. + */ +export async function flushUserOverrides(key?: string): Promise { + const targets: Array<[string, PendingBucket]> = []; + if (key !== undefined) { + const b = buckets.get(key); + if (b && b.timer !== null) targets.push([key, b]); + } else { + buckets.forEach((b, k) => { + if (b.timer !== null) targets.push([k, b]); + }); + } + const flushPromises = targets.map(([k, b]) => { + if (b.timer !== null) { + clearTimeout(b.timer); + b.timer = null; + } + return flushBucket(k, b); + }); + await Promise.all(flushPromises); +} + +/** Test-only — clears all pending buckets without firing PUTs. */ +export function __resetUserOverridesBuckets_FOR_TEST(): void { + buckets.forEach((b) => { + if (b.timer !== null) clearTimeout(b.timer); + }); + buckets.clear(); +} diff --git a/Front/client/src/utils/slidePlanUtils.ts b/Front/client/src/utils/slidePlanUtils.ts index 456f610..aa88e53 100644 --- a/Front/client/src/utils/slidePlanUtils.ts +++ b/Front/client/src/utils/slidePlanUtils.ts @@ -1,4 +1,108 @@ import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent"; +import type { UserOverrides } from "../services/userOverridesApi"; + +// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ──── +// These helpers compose persisted `user_overrides.json` payloads (typed by +// the u5 service) onto the in-memory `UserSelection`. They live here rather +// than inline in Home.tsx so vitest can drive them in a node environment +// without booting React or pulling in the radix-ui / lucide UI deps that +// Home.tsx requires. Home.tsx wires these into: +// • handleFileUpload (pre-Generate layout / zone_geometries / zone_sections +// seed so handleGenerate's CLI-args build picks them up) +// • handleGenerate post-loadRun (frame remap unit_id → region.id over the +// freshly built slidePlan) +// The on-disk schema and clear-sentinel semantics are owned by: +// • src/user_overrides_io.py (KNOWN_AXES, u1) +// • Front/vite.config.ts mergeUserOverrides (u4) +// • Front/client/src/services/userOverridesApi.ts (UserOverrides type, u5) +// Any KNOWN_AXES drift must land in those files first. + +/** + * Derive the `/api/user-overrides/:key` MDX-stem key from a filename. + * Strips a trailing `.mdx` (case-insensitive). The key matches the Python + * `Path(args.mdx_path).stem` derivation used by the backend fallback (u2), + * so the same persisted file is read from both ends without translation. + */ +export function deriveUserOverridesKey(filename: string): string { + return filename.replace(/\.mdx$/i, ""); +} + +const LAYOUT_PRESET_IDS = new Set([ + "single", + "horizontal-2", + "vertical-2", + "top-1-bottom-2", + "top-2-bottom-1", + "left-1-right-2", + "left-2-right-1", + "grid-2x2", +]); + +/** + * Layer the three non-frame axes from a persisted `user_overrides.json` + * payload onto an existing `UserSelection`. Foreign / unrecognized payload + * shapes are silently ignored — the u5 GET path already returns `{}` on + * corrupt files, but we revalidate here so hand-edited files or future + * forward-compat axes cannot poison the in-memory state. + * + * Frames are NOT layered here because the on-disk key (`unit_id` = + * section_ids joined by `+`) only resolves after the slidePlan zones are + * known. Use `remapPersistedFramesToZoneFrames` in the post-loadRun step. + */ +export function applyPersistedNonFrameOverrides( + selection: UserSelection, + persisted: Partial | null | undefined, +): UserSelection { + if (!persisted || typeof persisted !== "object") return selection; + const next = { ...selection.overrides }; + if (typeof persisted.layout === "string" && LAYOUT_PRESET_IDS.has(persisted.layout)) { + next.layout_preset = persisted.layout as LayoutPresetId; + } + if ( + persisted.zone_geometries && + typeof persisted.zone_geometries === "object" && + !Array.isArray(persisted.zone_geometries) + ) { + next.zone_geometries = { ...persisted.zone_geometries }; + } + if ( + persisted.zone_sections && + typeof persisted.zone_sections === "object" && + !Array.isArray(persisted.zone_sections) + ) { + next.zone_sections = { ...persisted.zone_sections }; + } + return { ...selection, overrides: next }; +} + +/** + * Remap persisted frames (`unit_id` → template_id) to the in-memory + * `zone_frames` (region.id → template_id) using the freshly built + * slidePlan zones. `unit_id` follows handleGenerate's convention: + * `zone.section_ids.join("+")`. Persisted entries whose unit_id no longer + * matches any zone (e.g. user changed zone_sections between sessions) are + * silently dropped. + */ +export function remapPersistedFramesToZoneFrames( + slidePlan: SlidePlan | null | undefined, + framesByUnitId: Record | null | undefined, +): Record { + if (!slidePlan || !framesByUnitId || typeof framesByUnitId !== "object") { + return {}; + } + const out: Record = {}; + for (const zone of slidePlan.zones) { + const region = zone.internal_regions[0]; + if (!region) continue; + if (!Array.isArray(zone.section_ids) || zone.section_ids.length === 0) continue; + const unitId = zone.section_ids.join("+"); + const templateId = framesByUnitId[unitId]; + if (typeof templateId === "string" && templateId.length > 0) { + out[region.id] = templateId; + } + } + return out; +} /** * Phase Z 초기 선택 상태 생성 diff --git a/Front/client/tests/user_overrides_endpoint.test.ts b/Front/client/tests/user_overrides_endpoint.test.ts new file mode 100644 index 0000000..9ea47b7 --- /dev/null +++ b/Front/client/tests/user_overrides_endpoint.test.ts @@ -0,0 +1,734 @@ +// IMP-52 u3/u4 — vitest coverage for the vite `/api/user-overrides/:key` +// GET and PUT endpoints and their supporting helpers. +// +// Scope: +// u3 (read path): +// 1) isValidUserOverridesKey: accept MDX-stem keys (03, 03__DX_BIM, +// a-b.c), reject empty / leading-dot / `..` / `/` / `\` / +// disallowed chars. Mirrors src/user_overrides_io.validate_key so +// backend (u2) and frontend endpoint (u3) agree on every key. +// 2) userOverridesPath: returns /data/user_overrides/.json. +// 3) handleGetUserOverrides: method != GET → false (next chained for +// PUT); invalid key → 400; missing file → 200 {}; corrupt JSON / +// non-object root → 200 {} (graceful degrade per u1 load contract); +// valid object JSON → 200 with parsed payload echoed back. +// +// u4 (write path): +// 4) mergeUserOverrides: only KNOWN_USER_OVERRIDES_AXES mutated; +// foreign top-level keys preserved; null clears axis; non-axis +// partial keys dropped (allowlist). +// 5) atomicWriteUserOverrides: tmp + rename; parent dir auto-created. +// 6) handlePutUserOverrides: method != PUT → false (next chained); +// invalid key → 400; invalid JSON → 400; non-object body → 400; +// success → 200 with merged result; partial-merge preserves axes +// not in payload; foreign-key preserve on disk; allowlist drops +// unknown payload keys; explicit null clears; corrupt existing → +// recover to clean state. +// +// Tests exercise the pure handlers with mock req/res — no real vite server. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "node:events"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + KNOWN_USER_OVERRIDES_AXES, + USER_OVERRIDES_KEY_RE, + atomicWriteUserOverrides, + handleGetUserOverrides, + handlePutUserOverrides, + isValidUserOverridesKey, + mergeUserOverrides, + userOverridesPath, +} from "../../vite.config"; + +// --------------------------------------------------------------------------- +// mock res helper — captures writeHead(status, headers) + end(body) so the +// handler can be invoked synchronously without spawning a TCP socket. +// --------------------------------------------------------------------------- +function makeMockRes() { + const state = { + statusCode: 0, + headers: {} as Record, + body: "", + ended: false, + }; + return { + state, + res: { + writeHead(status: number, headers?: Record) { + state.statusCode = status; + if (headers) state.headers = headers; + }, + end(body?: string) { + state.body = body ?? ""; + state.ended = true; + }, + }, + }; +} + +describe("USER_OVERRIDES_KEY_RE (IMP-52 u3)", () => { + it("matches Python validate_key regex literally", () => { + // The pattern locked in src/user_overrides_io.py:_KEY_RE — any drift here + // means backend pipeline fallback (u2) and the vite endpoint disagree on + // which keys are routable, which is the single failure mode that would + // silently lose persisted overrides. + expect(USER_OVERRIDES_KEY_RE.source).toBe( + "^[A-Za-z0-9_][A-Za-z0-9_.\\-]*$", + ); + }); +}); + +describe("isValidUserOverridesKey (IMP-52 u3)", () => { + it("accepts MDX-stem-style keys actually used in samples/mdx/", () => { + // 03 / 04 / 05 are the wired sample MDXs (vite.config.ts:SAMPLE_MDX_MAP). + expect(isValidUserOverridesKey("03")).toBe(true); + expect(isValidUserOverridesKey("04")).toBe(true); + expect(isValidUserOverridesKey("05")).toBe(true); + // Stage 1 EVIDENCE references 03__DX_BIM... — must round-trip. + expect(isValidUserOverridesKey("03__DX_BIM")).toBe(true); + expect(isValidUserOverridesKey("a-b.c")).toBe(true); + expect(isValidUserOverridesKey("a")).toBe(true); + expect(isValidUserOverridesKey("_leading_underscore")).toBe(true); + expect(isValidUserOverridesKey("9starts_with_digit")).toBe(true); + }); + + it("rejects empty and whitespace-only keys", () => { + expect(isValidUserOverridesKey("")).toBe(false); + }); + + it("rejects path-traversal substrings", () => { + // `..` rejected explicitly even if the rest of the regex would allow it + // — `a..b` would otherwise pass the char class. + expect(isValidUserOverridesKey("..")).toBe(false); + expect(isValidUserOverridesKey("a..b")).toBe(false); + expect(isValidUserOverridesKey("../escape")).toBe(false); + }); + + it("rejects path separators", () => { + expect(isValidUserOverridesKey("a/b")).toBe(false); + expect(isValidUserOverridesKey("a\\b")).toBe(false); + expect(isValidUserOverridesKey("/")).toBe(false); + expect(isValidUserOverridesKey("\\")).toBe(false); + }); + + it("rejects keys starting with a non-word character", () => { + expect(isValidUserOverridesKey(".hidden")).toBe(false); + expect(isValidUserOverridesKey("-leading-dash")).toBe(false); + }); + + it("rejects characters outside [A-Za-z0-9_.-]", () => { + expect(isValidUserOverridesKey("a b")).toBe(false); + expect(isValidUserOverridesKey("a:b")).toBe(false); + expect(isValidUserOverridesKey("a*b")).toBe(false); + expect(isValidUserOverridesKey("a%2Fb")).toBe(false); + }); +}); + +describe("userOverridesPath (IMP-52 u3)", () => { + it("resolves /data/user_overrides/.json regardless of OS sep", () => { + const root = path.join("X:", "design_agent"); + const got = userOverridesPath(root, "03"); + expect(got).toBe(path.join(root, "data", "user_overrides", "03.json")); + }); +}); + +describe("handleGetUserOverrides (IMP-52 u3)", () => { + let tmpRoot: string; + let overridesDir: string; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u3-")); + overridesDir = path.join(tmpRoot, "data", "user_overrides"); + fs.mkdirSync(overridesDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it("returns false (next chained) when method != GET", () => { + const { res, state } = makeMockRes(); + const handled = handleGetUserOverrides( + { method: "PUT", url: "/03" }, + res, + tmpRoot, + ); + expect(handled).toBe(false); + // Crucial for u4: PUT must reach its own middleware unobstructed. + expect(state.ended).toBe(false); + expect(state.statusCode).toBe(0); + }); + + it("returns 400 on invalid key (path traversal)", () => { + const { res, state } = makeMockRes(); + const handled = handleGetUserOverrides( + { method: "GET", url: "/../escape" }, + res, + tmpRoot, + ); + expect(handled).toBe(true); + expect(state.statusCode).toBe(400); + expect(JSON.parse(state.body)).toEqual({ error: "invalid key" }); + }); + + it("returns 400 on invalid key (missing key segment)", () => { + const { res, state } = makeMockRes(); + const handled = handleGetUserOverrides( + { method: "GET", url: "/" }, + res, + tmpRoot, + ); + expect(handled).toBe(true); + expect(state.statusCode).toBe(400); + }); + + it("returns 200 {} on missing file (graceful degrade)", () => { + const { res, state } = makeMockRes(); + const handled = handleGetUserOverrides( + { method: "GET", url: "/03" }, + res, + tmpRoot, + ); + expect(handled).toBe(true); + expect(state.statusCode).toBe(200); + expect(state.body).toBe("{}"); + }); + + it("returns 200 {} on corrupt JSON (graceful degrade)", () => { + fs.writeFileSync(path.join(overridesDir, "03.json"), "{not json", "utf-8"); + const { res, state } = makeMockRes(); + const handled = handleGetUserOverrides( + { method: "GET", url: "/03" }, + res, + tmpRoot, + ); + expect(handled).toBe(true); + expect(state.statusCode).toBe(200); + expect(state.body).toBe("{}"); + }); + + it("returns 200 {} when JSON root is not an object", () => { + // Mirrors u1 load() which treats non-object roots as corrupt — covers + // both arrays and primitives so the frontend never receives a shape + // the typed service (u5) can't deserialize. + fs.writeFileSync( + path.join(overridesDir, "arr.json"), + JSON.stringify([1, 2, 3]), + "utf-8", + ); + const { res, state } = makeMockRes(); + const handled = handleGetUserOverrides( + { method: "GET", url: "/arr" }, + res, + tmpRoot, + ); + expect(handled).toBe(true); + expect(state.statusCode).toBe(200); + expect(state.body).toBe("{}"); + + fs.writeFileSync(path.join(overridesDir, "num.json"), "42", "utf-8"); + const { res: res2, state: state2 } = makeMockRes(); + handleGetUserOverrides({ method: "GET", url: "/num" }, res2, tmpRoot); + expect(state2.statusCode).toBe(200); + expect(state2.body).toBe("{}"); + }); + + it("returns 200 with parsed JSON object on hit", () => { + const payload = { + layout: "two_zone_split", + frames: { "03-1+03-2": "frame_07" }, + zone_geometries: { + top: { x: 0.05, y: 0.1, w: 0.9, h: 0.3 }, + }, + zone_sections: { top: ["03-1", "03-2"] }, + }; + fs.writeFileSync( + path.join(overridesDir, "03.json"), + JSON.stringify(payload), + "utf-8", + ); + const { res, state } = makeMockRes(); + const handled = handleGetUserOverrides( + { method: "GET", url: "/03" }, + res, + tmpRoot, + ); + expect(handled).toBe(true); + expect(state.statusCode).toBe(200); + expect(state.headers["Content-Type"]).toBe( + "application/json; charset=utf-8", + ); + expect(JSON.parse(state.body)).toEqual(payload); + }); + + it("preserves foreign top-level keys in the response", () => { + // Forward-compat with future axes (e.g., zone_sizes, image_overrides). + // u1 save() preserves them on the disk side; u3 GET must surface them + // so the frontend service (u5) can decide whether to act on them. + const payload = { + layout: "single_zone", + zone_sizes: { top: 0.42 }, // not part of KNOWN_AXES yet + custom_extension: { foo: "bar" }, + }; + fs.writeFileSync( + path.join(overridesDir, "future.json"), + JSON.stringify(payload), + "utf-8", + ); + const { res, state } = makeMockRes(); + handleGetUserOverrides({ method: "GET", url: "/future" }, res, tmpRoot); + expect(state.statusCode).toBe(200); + expect(JSON.parse(state.body)).toEqual(payload); + }); + + it("strips the leading slash and ignores query string when keying", () => { + fs.writeFileSync( + path.join(overridesDir, "03.json"), + JSON.stringify({ layout: "x" }), + "utf-8", + ); + const { res, state } = makeMockRes(); + handleGetUserOverrides( + { method: "GET", url: "/03?ts=1747884800" }, + res, + tmpRoot, + ); + expect(state.statusCode).toBe(200); + expect(JSON.parse(state.body)).toEqual({ layout: "x" }); + }); +}); + +// --------------------------------------------------------------------------- +// IMP-52 u4 — PUT endpoint coverage +// --------------------------------------------------------------------------- + +describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => { + it("matches the Python KNOWN_AXES tuple in src/user_overrides_io.py", () => { + // The on-disk schema is shared with backend pipeline fallback (u2). + // Any drift here means a PUT could write an axis that the Python + // load() ignores, or vice-versa, silently losing user overrides. + expect(KNOWN_USER_OVERRIDES_AXES).toEqual([ + "layout", + "zone_geometries", + "zone_sections", + "frames", + ]); + }); +}); + +describe("mergeUserOverrides (IMP-52 u4)", () => { + it("only mutates KNOWN_AXES present in partial", () => { + const existing = { + layout: "old", + frames: { "03-1": "frame_01" }, + zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } }, + zone_sections: { top: ["03-1"] }, + }; + const merged = mergeUserOverrides(existing, { layout: "new" }); + expect(merged.layout).toBe("new"); + // axes not in partial are preserved + expect(merged.frames).toEqual({ "03-1": "frame_01" }); + expect(merged.zone_geometries).toEqual({ + top: { x: 0, y: 0, w: 1, h: 0.5 }, + }); + expect(merged.zone_sections).toEqual({ top: ["03-1"] }); + }); + + it("preserves foreign top-level keys in existing", () => { + // Forward-compat: future axes (zone_sizes, image_overrides, etc.) on + // disk must survive PUT writes that only touch the 4 in-scope axes. + const existing = { + layout: "old", + zone_sizes: { top: 0.42 }, + image_overrides: { img1: { x: 0.1 } }, + }; + const merged = mergeUserOverrides(existing, { layout: "new" }); + expect(merged.zone_sizes).toEqual({ top: 0.42 }); + expect(merged.image_overrides).toEqual({ img1: { x: 0.1 } }); + }); + + it("clears axis when partial value is null (explicit clear)", () => { + const existing = { layout: "x", frames: { "03-1": "f01" } }; + const merged = mergeUserOverrides(existing, { layout: null }); + expect("layout" in merged).toBe(false); + expect(merged.frames).toEqual({ "03-1": "f01" }); + }); + + it("drops non-axis keys in partial (allowlist)", () => { + // PUT payload may carry junk fields (typo, malicious key); allowlist + // ensures only the 4 axes can be written to disk. + const merged = mergeUserOverrides( + {}, + { layout: "x", random_key: "evil", __proto__: "x" } as Record< + string, + unknown + >, + ); + expect(merged.layout).toBe("x"); + expect("random_key" in merged).toBe(false); + }); + + it("merges all 4 axes when present in partial", () => { + const merged = mergeUserOverrides( + {}, + { + layout: "two_zone_split", + frames: { "03-1+03-2": "frame_07" }, + zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } }, + zone_sections: { top: ["03-1", "03-2"] }, + }, + ); + expect(Object.keys(merged).sort()).toEqual([ + "frames", + "layout", + "zone_geometries", + "zone_sections", + ]); + }); + + it("does not mutate the existing input", () => { + const existing = { layout: "old", frames: { a: "b" } }; + const snapshot = JSON.parse(JSON.stringify(existing)); + mergeUserOverrides(existing, { layout: "new", layout_evil: "x" } as Record< + string, + unknown + >); + expect(existing).toEqual(snapshot); + }); +}); + +describe("atomicWriteUserOverrides (IMP-52 u4)", () => { + let tmpRoot: string; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u4-aw-")); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it("creates parent dir if missing and writes JSON content", () => { + const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json"); + expect(fs.existsSync(path.dirname(filePath))).toBe(false); + atomicWriteUserOverrides(filePath, { layout: "x" }); + expect(fs.existsSync(filePath)).toBe(true); + expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({ + layout: "x", + }); + }); + + it("leaves no .tmp residue after a successful write", () => { + const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json"); + atomicWriteUserOverrides(filePath, { layout: "x" }); + const dirContents = fs.readdirSync(path.dirname(filePath)); + expect(dirContents).toEqual(["03.json"]); + }); + + it("overwrites an existing file atomically", () => { + const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json"); + atomicWriteUserOverrides(filePath, { layout: "v1" }); + atomicWriteUserOverrides(filePath, { layout: "v2" }); + expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({ + layout: "v2", + }); + }); +}); + +// req mock — EventEmitter with method/url + a `send(body)` helper that +// emits the data chunk and then `end`, mirroring the node IncomingMessage +// flow used by vite's dev middlewares. +function makeMockReq(opts: { + method?: string; + url?: string; +}): EventEmitter & { method?: string; url?: string; send: (body: string) => void } { + const ee = new EventEmitter() as EventEmitter & { + method?: string; + url?: string; + send: (body: string) => void; + }; + ee.method = opts.method; + ee.url = opts.url; + ee.send = (body: string) => { + if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8")); + ee.emit("end"); + }; + return ee; +} + +describe("handlePutUserOverrides (IMP-52 u4)", () => { + let tmpRoot: string; + let overridesDir: string; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u4-")); + overridesDir = path.join(tmpRoot, "data", "user_overrides"); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it("returns false (next chained) when method != PUT", () => { + const req = makeMockReq({ method: "GET", url: "/03" }); + const { res, state } = makeMockRes(); + const handled = handlePutUserOverrides(req, res, tmpRoot); + expect(handled).toBe(false); + expect(state.ended).toBe(false); + }); + + it("returns 400 on invalid key", () => { + const req = makeMockReq({ method: "PUT", url: "/../escape" }); + const { res, state } = makeMockRes(); + const handled = handlePutUserOverrides(req, res, tmpRoot); + expect(handled).toBe(true); + expect(state.statusCode).toBe(400); + expect(JSON.parse(state.body)).toEqual({ error: "invalid key" }); + }); + + it("returns 400 on invalid JSON body", () => { + const req = makeMockReq({ method: "PUT", url: "/03" }); + const { res, state } = makeMockRes(); + expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true); + req.send("{not json"); + expect(state.statusCode).toBe(400); + expect(JSON.parse(state.body)).toEqual({ error: "invalid JSON" }); + // file MUST NOT have been created on parse failure + expect(fs.existsSync(path.join(overridesDir, "03.json"))).toBe(false); + }); + + it("returns 400 when JSON body is an array", () => { + const req = makeMockReq({ method: "PUT", url: "/03" }); + const { res, state } = makeMockRes(); + expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true); + req.send(JSON.stringify([1, 2, 3])); + expect(state.statusCode).toBe(400); + expect(JSON.parse(state.body)).toEqual({ + error: "body must be a JSON object", + }); + }); + + it("returns 400 when JSON body is a primitive", () => { + const req = makeMockReq({ method: "PUT", url: "/03" }); + const { res, state } = makeMockRes(); + expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true); + req.send("42"); + expect(state.statusCode).toBe(400); + expect(JSON.parse(state.body)).toEqual({ + error: "body must be a JSON object", + }); + }); + + it("creates the override file on first PUT and returns merged body", () => { + const req = makeMockReq({ method: "PUT", url: "/03" }); + const { res, state } = makeMockRes(); + expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true); + + const payload = { layout: "two_zone_split" }; + req.send(JSON.stringify(payload)); + + expect(state.statusCode).toBe(200); + expect(state.headers["Content-Type"]).toBe( + "application/json; charset=utf-8", + ); + expect(JSON.parse(state.body)).toEqual({ layout: "two_zone_split" }); + + const filePath = path.join(overridesDir, "03.json"); + expect(fs.existsSync(filePath)).toBe(true); + expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({ + layout: "two_zone_split", + }); + }); + + it("partial-merges: axes absent from payload are preserved on disk", () => { + fs.mkdirSync(overridesDir, { recursive: true }); + fs.writeFileSync( + path.join(overridesDir, "03.json"), + JSON.stringify({ + layout: "old", + frames: { "03-1": "frame_01" }, + zone_sections: { top: ["03-1"] }, + }), + "utf-8", + ); + + const req = makeMockReq({ method: "PUT", url: "/03" }); + const { res, state } = makeMockRes(); + expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true); + req.send(JSON.stringify({ layout: "new" })); + + expect(state.statusCode).toBe(200); + const onDisk = JSON.parse( + fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"), + ); + expect(onDisk).toEqual({ + layout: "new", + frames: { "03-1": "frame_01" }, + zone_sections: { top: ["03-1"] }, + }); + }); + + it("preserves foreign top-level keys on disk (forward-compat)", () => { + fs.mkdirSync(overridesDir, { recursive: true }); + fs.writeFileSync( + path.join(overridesDir, "future.json"), + JSON.stringify({ + layout: "old", + zone_sizes: { top: 0.42 }, + image_overrides: { img1: { x: 0.1 } }, + }), + "utf-8", + ); + + const req = makeMockReq({ method: "PUT", url: "/future" }); + const { res } = makeMockRes(); + expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true); + req.send(JSON.stringify({ layout: "new" })); + + const onDisk = JSON.parse( + fs.readFileSync(path.join(overridesDir, "future.json"), "utf-8"), + ); + expect(onDisk.zone_sizes).toEqual({ top: 0.42 }); + expect(onDisk.image_overrides).toEqual({ img1: { x: 0.1 } }); + expect(onDisk.layout).toBe("new"); + }); + + it("drops non-axis payload keys (allowlist enforced at write)", () => { + fs.mkdirSync(overridesDir, { recursive: true }); + const req = makeMockReq({ method: "PUT", url: "/03" }); + const { res, state } = makeMockRes(); + expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true); + + req.send( + JSON.stringify({ + layout: "two_zone_split", + random_evil_key: "should not persist", + }), + ); + + expect(state.statusCode).toBe(200); + const onDisk = JSON.parse( + fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"), + ); + expect(onDisk).toEqual({ layout: "two_zone_split" }); + expect("random_evil_key" in onDisk).toBe(false); + }); + + it("clears an axis when payload sets it to null", () => { + fs.mkdirSync(overridesDir, { recursive: true }); + fs.writeFileSync( + path.join(overridesDir, "03.json"), + JSON.stringify({ layout: "old", frames: { "03-1": "f01" } }), + "utf-8", + ); + + const req = makeMockReq({ method: "PUT", url: "/03" }); + const { res } = makeMockRes(); + expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true); + req.send(JSON.stringify({ layout: null })); + + const onDisk = JSON.parse( + fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"), + ); + expect("layout" in onDisk).toBe(false); + expect(onDisk.frames).toEqual({ "03-1": "f01" }); + }); + + it("recovers from corrupt existing file (graceful degrade)", () => { + fs.mkdirSync(overridesDir, { recursive: true }); + fs.writeFileSync( + path.join(overridesDir, "03.json"), + "{this is not JSON", + "utf-8", + ); + + const req = makeMockReq({ method: "PUT", url: "/03" }); + const { res, state } = makeMockRes(); + expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true); + req.send(JSON.stringify({ layout: "recovered" })); + + expect(state.statusCode).toBe(200); + const onDisk = JSON.parse( + fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"), + ); + expect(onDisk).toEqual({ layout: "recovered" }); + }); + + it("treats array-rooted existing file as empty (graceful degrade)", () => { + fs.mkdirSync(overridesDir, { recursive: true }); + fs.writeFileSync( + path.join(overridesDir, "03.json"), + JSON.stringify(["not", "an", "object"]), + "utf-8", + ); + + const req = makeMockReq({ method: "PUT", url: "/03" }); + const { res, state } = makeMockRes(); + expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true); + req.send(JSON.stringify({ layout: "recovered" })); + + expect(state.statusCode).toBe(200); + const onDisk = JSON.parse( + fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"), + ); + expect(onDisk).toEqual({ layout: "recovered" }); + }); + + it("strips the leading slash and ignores query string when keying", () => { + const req = makeMockReq({ + method: "PUT", + url: "/03?ts=1747884800", + }); + const { res, state } = makeMockRes(); + expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true); + req.send(JSON.stringify({ layout: "x" })); + + expect(state.statusCode).toBe(200); + expect(fs.existsSync(path.join(overridesDir, "03.json"))).toBe(true); + }); + + it("accepts an empty body as a no-op partial (no axes mutated)", () => { + fs.mkdirSync(overridesDir, { recursive: true }); + fs.writeFileSync( + path.join(overridesDir, "03.json"), + JSON.stringify({ layout: "kept" }), + "utf-8", + ); + + const req = makeMockReq({ method: "PUT", url: "/03" }); + const { res, state } = makeMockRes(); + expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true); + req.send(""); + + expect(state.statusCode).toBe(200); + const onDisk = JSON.parse( + fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"), + ); + expect(onDisk).toEqual({ layout: "kept" }); + }); + + it("accepts a chunked PUT body (concatenates data events)", () => { + const req = makeMockReq({ method: "PUT", url: "/03" }); + const { res, state } = makeMockRes(); + expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true); + + const body = JSON.stringify({ + layout: "two_zone_split", + frames: { "03-1": "frame_01" }, + }); + // Emit in two halves to simulate a fragmented HTTP body. + const half = Math.floor(body.length / 2); + req.emit("data", Buffer.from(body.slice(0, half), "utf-8")); + req.emit("data", Buffer.from(body.slice(half), "utf-8")); + req.emit("end"); + + expect(state.statusCode).toBe(200); + expect(JSON.parse(state.body)).toEqual({ + layout: "two_zone_split", + frames: { "03-1": "frame_01" }, + }); + }); +}); diff --git a/Front/client/tests/user_overrides_restore.test.ts b/Front/client/tests/user_overrides_restore.test.ts new file mode 100644 index 0000000..cdeef10 --- /dev/null +++ b/Front/client/tests/user_overrides_restore.test.ts @@ -0,0 +1,302 @@ +// IMP-52 u6 — vitest coverage for restore-on-reopen helpers used by +// `Home.tsx` to layer persisted `user_overrides.json` payloads onto the +// in-memory `UserSelection` and `slidePlan`. +// +// Scope (Stage 2 unit u6 contract): +// 1) deriveUserOverridesKey(filename) — MDX-stem key derivation that +// matches backend u2 fallback's `Path(args.mdx_path).stem`. Strips +// `.mdx` case-insensitively; preserves everything else. +// 2) applyPersistedNonFrameOverrides(selection, persisted) — layers +// layout / zone_geometries / zone_sections onto an existing selection. +// Frames are NOT layered here (unit_id key requires slidePlan). +// Foreign / unrecognized payloads degrade silently (no throw, no +// partial mutation). +// 3) remapPersistedFramesToZoneFrames(slidePlan, framesByUnitId) — +// remaps frames (unit_id → template_id) to zone_frames (region.id → +// template_id). Stale unit_ids (no matching zone) drop silently; +// zones without internal_regions[0] or without section_ids are +// skipped without throwing. +// +// All helpers are pure; tests run in vitest's default node environment +// without RTL / jsdom. Home.tsx wiring sites (handleFileUpload pre-Generate +// seed + handleGenerate post-loadRun frame remap) are 1-line call sites that +// these helpers cover end-to-end. + +import { describe, it, expect } from "vitest"; +import type { + LayoutPresetId, + SlidePlan, + UserSelection, + Zone, +} from "../src/types/designAgent"; +import { + applyPersistedNonFrameOverrides, + deriveUserOverridesKey, + remapPersistedFramesToZoneFrames, +} from "../src/utils/slidePlanUtils"; + +// ─── Fixtures ─────────────────────────────────────────────────────────────── + +function makeSelection(overrides?: Partial): UserSelection { + return { + selectedSectionId: null, + selectedZoneId: null, + selectedRegionId: null, + overrides: { + layout_preset: undefined, + zone_frames: {}, + zone_sections: {}, + zone_sizes: {}, + zone_geometries: {}, + ...overrides, + }, + }; +} + +function makeZone( + partial: { id: string; zone_id: string; section_ids: string[]; region_id?: string }, +): Zone { + return { + id: partial.id, + zone_id: partial.zone_id, + section_ids: partial.section_ids, + position: { x: 0, y: 0, width: 1, height: 1 }, + internal_regions: [ + { + id: partial.region_id ?? `${partial.id}-r0`, + region_id: "region-single", + role: "primary", + content_type: "text_block", + ratio_estimate: 1, + content_unit_ids: [], + frame_match_strategy: { + kind: "frame_match", + frame_id: null, + display_strategy: "inline_full", + }, + frame_candidates: [], + }, + ], + }; +} + +function makeSlidePlan(zones: Zone[], layout: LayoutPresetId = "single"): SlidePlan { + return { + id: "plan-1", + title: "test plan", + layout_preset: layout, + zones, + }; +} + +// ─── deriveUserOverridesKey ───────────────────────────────────────────────── + +describe("deriveUserOverridesKey (IMP-52 u6)", () => { + it("strips trailing .mdx", () => { + expect(deriveUserOverridesKey("03__DX_BIM_value_chain.mdx")).toBe( + "03__DX_BIM_value_chain", + ); + }); + + it("strips .MDX case-insensitively", () => { + expect(deriveUserOverridesKey("04_demo.MDX")).toBe("04_demo"); + expect(deriveUserOverridesKey("05_intro.Mdx")).toBe("05_intro"); + }); + + it("returns the filename unchanged when no .mdx suffix", () => { + expect(deriveUserOverridesKey("03__DX_BIM_value_chain")).toBe( + "03__DX_BIM_value_chain", + ); + expect(deriveUserOverridesKey("notes.txt")).toBe("notes.txt"); + }); + + it("only strips the final .mdx, preserves dots inside the stem", () => { + expect(deriveUserOverridesKey("05.2_layer.mdx")).toBe("05.2_layer"); + }); + + it("returns empty string for empty input", () => { + expect(deriveUserOverridesKey("")).toBe(""); + }); + + it("matches backend Path(args.mdx_path).stem for the canonical demo MDXs", () => { + // These are the three canonical samples loaded by /api/sample-mdx; the + // key on both ends must agree so a write from frontend (PUT) is found + // by backend (u2 fallback on next pipeline run). + expect(deriveUserOverridesKey("03_demo.mdx")).toBe("03_demo"); + expect(deriveUserOverridesKey("04_demo.mdx")).toBe("04_demo"); + expect(deriveUserOverridesKey("05_demo.mdx")).toBe("05_demo"); + }); +}); + +// ─── applyPersistedNonFrameOverrides ──────────────────────────────────────── + +describe("applyPersistedNonFrameOverrides (IMP-52 u6)", () => { + it("layers layout / zone_geometries / zone_sections", () => { + const sel = makeSelection(); + const persisted = { + layout: "horizontal-2", + zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.4 } }, + zone_sections: { top: ["03-1"], bottom: ["03-2"] }, + } as const; + const next = applyPersistedNonFrameOverrides(sel, persisted); + expect(next.overrides.layout_preset).toBe("horizontal-2"); + expect(next.overrides.zone_geometries).toEqual({ + top: { x: 0, y: 0, w: 1, h: 0.4 }, + }); + expect(next.overrides.zone_sections).toEqual({ + top: ["03-1"], + bottom: ["03-2"], + }); + }); + + it("does NOT layer frames (frames need post-loadRun remap)", () => { + const sel = makeSelection({ zone_frames: { "r-existing": "tpl-existing" } }); + const persisted = { + frames: { "03-1+03-2": "tpl-persisted" }, + }; + const next = applyPersistedNonFrameOverrides(sel, persisted); + // zone_frames is untouched here; the post-loadRun remap step owns it. + expect(next.overrides.zone_frames).toEqual({ "r-existing": "tpl-existing" }); + }); + + it("rejects layout values outside the 8 known preset ids", () => { + const sel = makeSelection({ layout_preset: "single" }); + const next = applyPersistedNonFrameOverrides(sel, { + layout: "rogue-layout" as unknown as string, + }); + // Stays at the original — preset whitelist guards against hand-edited + // files or future schema drift. + expect(next.overrides.layout_preset).toBe("single"); + }); + + it("ignores zone_geometries when the payload axis is an array", () => { + const sel = makeSelection({ zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } } }); + const next = applyPersistedNonFrameOverrides(sel, { + zone_geometries: [] as unknown as Record, + }); + expect(next.overrides.zone_geometries).toEqual({ + top: { x: 0, y: 0, w: 1, h: 0.5 }, + }); + }); + + it("returns the selection unchanged when persisted is null / undefined / non-object", () => { + const sel = makeSelection({ layout_preset: "single" }); + expect(applyPersistedNonFrameOverrides(sel, null)).toEqual(sel); + expect(applyPersistedNonFrameOverrides(sel, undefined)).toEqual(sel); + }); + + it("returns the selection unchanged when persisted is empty {}", () => { + const sel = makeSelection({ layout_preset: "single" }); + const next = applyPersistedNonFrameOverrides(sel, {}); + expect(next.overrides.layout_preset).toBe("single"); + expect(next.overrides.zone_geometries).toEqual({}); + expect(next.overrides.zone_sections).toEqual({}); + }); + + it("returns a NEW selection object (no mutation of input)", () => { + const sel = makeSelection(); + const next = applyPersistedNonFrameOverrides(sel, { layout: "vertical-2" }); + expect(next).not.toBe(sel); + expect(next.overrides).not.toBe(sel.overrides); + // Input still pristine. + expect(sel.overrides.layout_preset).toBeUndefined(); + }); +}); + +// ─── remapPersistedFramesToZoneFrames ─────────────────────────────────────── + +describe("remapPersistedFramesToZoneFrames (IMP-52 u6)", () => { + it("maps unit_id (section_ids joined by +) to region.id", () => { + const plan = makeSlidePlan([ + makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }), + makeZone({ id: "z-bot", zone_id: "bottom", section_ids: ["03-2", "03-3"], region_id: "r-bot" }), + ]); + const remapped = remapPersistedFramesToZoneFrames(plan, { + "03-1": "tpl-a", + "03-2+03-3": "tpl-b", + }); + expect(remapped).toEqual({ + "r-top": "tpl-a", + "r-bot": "tpl-b", + }); + }); + + it("silently drops persisted entries whose unit_id matches no zone", () => { + const plan = makeSlidePlan([ + makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }), + ]); + const remapped = remapPersistedFramesToZoneFrames(plan, { + "03-1": "tpl-a", + "stale-section-id": "tpl-stale", // user changed zone_sections between sessions + }); + expect(remapped).toEqual({ "r-top": "tpl-a" }); + }); + + it("returns {} when slidePlan is null / undefined", () => { + expect(remapPersistedFramesToZoneFrames(null, { "03-1": "tpl-a" })).toEqual({}); + expect(remapPersistedFramesToZoneFrames(undefined, { "03-1": "tpl-a" })).toEqual({}); + }); + + it("returns {} when framesByUnitId is null / undefined / {}", () => { + const plan = makeSlidePlan([ + makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }), + ]); + expect(remapPersistedFramesToZoneFrames(plan, null)).toEqual({}); + expect(remapPersistedFramesToZoneFrames(plan, undefined)).toEqual({}); + expect(remapPersistedFramesToZoneFrames(plan, {})).toEqual({}); + }); + + it("skips zones with empty section_ids (no unit_id to derive)", () => { + const plan = makeSlidePlan([ + makeZone({ id: "z-empty", zone_id: "empty", section_ids: [], region_id: "r-empty" }), + makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }), + ]); + const remapped = remapPersistedFramesToZoneFrames(plan, { + "": "tpl-should-not-match-empty-join", + "03-1": "tpl-a", + }); + expect(remapped).toEqual({ "r-top": "tpl-a" }); + }); + + it("skips zones without internal_regions[0]", () => { + const plan: SlidePlan = { + id: "plan-x", + title: "no regions", + layout_preset: "single", + zones: [ + { + id: "z-bare", + zone_id: "bare", + section_ids: ["03-1"], + position: { x: 0, y: 0, width: 1, height: 1 }, + internal_regions: [], + }, + ], + }; + expect(remapPersistedFramesToZoneFrames(plan, { "03-1": "tpl-a" })).toEqual({}); + }); + + it("ignores persisted entries with empty / non-string template_id", () => { + const plan = makeSlidePlan([ + makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }), + ]); + const remapped = remapPersistedFramesToZoneFrames(plan, { + "03-1": "" as unknown as string, + }); + expect(remapped).toEqual({}); + }); + + it("preserves the user-selected template even when slidePlan layout would imply a different default", () => { + // Backend u2 fallback should already have applied the user's frame + // override via CLI args, but if the plan's default frame_match_strategy + // disagrees, the post-loadRun remap still surfaces the user's choice + // for the SlideCanvas override-vs-default preview indicator. + const plan = makeSlidePlan([ + makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }), + ]); + const remapped = remapPersistedFramesToZoneFrames(plan, { + "03-1": "user-chosen-tpl", + }); + expect(remapped["r-top"]).toBe("user-chosen-tpl"); + }); +}); diff --git a/Front/client/tests/user_overrides_service.test.ts b/Front/client/tests/user_overrides_service.test.ts new file mode 100644 index 0000000..28c9c0e --- /dev/null +++ b/Front/client/tests/user_overrides_service.test.ts @@ -0,0 +1,485 @@ +// IMP-52 u5 — vitest coverage for the typed frontend client at +// `Front/client/src/services/userOverridesApi.ts`. +// +// Scope (Stage 2 unit u5 contract): +// 1) getUserOverrides: +// • 200 with object body → typed payload echoed. +// • 200 with array / primitive / non-JSON body → {} (graceful). +// • 4xx / 5xx → {}. +// • fetch reject (network) → {} (no throw to caller). +// 2) saveUserOverrides: +// • Single call: PUT fires after exactly 300 ms with the mutated-axis +// partial as body (NOT a full snapshot of UserOverrides). +// • Rapid coalescing: N calls in <300 ms window collapse to ONE PUT +// carrying the union of mutated axes. +// • Per-axis later-wins: later call's value replaces earlier pending +// value for the same axis; axes the user did not touch stay absent. +// • null sentinel: forwarded verbatim so u4 mergeUserOverrides can +// `delete` the axis on disk. +// • Per-key isolation: rapid edits to "03" do not delay flush of "04". +// • Promise resolves with the server-side merged document. +// • Promise rejects on 4xx/5xx and on fetch reject. +// 3) flushUserOverrides: +// • No arg → flushes all pending buckets immediately (no 300 ms wait). +// • Specific key → flushes only that bucket; other buckets stay +// pending. +// • No-op when no buckets are pending. +// +// All tests mock `fetch` and use `vi.useFakeTimers()` to make the 300 ms +// debounce deterministic — no real wall-clock waits. + +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from "vitest"; +import { + __resetUserOverridesBuckets_FOR_TEST, + flushUserOverrides, + getUserOverrides, + saveUserOverrides, + type UserOverridesPartial, +} from "../src/services/userOverridesApi"; + +// --------------------------------------------------------------------------- +// fetch mock — minimal Response stub with the two methods the service uses +// (.ok / .status / .json()). We track the call log so debounce + coalescing +// can be asserted by counting PUTs and inspecting their bodies. +// --------------------------------------------------------------------------- + +type MockResponse = { + ok: boolean; + status: number; + json: () => Promise; +}; + +function mockResponse(body: unknown, ok = true, status = 200): MockResponse { + return { + ok, + status, + json: async () => body, + }; +} + +let fetchMock: Mock; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.useFakeTimers(); + __resetUserOverridesBuckets_FOR_TEST(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + __resetUserOverridesBuckets_FOR_TEST(); +}); + +// Microtask-flushing helper. vi.advanceTimersByTime fires timers, but the +// promise chain inside flushBucket (await fetch → await res.json() → resolve +// waiters) needs the microtask queue to drain before assertions run. +async function drainMicrotasks(): Promise { + // Multiple ticks because each `await` in flushBucket adds another tick. + for (let i = 0; i < 4; i++) { + await Promise.resolve(); + } +} + +function lastPutBody(): unknown { + const lastCall = fetchMock.mock.calls.at(-1); + if (!lastCall) throw new Error("fetch was not called"); + const init = lastCall[1] as RequestInit | undefined; + if (!init?.body) throw new Error("fetch was called without a body"); + return JSON.parse(String(init.body)); +} + +function putCallsCount(): number { + return fetchMock.mock.calls.filter( + (call) => (call[1] as RequestInit | undefined)?.method === "PUT", + ).length; +} + +// ============================================================================ +// getUserOverrides +// ============================================================================ + +describe("getUserOverrides (IMP-52 u5)", () => { + it("issues GET against /api/user-overrides/", async () => { + fetchMock.mockResolvedValueOnce(mockResponse({ layout: "x" })); + await getUserOverrides("03"); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/user-overrides/03"); + expect((init as RequestInit).method).toBe("GET"); + }); + + it("returns the parsed object on 200 with object body", async () => { + const payload = { + layout: "two_zone_split", + frames: { "03-1+03-2": "frame_07" }, + zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } }, + zone_sections: { top: ["03-1", "03-2"] }, + }; + fetchMock.mockResolvedValueOnce(mockResponse(payload)); + const got = await getUserOverrides("03"); + expect(got).toEqual(payload); + }); + + it("returns {} when JSON root is an array (mirrors u3 graceful degrade)", async () => { + fetchMock.mockResolvedValueOnce(mockResponse([1, 2, 3])); + expect(await getUserOverrides("03")).toEqual({}); + }); + + it("returns {} when JSON root is a primitive", async () => { + fetchMock.mockResolvedValueOnce(mockResponse(42)); + expect(await getUserOverrides("03")).toEqual({}); + }); + + it("returns {} when JSON root is null", async () => { + fetchMock.mockResolvedValueOnce(mockResponse(null)); + expect(await getUserOverrides("03")).toEqual({}); + }); + + it("returns {} on 4xx (invalid key path from u3)", async () => { + fetchMock.mockResolvedValueOnce( + mockResponse({ error: "invalid key" }, false, 400), + ); + expect(await getUserOverrides("..")).toEqual({}); + }); + + it("returns {} on 5xx", async () => { + fetchMock.mockResolvedValueOnce( + mockResponse({ error: "boom" }, false, 500), + ); + expect(await getUserOverrides("03")).toEqual({}); + }); + + it("returns {} when response.json() throws (non-JSON body)", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError("Unexpected token"); + }, + }); + expect(await getUserOverrides("03")).toEqual({}); + }); + + it("returns {} when fetch rejects (network error) — does NOT throw", async () => { + fetchMock.mockRejectedValueOnce(new Error("network down")); + await expect(getUserOverrides("03")).resolves.toEqual({}); + }); +}); + +// ============================================================================ +// saveUserOverrides — debounce + coalescing +// ============================================================================ + +describe("saveUserOverrides (IMP-52 u5) — debounce", () => { + it("does NOT fire fetch before 300 ms have elapsed", async () => { + fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" })); + void saveUserOverrides("03", { layout: "two_zone_split" }); + + vi.advanceTimersByTime(299); + await drainMicrotasks(); + expect(putCallsCount()).toBe(0); + }); + + it("fires exactly one PUT at the 300 ms boundary", async () => { + fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" })); + void saveUserOverrides("03", { layout: "two_zone_split" }); + + vi.advanceTimersByTime(300); + await drainMicrotasks(); + expect(putCallsCount()).toBe(1); + + const lastCall = fetchMock.mock.calls.at(-1)!; + expect(lastCall[0]).toBe("/api/user-overrides/03"); + expect((lastCall[1] as RequestInit).method).toBe("PUT"); + expect((lastCall[1] as RequestInit).headers).toMatchObject({ + "Content-Type": "application/json", + }); + expect(lastPutBody()).toEqual({ layout: "two_zone_split" }); + }); + + it("PUT body contains ONLY the mutated axis (not a full snapshot)", async () => { + // The frontend handler only knows the axis it just mutated; the server + // is responsible for partial-merge against axes already on disk. + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03", { + zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } }, + }); + vi.advanceTimersByTime(300); + await drainMicrotasks(); + + const body = lastPutBody() as Record; + expect(Object.keys(body)).toEqual(["zone_geometries"]); + expect("layout" in body).toBe(false); + expect("frames" in body).toBe(false); + expect("zone_sections" in body).toBe(false); + }); + + it("coalesces N rapid calls into a SINGLE PUT after the debounce", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03", { layout: "old" }); + vi.advanceTimersByTime(100); + void saveUserOverrides("03", { frames: { "03-1": "frame_01" } }); + vi.advanceTimersByTime(100); + void saveUserOverrides("03", { zone_sections: { top: ["03-1"] } }); + vi.advanceTimersByTime(100); + + // After 300 ms total (but the timer was reset each call to start the + // 300 ms window over), so we need one more 300 ms to fire. + expect(putCallsCount()).toBe(0); + vi.advanceTimersByTime(300); + await drainMicrotasks(); + expect(putCallsCount()).toBe(1); + + // All three axes accumulated. + const body = lastPutBody() as Record; + expect(body).toEqual({ + layout: "old", + frames: { "03-1": "frame_01" }, + zone_sections: { top: ["03-1"] }, + }); + }); + + it("per-axis later-wins: same axis mutated twice keeps the LAST value", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03", { layout: "first" }); + void saveUserOverrides("03", { layout: "second" }); + void saveUserOverrides("03", { layout: "final" }); + + vi.advanceTimersByTime(300); + await drainMicrotasks(); + expect(putCallsCount()).toBe(1); + expect(lastPutBody()).toEqual({ layout: "final" }); + }); + + it("forwards null sentinel verbatim (explicit clear)", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03", { layout: null }); + + vi.advanceTimersByTime(300); + await drainMicrotasks(); + expect(lastPutBody()).toEqual({ layout: null }); + }); + + it("null can override a prior non-null pending value for the same axis", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03", { layout: "two_zone_split" }); + void saveUserOverrides("03", { layout: null }); + + vi.advanceTimersByTime(300); + await drainMicrotasks(); + expect(lastPutBody()).toEqual({ layout: null }); + }); + + it("resolves the caller promise with the server-merged document", async () => { + fetchMock.mockResolvedValueOnce( + mockResponse({ + layout: "two_zone_split", + // server's view includes axes preserved on disk that the partial + // PUT did NOT carry — confirms we surface the full merged state. + frames: { "03-1": "frame_01" }, + }), + ); + const p = saveUserOverrides("03", { layout: "two_zone_split" }); + vi.advanceTimersByTime(300); + await drainMicrotasks(); + + await expect(p).resolves.toEqual({ + layout: "two_zone_split", + frames: { "03-1": "frame_01" }, + }); + }); + + it("rejects all coalesced waiters on 5xx response", async () => { + fetchMock.mockResolvedValueOnce( + mockResponse({ error: "write failed" }, false, 500), + ); + const p1 = saveUserOverrides("03", { layout: "x" }); + const p2 = saveUserOverrides("03", { frames: { "03-1": "f01" } }); + + vi.advanceTimersByTime(300); + await drainMicrotasks(); + + await expect(p1).rejects.toThrow(/500/); + await expect(p2).rejects.toThrow(/500/); + }); + + it("rejects waiters on fetch network error", async () => { + fetchMock.mockRejectedValueOnce(new Error("ECONNRESET")); + const p = saveUserOverrides("03", { layout: "x" }); + + vi.advanceTimersByTime(300); + await drainMicrotasks(); + + await expect(p).rejects.toThrow("ECONNRESET"); + }); + + it("after a successful flush, a new save starts a fresh debounce window", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03", { layout: "first" }); + vi.advanceTimersByTime(300); + await drainMicrotasks(); + expect(putCallsCount()).toBe(1); + expect(lastPutBody()).toEqual({ layout: "first" }); + + void saveUserOverrides("03", { layout: "second" }); + vi.advanceTimersByTime(299); + await drainMicrotasks(); + expect(putCallsCount()).toBe(1); // not fired yet + vi.advanceTimersByTime(1); + await drainMicrotasks(); + expect(putCallsCount()).toBe(2); + expect(lastPutBody()).toEqual({ layout: "second" }); + }); +}); + +// ============================================================================ +// saveUserOverrides — per-key isolation +// ============================================================================ + +describe("saveUserOverrides (IMP-52 u5) — per-key isolation", () => { + it("rapid edits to key A do not delay key B's flush", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + // Schedule a save on "03" + void saveUserOverrides("03", { layout: "x" }); + // Schedule a save on "04" at t=0 + void saveUserOverrides("04", { layout: "y" }); + + vi.advanceTimersByTime(150); + // Keep extending "03"'s window + void saveUserOverrides("03", { layout: "x2" }); + + // "04" should still fire at t=300 (untouched after first call) + vi.advanceTimersByTime(150); // t=300 + await drainMicrotasks(); + + const puts = fetchMock.mock.calls.filter( + (c) => (c[1] as RequestInit).method === "PUT", + ); + expect(puts.length).toBe(1); + expect(puts[0][0]).toBe("/api/user-overrides/04"); + expect(JSON.parse(String((puts[0][1] as RequestInit).body))).toEqual({ + layout: "y", + }); + }); + + it("each key's PUT carries only that key's mutated axes", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03", { layout: "for-03" }); + void saveUserOverrides("04", { frames: { "04-1": "frame_05" } }); + + vi.advanceTimersByTime(300); + await drainMicrotasks(); + + const puts = fetchMock.mock.calls.filter( + (c) => (c[1] as RequestInit).method === "PUT", + ); + expect(puts.length).toBe(2); + + const byUrl = new Map( + puts.map((c) => [ + c[0], + JSON.parse(String((c[1] as RequestInit).body)) as Record< + string, + unknown + >, + ]), + ); + expect(byUrl.get("/api/user-overrides/03")).toEqual({ layout: "for-03" }); + expect(byUrl.get("/api/user-overrides/04")).toEqual({ + frames: { "04-1": "frame_05" }, + }); + }); +}); + +// ============================================================================ +// flushUserOverrides +// ============================================================================ + +describe("flushUserOverrides (IMP-52 u5)", () => { + it("with no arg, flushes ALL pending buckets immediately (no 300 ms wait)", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03", { layout: "x" }); + void saveUserOverrides("04", { layout: "y" }); + + expect(putCallsCount()).toBe(0); + const flushP = flushUserOverrides(); + await drainMicrotasks(); + await flushP; + + expect(putCallsCount()).toBe(2); + }); + + it("with a key arg, flushes only that bucket; others stay pending", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03", { layout: "x" }); + void saveUserOverrides("04", { layout: "y" }); + + await flushUserOverrides("03"); + await drainMicrotasks(); + + const puts = fetchMock.mock.calls.filter( + (c) => (c[1] as RequestInit).method === "PUT", + ); + expect(puts.length).toBe(1); + expect(puts[0][0]).toBe("/api/user-overrides/03"); + + // "04" should still fire at the regular 300 ms boundary. + vi.advanceTimersByTime(300); + await drainMicrotasks(); + expect(putCallsCount()).toBe(2); + }); + + it("is a no-op when no buckets are pending", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + await flushUserOverrides(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("resolves the original saveUserOverrides promise via the in-flight PUT", async () => { + fetchMock.mockResolvedValueOnce(mockResponse({ layout: "flushed" })); + const savePromise = saveUserOverrides("03", { layout: "flushed" }); + const flushPromise = flushUserOverrides(); + await drainMicrotasks(); + await flushPromise; + await expect(savePromise).resolves.toEqual({ layout: "flushed" }); + }); + + it("propagates PUT failure as caller rejection (flush itself swallows)", async () => { + fetchMock.mockResolvedValueOnce( + mockResponse({ error: "boom" }, false, 500), + ); + const savePromise = saveUserOverrides("03", { layout: "x" }); + // flush itself should not throw — the original waiter takes the rejection. + const flushPromise = flushUserOverrides(); + await drainMicrotasks(); + await expect(flushPromise).resolves.toBeUndefined(); + await expect(savePromise).rejects.toThrow(/500/); + }); +}); + +// ============================================================================ +// type-level export sanity check (compile-time evidence; runtime no-op) +// ============================================================================ + +describe("UserOverridesPartial type (IMP-52 u5)", () => { + it("permits per-axis null sentinels and partial keys", () => { + // Compile-time only — if any of these stops being a valid assignment, + // the test suite fails at build with a TS error before this assertion + // runs. The expect() is a placebo to keep vitest happy. + const a: UserOverridesPartial = { layout: "x" }; + const b: UserOverridesPartial = { layout: null }; + const c: UserOverridesPartial = { frames: { unit: "tmpl" } }; + const d: UserOverridesPartial = {}; + expect([a, b, c, d]).toHaveLength(4); + }); +}); diff --git a/Front/client/tests/user_overrides_write.test.ts b/Front/client/tests/user_overrides_write.test.ts new file mode 100644 index 0000000..aabb231 --- /dev/null +++ b/Front/client/tests/user_overrides_write.test.ts @@ -0,0 +1,569 @@ +// IMP-52 u10 — Frontend write-side regression coverage. +// +// Stage 2 unit u10 contract: +// 1) All 4 in-scope mutation handlers persist their axis. +// 2) zone_sizes is NOT persisted (handleLayoutResize stays in-memory). +// 3) Write-before-Generate ordering — flushUserOverrides forces pending +// PUTs to commit before the pipeline run begins. +// 4) Restore-on-reopen end-to-end — getUserOverrides → non-frame layering +// and post-loadRun frame remap compose into a single restored state. +// +// React Testing Library is NOT installed in this repo (devDependencies has +// vitest only). Home.tsx's mutation handlers live inside `useCallback` +// closures so they cannot be invoked from a test without mounting the +// component. We cover them with two complementary tactics: +// • Source-pattern grep on Home.tsx that pins the exact wiring shape per +// handler. A regression that drops or rewires a `saveUserOverrides` +// call fails here loudly. +// • End-to-end mocked-fetch tests on the `userOverridesApi` flow with the +// payload shapes that Home.tsx produces — proves the contract the +// handlers depend on still holds. +// +// File extension is `.ts` (no JSX). All tests run in vitest's default node +// environment; fetch is stubbed with vi.stubGlobal and timers are faked so +// the 300ms debounce in `saveUserOverrides` is deterministic. + +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from "vitest"; +import { + __resetUserOverridesBuckets_FOR_TEST, + flushUserOverrides, + getUserOverrides, + saveUserOverrides, + type UserOverridesPartial, +} from "../src/services/userOverridesApi"; +import { + applyPersistedNonFrameOverrides, + createInitialUserSelection, + deriveUserOverridesKey, + remapPersistedFramesToZoneFrames, +} from "../src/utils/slidePlanUtils"; +import type { SlidePlan, Zone } from "../src/types/designAgent"; + +// ─── Source-pattern regression ───────────────────────────────────────────── +// Without RTL we can't dispatch a click and read `fetch.mock.calls`. Instead +// we read Home.tsx as text and assert each in-scope handler closure contains +// the exact wiring that Stage 2 u7 specified. This is brittle in a good way: +// if a handler is renamed or its `saveUserOverrides` call is moved/removed, +// the assertion fires with a clear "X handler does not persist Y axis" +// message instead of silently regressing in prod. + +const HOME_TSX_PATH = path.resolve( + __dirname, + "..", + "src", + "pages", + "Home.tsx", +); +const HOME_TSX = fs.readFileSync(HOME_TSX_PATH, "utf-8"); + +/** + * Slice the `const = useCallback(...)` block out of Home.tsx. The + * handlers are well-formed and end either at the next `const handle...` + * declaration or at the next top-level `const ` at 2-space indent. + */ +function sliceHandler(source: string, name: string): string { + const start = source.indexOf(`const ${name} = useCallback(`); + if (start === -1) { + throw new Error(`handler "${name}" not found in Home.tsx`); + } + // Find the next handler / top-level const after `start`. + const nextHandler = source.indexOf("\n const handle", start + 1); + const nextConst = source.indexOf("\n const ", start + 1); + const candidates = [nextHandler, nextConst].filter((i) => i > start); + const end = candidates.length > 0 ? Math.min(...candidates) : source.length; + return source.slice(start, end); +} + +describe("Home.tsx write-side wiring (IMP-52 u10) — source pattern", () => { + it("handleSectionDrop persists zone_sections behind uploadedFile gate", () => { + const block = sliceHandler(HOME_TSX, "handleSectionDrop"); + // gate + expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/); + // axis key + value source + expect(block).toMatch( + /saveUserOverrides\([\s\S]*?zone_sections:\s*finalSelection\.overrides\.zone_sections/, + ); + // key derivation + expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/); + }); + + it("handleLayoutSelect persists `layout` axis behind uploadedFile gate", () => { + const block = sliceHandler(HOME_TSX, "handleLayoutSelect"); + expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/); + expect(block).toMatch( + /saveUserOverrides\([\s\S]*?layout:\s*layoutId\s*\}/, + ); + expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/); + }); + + it("handleZoneResize persists merged zone_geometries behind uploadedFile gate", () => { + const block = sliceHandler(HOME_TSX, "handleZoneResize"); + expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/); + // merged geometry (not the partial delta) is persisted so the on-disk + // axis is a complete snapshot of all currently-resized zones. + expect(block).toMatch( + /saveUserOverrides\([\s\S]*?zone_geometries:\s*mergedGeometries/, + ); + expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/); + }); + + it("handleFrameSelect persists frames-by-unit_id with default-frame gate", () => { + const block = sliceHandler(HOME_TSX, "handleFrameSelect"); + expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*&&\s*effectiveSlidePlan\s*\)/); + // unit_id derivation matches handleGenerate's CLI-forwarding contract + expect(block).toMatch(/z\.section_ids\.join\(\s*"\+"\s*\)/); + // default-frame gate (rewind fix from Codex #17 / Claude #18) + expect(block).toMatch(/overrideId\s*!==\s*defaultFrameId/); + // axis key + expect(block).toMatch( + /saveUserOverrides\([\s\S]*?frames:\s*framesByUnitId/, + ); + }); + + it("handleLayoutResize does NOT call saveUserOverrides (zone_sizes excluded)", () => { + const block = sliceHandler(HOME_TSX, "handleLayoutResize"); + expect(block).not.toMatch(/saveUserOverrides/); + // Sanity: handleLayoutResize still writes zone_sizes in-memory. + expect(block).toMatch(/saveZoneSizes/); + }); + + it("handleGenerate does NOT call saveUserOverrides (read-only re: persistence layer)", () => { + const block = sliceHandler(HOME_TSX, "handleGenerate"); + // handleGenerate forwards overrides through runPipeline → /api/run, not + // through /api/user-overrides. The persistence layer is owned by the + // four mutation handlers; Generate must not introduce a competing + // write path that could clobber a partially-edited bucket. + expect(block).not.toMatch(/saveUserOverrides\(/); + }); + + it("no handler in Home.tsx persists the zone_sizes axis", () => { + // Top-level regression: searching the whole file rules out a future + // accidental wiring inside a new handler we forgot to enumerate above. + expect(HOME_TSX).not.toMatch( + /saveUserOverrides\([\s\S]{0,200}?zone_sizes\s*:/, + ); + }); +}); + +// ─── Payload-shape contract via mocked fetch ─────────────────────────────── +// Drive `saveUserOverrides` with the exact payload shapes each in-scope +// handler produces in Home.tsx. Asserts that (a) the PUT body matches what +// the on-disk schema (u1 / u4) accepts and (b) the partial-axis contract +// holds — only the mutated axis is sent, never a full snapshot. + +type MockResponse = { + ok: boolean; + status: number; + json: () => Promise; +}; + +function mockResponse(body: unknown, ok = true, status = 200): MockResponse { + return { ok, status, json: async () => body }; +} + +let fetchMock: Mock; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.useFakeTimers(); + __resetUserOverridesBuckets_FOR_TEST(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + __resetUserOverridesBuckets_FOR_TEST(); +}); + +async function drainMicrotasks(): Promise { + for (let i = 0; i < 4; i++) { + await Promise.resolve(); + } +} + +function lastPutBody(): unknown { + const lastCall = fetchMock.mock.calls.at(-1); + if (!lastCall) throw new Error("fetch was not called"); + const init = lastCall[1] as RequestInit | undefined; + if (!init?.body) throw new Error("fetch called without a body"); + return JSON.parse(String(init.body)); +} + +describe("save payload contract per axis (IMP-52 u10)", () => { + it("section-drop payload: PUT body carries only zone_sections", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + // Shape produced by handleSectionDrop after moveSectionToZone. + const payload: UserOverridesPartial = { + zone_sections: { + top: ["03-1", "03-2"], + bottom: ["03-3"], + }, + }; + void saveUserOverrides("03_demo", payload); + vi.advanceTimersByTime(300); + await drainMicrotasks(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = lastPutBody() as Record; + expect(Object.keys(body)).toEqual(["zone_sections"]); + expect(body.zone_sections).toEqual(payload.zone_sections); + }); + + it("layout-select payload: PUT body carries only `layout` (string)", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03_demo", { layout: "two-column" }); + vi.advanceTimersByTime(300); + await drainMicrotasks(); + const body = lastPutBody() as Record; + expect(Object.keys(body)).toEqual(["layout"]); + expect(body.layout).toBe("two-column"); + }); + + it("zone-resize payload: PUT body carries only zone_geometries (merged snapshot)", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + const merged = { + top: { x: 0, y: 0, w: 1, h: 0.42 }, + bottom_l: { x: 0, y: 0.42, w: 0.5, h: 0.58 }, + bottom_r: { x: 0.5, y: 0.42, w: 0.5, h: 0.58 }, + }; + void saveUserOverrides("03_demo", { zone_geometries: merged }); + vi.advanceTimersByTime(300); + await drainMicrotasks(); + const body = lastPutBody() as Record; + expect(Object.keys(body)).toEqual(["zone_geometries"]); + expect(body.zone_geometries).toEqual(merged); + }); + + it("frame-select payload: PUT body carries only frames (unit_id → template_id)", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + // Shape produced by handleFrameSelect after the default-frame gate: + // only zones the user explicitly chose a non-default frame for. + const framesByUnitId = { + "03-1": "process_product_two_way", + "03-2+03-3": "three_parallel_requirements", + }; + void saveUserOverrides("03_demo", { frames: framesByUnitId }); + vi.advanceTimersByTime(300); + await drainMicrotasks(); + const body = lastPutBody() as Record; + expect(Object.keys(body)).toEqual(["frames"]); + expect(body.frames).toEqual(framesByUnitId); + }); + + it("frame-select payload with empty framesByUnitId still PUTs (replaces axis with {})", async () => { + // When the user reverts the last frame override back to the backend + // default, handleFrameSelect computes `framesByUnitId = {}`. The PUT + // path still fires so the on-disk `frames` axis is cleared to the empty + // object via u4's partial-merge replace semantics. Foreign axes + // (layout / zone_geometries / zone_sections) remain on disk. + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03_demo", { frames: {} }); + vi.advanceTimersByTime(300); + await drainMicrotasks(); + expect(lastPutBody()).toEqual({ frames: {} }); + }); +}); + +// ─── zone_sizes axis is not part of the on-disk schema ───────────────────── + +describe("zone_sizes axis exclusion (IMP-52 u10)", () => { + it("UserOverridesPartial type does not include zone_sizes at compile time", () => { + // Compile-time check: this assignment must be a TS error. The runtime + // assertion below is a placebo; the meaningful evidence is that the + // suite *builds*. If a future schema bump adds zone_sizes to + // UserOverrides, this comment serves as the migration touchpoint. + // @ts-expect-error — zone_sizes is intentionally not part of UserOverridesPartial + const _bad: UserOverridesPartial = { zone_sizes: { layout_group_1: [0.5, 0.5] } }; + void _bad; + expect(true).toBe(true); + }); + + it("Home.tsx never imports a write helper that would persist zone_sizes", () => { + // handleLayoutResize delegates to saveZoneSizes (in-memory), not + // saveUserOverrides. Cross-check the import line and the handler body. + expect(HOME_TSX).toMatch(/import\s*\{[^}]*\bsaveZoneSizes\b[^}]*\}\s*from\s*"\.\.\/utils\/slidePlanUtils"/); + const block = sliceHandler(HOME_TSX, "handleLayoutResize"); + expect(block).toMatch(/saveZoneSizes\(/); + expect(block).not.toMatch(/saveUserOverrides/); + }); +}); + +// ─── Write-before-Generate ordering ──────────────────────────────────────── +// The four mutation handlers schedule debounced PUTs (300ms). If the user +// hits Generate before the debounce fires, the persistence layer must not +// drop the pending writes. `flushUserOverrides` is the contract: callers can +// force-commit pending buckets before pipeline kickoff so the backend u2 +// fallback reads the latest file. + +describe("write-before-Generate ordering (IMP-52 u10)", () => { + // The service-level tests below prove the `flushUserOverrides` contract in + // isolation. The two source-pattern checks here pin the *real* Generate + // call site so a future refactor that drops the flush — re-exposing the + // 300ms debounce race against `runPipeline` / the u2 backend fallback — + // fails loudly. Without React Testing Library we cannot dispatch a click + // on the Generate button, so we read Home.tsx as text and assert (a) the + // import names `flushUserOverrides`, (b) the `handleGenerate` closure + // awaits the flush before it awaits `runPipeline`. + + it("Home.tsx imports flushUserOverrides from userOverridesApi", () => { + expect(HOME_TSX).toMatch( + /import\s*\{[^}]*\bflushUserOverrides\b[^}]*\}\s*from\s*"\.\.\/services\/userOverridesApi"/, + ); + }); + + it("handleGenerate awaits flushUserOverrides before awaiting runPipeline", () => { + const block = sliceHandler(HOME_TSX, "handleGenerate"); + expect(block).toMatch(/await\s+flushUserOverrides\s*\(\s*\)/); + expect(block).toMatch(/await\s+runPipeline\s*\(/); + const flushIdx = block.search(/await\s+flushUserOverrides\s*\(/); + const runIdx = block.search(/await\s+runPipeline\s*\(/); + expect(flushIdx).toBeGreaterThan(-1); + expect(runIdx).toBeGreaterThan(-1); + expect(flushIdx).toBeLessThan(runIdx); + }); + + it("flushUserOverrides commits a pending PUT before its 300ms debounce fires", async () => { + fetchMock.mockResolvedValue(mockResponse({ layout: "two-column" })); + const savePromise = saveUserOverrides("03_demo", { layout: "two-column" }); + + // Without flush, the PUT would not fire for another 300ms. + expect(fetchMock).not.toHaveBeenCalled(); + + const flushPromise = flushUserOverrides(); + await drainMicrotasks(); + await flushPromise; + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/user-overrides/03_demo"); + expect((init as RequestInit).method).toBe("PUT"); + + // Caller's promise resolves with the server-merged document — so a + // pre-Generate `await flushUserOverrides()` can be paired with + // `await savePromise` for stronger ordering if needed. + await expect(savePromise).resolves.toEqual({ layout: "two-column" }); + }); + + it("flushUserOverrides (no arg) flushes pending writes across multiple MDX keys", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03_demo", { layout: "two-column" }); + void saveUserOverrides("04_demo", { frames: { "04-1": "tpl_a" } }); + void saveUserOverrides("05_demo", { + zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } }, + }); + + await flushUserOverrides(); + await drainMicrotasks(); + + const putUrls = fetchMock.mock.calls + .filter((c) => (c[1] as RequestInit).method === "PUT") + .map((c) => c[0]); + expect(putUrls).toEqual( + expect.arrayContaining([ + "/api/user-overrides/03_demo", + "/api/user-overrides/04_demo", + "/api/user-overrides/05_demo", + ]), + ); + expect(putUrls).toHaveLength(3); + }); + + it("flushUserOverrides is a no-op when no writes are pending", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + await flushUserOverrides(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("post-flush, a new save schedules a fresh 300ms debounce window", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03_demo", { layout: "two-column" }); + await flushUserOverrides(); + await drainMicrotasks(); + expect( + fetchMock.mock.calls.filter( + (c) => (c[1] as RequestInit).method === "PUT", + ), + ).toHaveLength(1); + + // Second save after Generate completes — must not piggy-back on the + // already-flushed bucket; must re-arm a fresh debounce. + void saveUserOverrides("03_demo", { layout: "hero-detail" }); + vi.advanceTimersByTime(299); + await drainMicrotasks(); + expect( + fetchMock.mock.calls.filter( + (c) => (c[1] as RequestInit).method === "PUT", + ), + ).toHaveLength(1); + vi.advanceTimersByTime(1); + await drainMicrotasks(); + expect( + fetchMock.mock.calls.filter( + (c) => (c[1] as RequestInit).method === "PUT", + ), + ).toHaveLength(2); + }); +}); + +// ─── Restore-on-reopen — end-to-end compose ──────────────────────────────── +// u6 covers the helpers in isolation. This test wires them together with a +// mocked GET response in the order Home.tsx invokes them at file-upload +// time (key derive → fetch persisted → layer non-frame axes pre-loadRun → +// remap frames post-loadRun) to pin the integration contract. + +function makeZone(partial: { + id: string; + zone_id: string; + section_ids: string[]; + default_frame_id?: string | null; +}): Zone { + return { + id: partial.id, + zone_id: partial.zone_id, + section_ids: partial.section_ids, + position: { x: 0, y: 0, width: 1, height: 1 }, + internal_regions: [ + { + id: `${partial.id}-r0`, + region_id: "region-single", + role: "primary", + content_type: "text_block", + ratio_estimate: 1, + content_unit_ids: [], + frame_match_strategy: { + kind: "frame_match", + frame_id: partial.default_frame_id ?? null, + display_strategy: "inline_full", + }, + frame_candidates: [], + }, + ], + }; +} + +describe("restore-on-reopen end-to-end (IMP-52 u10)", () => { + it("getUserOverrides → non-frame layer + post-load frame remap composes a restored selection", async () => { + // GET returns the persisted file for "03_demo". The `layout` value + // must be a real LayoutPresetId — applyPersistedNonFrameOverrides + // validates against the 8-preset whitelist (slidePlanUtils.ts:30). + fetchMock.mockResolvedValueOnce( + mockResponse({ + layout: "horizontal-2", + frames: { "03-1": "process_product_two_way" }, + zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.42 } }, + zone_sections: { top: ["03-1"], bottom: ["03-2", "03-3"] }, + }), + ); + + const key = deriveUserOverridesKey("03_demo.mdx"); + expect(key).toBe("03_demo"); + + // Step 1: Home.tsx fetches at handleFileUpload time. + const persisted = await getUserOverrides(key); + expect(persisted.layout).toBe("horizontal-2"); + + // Step 2: pre-loadRun layering applies layout / zone_geometries / + // zone_sections onto a fresh selection. Frames are deferred because + // the unit_id key cannot be remapped without a slidePlan yet. + const seededSelection = applyPersistedNonFrameOverrides( + createInitialUserSelection(null), + persisted, + ); + expect(seededSelection.overrides.layout_preset).toBe("horizontal-2"); + expect(seededSelection.overrides.zone_geometries).toEqual({ + top: { x: 0, y: 0, w: 1, h: 0.42 }, + }); + expect(seededSelection.overrides.zone_sections).toEqual({ + top: ["03-1"], + bottom: ["03-2", "03-3"], + }); + // Frames must NOT have been layered at this stage. + expect(seededSelection.overrides.zone_frames).toEqual({}); + + // Step 3: post-loadRun, Home.tsx has a slidePlan. Remap unit_id-keyed + // frames to region.id-keyed frames against the rebuilt plan. + const plan: SlidePlan = { + id: "plan-3", + title: "demo", + layout_preset: "horizontal-2", + zones: [ + makeZone({ + id: "z-top", + zone_id: "top", + section_ids: ["03-1"], + default_frame_id: "some_default_frame", + }), + makeZone({ + id: "z-bot", + zone_id: "bottom", + section_ids: ["03-2", "03-3"], + default_frame_id: null, + }), + ], + }; + const remapped = remapPersistedFramesToZoneFrames( + plan, + persisted.frames, + ); + expect(remapped).toEqual({ + "z-top-r0": "process_product_two_way", + }); + + // Step 4: post-loadRun merge — Home.tsx layers `remapped` onto + // `createInitialUserSelection(slidePlan)` so the SlideCanvas + // override-vs-default preview indicator surfaces the restored choice. + const finalSelection = { + ...applyPersistedNonFrameOverrides( + createInitialUserSelection(plan), + persisted, + ), + }; + finalSelection.overrides = { + ...finalSelection.overrides, + zone_frames: { ...finalSelection.overrides.zone_frames, ...remapped }, + }; + expect(finalSelection.overrides.zone_frames["z-top-r0"]).toBe( + "process_product_two_way", + ); + expect(finalSelection.overrides.layout_preset).toBe("horizontal-2"); + expect(finalSelection.overrides.zone_sections).toEqual({ + top: ["03-1"], + bottom: ["03-2", "03-3"], + }); + }); + + it("missing persisted file (GET returns {}) leaves the selection at backend defaults", async () => { + fetchMock.mockResolvedValueOnce(mockResponse({})); + const persisted = await getUserOverrides(deriveUserOverridesKey("new_file.mdx")); + expect(persisted).toEqual({}); + + const plan: SlidePlan = { + id: "plan-x", + title: "fresh", + layout_preset: "single", + zones: [ + makeZone({ id: "z-only", zone_id: "main", section_ids: ["x-1"] }), + ], + }; + const seeded = applyPersistedNonFrameOverrides( + createInitialUserSelection(plan), + persisted, + ); + // No override applied → layout_preset, geometries, sections all from + // the slidePlan defaults; remap yields {} so no frames layered. + expect(seeded.overrides.layout_preset).toBe("single"); + expect(seeded.overrides.zone_geometries).toEqual({}); + expect(remapPersistedFramesToZoneFrames(plan, persisted.frames)).toEqual({}); + }); +}); diff --git a/Front/vite.config.ts b/Front/vite.config.ts index 6e3d0f2..72a4854 100644 --- a/Front/vite.config.ts +++ b/Front/vite.config.ts @@ -204,12 +204,307 @@ function vitePluginStorageProxy(): Plugin { }; } +// ============================================================================= +// IMP-52 u3/u4 — user_overrides.json persistence (MDX-stem keyed store). +// +// On-disk layout: /data/user_overrides/.json. Mirrors +// the Python contract in src/user_overrides_io.py — same validate_key regex, +// same graceful-degrade (corrupt → {}) so backend pipeline entry fallback +// (u2) and the vite endpoints (u3 GET, u4 PUT) agree on every file. +// +// Helpers are named exports so vitest can drive handleGetUserOverrides / +// handlePutUserOverrides with mock req/res without booting a real dev +// server. vite still consumes the default `defineConfig` export below. +// ============================================================================= + +export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/; + +// The four 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). +export const KNOWN_USER_OVERRIDES_AXES = [ + "layout", + "zone_geometries", + "zone_sections", + "frames", +] as const; +export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number]; + +// 1MB cap on PUT bodies. Override files in practice are < 10KB (4 axes, +// each a small dict). The cap is a safety net against runaway client +// loops, not a real schema constraint. +const USER_OVERRIDES_PUT_MAX_BYTES = 1_000_000; + +export function isValidUserOverridesKey(key: string): boolean { + if (!key) return false; + if (key.includes("..")) return false; + if (key.includes("/") || key.includes("\\")) return false; + return USER_OVERRIDES_KEY_RE.test(key); +} + +export function userOverridesPath(root: string, key: string): string { + return path.join(root, "data", "user_overrides", `${key}.json`); +} + +// Minimal req/res shapes — node IncomingMessage / ServerResponse have many +// fields the handler does not touch, so we accept a structural subset for +// testability. +type GetReqLike = { method?: string; url?: string }; +type PutReqLike = { + method?: string; + url?: string; + on(event: "data" | "end" | "error", cb: (...args: any[]) => void): unknown; +}; +type ResLike = { + writeHead: (status: number, headers?: Record) => void; + end: (body?: string) => void; +}; + +// IMP-52 u3 — GET /api/user-overrides/:key handler. Returns true when the +// handler took over the response, false when the caller should `next()`. +// Invariants: +// • method != GET → false (chain continues; u4 PUT may handle) +// • invalid key → 400 {"error":"invalid key"} +// • file missing → 200 {} +// • file unreadable/corrupt → 200 {} (graceful degrade, mirrors u1 load) +// • non-object JSON root → 200 {} (mirrors u1 load) +// • valid object JSON → 200 with parsed JSON body +export function handleGetUserOverrides( + req: GetReqLike, + res: ResLike, + root: string, +): boolean { + if (req.method !== "GET") return false; + + const url = req.url || ""; + const key = url.split("?")[0].replace(/^\//, ""); + + if (!isValidUserOverridesKey(key)) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "invalid key" })); + return true; + } + + const filePath = userOverridesPath(root, key); + + if (!fs.existsSync(filePath)) { + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end("{}"); + return true; + } + + let parsed: unknown; + try { + const raw = fs.readFileSync(filePath, "utf-8"); + parsed = JSON.parse(raw); + } catch { + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end("{}"); + return true; + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end("{}"); + return true; + } + + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(parsed)); + return true; +} + +// IMP-52 u4 — pure merge function. Mirrors src/user_overrides_io.save(): +// • Only KNOWN_USER_OVERRIDES_AXES present in `partial` are mutated. +// • Axes absent from `partial` are preserved verbatim from `existing`. +// • Foreign top-level keys in `existing` (future axes like zone_sizes +// or image_overrides) are preserved verbatim — allowlist guards what +// the PUT writes, NOT what the file already holds. +// • `partial[axis] = null` is the explicit clear sentinel (remove key). +// • Any non-axis keys in `partial` are silently dropped (allowlist). +export function mergeUserOverrides( + existing: Record, + partial: Record, +): Record { + const merged: Record = { ...existing }; + for (const axis of KNOWN_USER_OVERRIDES_AXES) { + if (!(axis in partial)) continue; + const value = partial[axis]; + if (value === null) { + delete merged[axis]; + } else { + merged[axis] = value; + } + } + return merged; +} + +// IMP-52 u4 — atomic file write via tmp + rename. Mirrors the +// `_atomic_write_json` semantics in src/user_overrides_io.py so a +// crashed/interrupted PUT cannot leave a half-written .json on disk +// (the next GET / pipeline-entry read would otherwise return {} via +// graceful degrade, silently losing the user's prior overrides). +export function atomicWriteUserOverrides( + filePath: string, + data: Record, +): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const tmpName = path.join( + dir, + `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`, + ); + try { + fs.writeFileSync( + tmpName, + JSON.stringify(data, null, 2) + "\n", + "utf-8", + ); + fs.renameSync(tmpName, filePath); + } catch (err) { + try { + fs.unlinkSync(tmpName); + } catch { + // best-effort cleanup; the rename source may not exist on early failure + } + throw err; + } +} + +// IMP-52 u4 — PUT /api/user-overrides/:key handler. Returns true when the +// handler took over the response, false when the caller should `next()`. +// Invariants: +// • method != PUT → false (chain continues; GET runs first) +// • invalid key → 400 {"error":"invalid key"} +// • body > 1MB → 413 {"error":"payload too large"} +// • invalid JSON → 400 {"error":"invalid JSON"} +// • non-object JSON root → 400 {"error":"body must be a JSON object"} +// • write failure → 500 {"error":"write failed: ..."} +// • success → 200 with merged JSON body +// +// Existing-file read uses the same graceful-degrade rules as GET (corrupt +// JSON / non-object root → treat as empty {}) so a PUT cannot fail solely +// because a prior file is unparseable — the new payload replaces it. +export function handlePutUserOverrides( + req: PutReqLike, + res: ResLike, + root: string, +): boolean { + if (req.method !== "PUT") return false; + + const url = req.url || ""; + const key = url.split("?")[0].replace(/^\//, ""); + + if (!isValidUserOverridesKey(key)) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "invalid key" })); + return true; + } + + let body = ""; + let aborted = false; + + req.on("data", (chunk: Buffer | string) => { + if (aborted) return; + body += typeof chunk === "string" ? chunk : chunk.toString(); + if (body.length > USER_OVERRIDES_PUT_MAX_BYTES) { + aborted = true; + res.writeHead(413, { + "Content-Type": "application/json; charset=utf-8", + }); + res.end(JSON.stringify({ error: "payload too large" })); + } + }); + + req.on("end", () => { + if (aborted) return; + + let parsed: unknown; + try { + parsed = body.length > 0 ? JSON.parse(body) : {}; + } catch { + res.writeHead(400, { + "Content-Type": "application/json; charset=utf-8", + }); + res.end(JSON.stringify({ error: "invalid JSON" })); + return; + } + + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + res.writeHead(400, { + "Content-Type": "application/json; charset=utf-8", + }); + res.end(JSON.stringify({ error: "body must be a JSON object" })); + return; + } + + const partial = parsed as Record; + const filePath = userOverridesPath(root, key); + + // Load existing — corrupt / non-object → {} so the PUT still succeeds + // and recovers the file to a clean state. Mirrors u1 load() graceful + // degrade. + let existing: Record = {}; + if (fs.existsSync(filePath)) { + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const ex = JSON.parse(raw); + if ( + typeof ex === "object" && + ex !== null && + !Array.isArray(ex) + ) { + existing = ex as Record; + } + } catch { + // corrupt → treat as empty + } + } + + const merged = mergeUserOverrides(existing, partial); + + try { + atomicWriteUserOverrides(filePath, merged); + } catch (err) { + res.writeHead(500, { + "Content-Type": "application/json; charset=utf-8", + }); + res.end(JSON.stringify({ error: `write failed: ${String(err)}` })); + return; + } + + res.writeHead(200, { + "Content-Type": "application/json; charset=utf-8", + }); + res.end(JSON.stringify(merged)); + }); + + req.on("error", () => { + if (aborted) return; + aborted = true; + res.writeHead(500, { + "Content-Type": "application/json; charset=utf-8", + }); + res.end(JSON.stringify({ error: "request error" })); + }); + + return true; +} + // ============================================================================= // Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출 // // Endpoints (vite dev middleware) : // POST /api/run multipart/JSON body {filename, content} → run_id // GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path} +// GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3) +// PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4) // // 환경 변수 (선택) : // DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent @@ -464,6 +759,19 @@ function vitePluginPhaseZApi(): Plugin { fs.createReadStream(previewPath).pipe(res); }); + // ── GET / PUT /api/user-overrides/{key} → data/user_overrides/{key}.json ── + // IMP-52 u3 (GET) + u4 (PUT) — MDX-stem keyed user overrides. Logic + // lives in the pure helpers (handleGetUserOverrides / handlePutUserOverrides) + // so vitest can exercise them without booting vite. Both handlers + // return false when the HTTP method does not match, so they chain + // cleanly: GET first, then PUT, then next() for everything else + // (e.g., OPTIONS / preflight handled by upstream middleware). + server.middlewares.use("/api/user-overrides", (req, res, next) => { + if (handleGetUserOverrides(req, res, DESIGN_AGENT_ROOT)) return; + if (handlePutUserOverrides(req, res, DESIGN_AGENT_ROOT)) return; + next(); + }); + // ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ── server.middlewares.use("/data/runs", (req, res, next) => { if (req.method !== "GET") return next(); diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index 51e74f3..2f8c7cc 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -1689,6 +1689,14 @@ def _override_to_grid_tracks( R = len(rows_grid) C = len(rows_grid[0]) + # Hot-fix (2026-05-22): partial override 버그 fix — override 없는 track 은 + # default 비율로 fallback. 이전엔 0 반환 → normalize 후 다른 track 이 모든 공간 흡수. + _default_result = _build_grid_dynamic_2d(preset, zones_data, gap=gap) + _default_widths = _default_result.get("widths_px", []) or [] + _default_heights = _default_result.get("heights_px", []) or [] + _sum_w = sum(_default_widths) if _default_widths else 1.0 + _sum_h = sum(_default_heights) if _default_heights else 1.0 + occupancy: list[tuple[dict, set[int], set[int]]] = [] for z in zones_data: pos = z["position"] @@ -1703,17 +1711,19 @@ def _override_to_grid_tracks( single = [z for z, rr, _cc in occupancy if rr == {idx}] allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))] key = "h" + _fallback = (_default_heights[idx] / _sum_h) if idx < len(_default_heights) and _sum_h else (1.0 / R) else: single = [z for z, _rr, cc in occupancy if cc == {idx}] allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))] key = "w" + _fallback = (_default_widths[idx] / _sum_w) if idx < len(_default_widths) and _sum_w else (1.0 / C) candidates = single or allspan vals = [ float(override_zone_geometries[z["position"]][key]) for z in candidates if z["position"] in override_zone_geometries ] - return max(vals) if vals else 0.0 + return max(vals) if vals else _fallback row_values = [_track_value(r, "row") for r in range(R)] col_values = [_track_value(c, "col") for c in range(C)] @@ -1793,10 +1803,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict], if override_zone_geometries: if layout_preset == "horizontal-2": # heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배. + # Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에 + # 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐. + overridden_h = sum( + float(override_zone_geometries[p]["h"]) + for p in positions if p in override_zone_geometries + ) + non_overridden = [p for p in positions if p not in override_zone_geometries] + per_non = max(0.0, 1.0 - overridden_h) / max(len(non_overridden), 1) ratios = [] for pos in positions: geom = override_zone_geometries.get(pos) - ratios.append(float(geom["h"]) if geom else 0.0) + ratios.append(float(geom["h"]) if geom else per_non) total = sum(ratios) if total > 0: heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios] @@ -1818,10 +1836,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict], # cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols). # PR 1 keeps fr-string cols for legacy preserve; widths_px is # populated in pixels for _compute_per_zone_geometry length contract. + # Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에 + # 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐. + overridden_w = sum( + float(override_zone_geometries[p]["w"]) + for p in positions if p in override_zone_geometries + ) + non_overridden = [p for p in positions if p not in override_zone_geometries] + per_non = max(0.0, 1.0 - overridden_w) / max(len(non_overridden), 1) ratios = [] for pos in positions: geom = override_zone_geometries.get(pos) - ratios.append(float(geom["w"]) if geom else 0.0) + ratios.append(float(geom["w"]) if geom else per_non) total = sum(ratios) if total > 0: cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios) @@ -5917,10 +5943,81 @@ if __name__ == "__main__": _seen_sections_across_zones[sid] = zid overrides_section_assignments[zid] = section_ids + # IMP-52 (#80) u2 — user_overrides.json persistence fallback. + # After argparse fully parses CLI flags, fill ONLY the axes the user + # did NOT pass on the command line. CLI payload always wins over the + # persisted file (Stage 2 lock: "CLI > file, 결손 축만 채움"). + # MDX stem keys the persistence file; invalid stems / corrupt file + # degrade gracefully (warning to stderr + no override injected). + from src.user_overrides_io import ( + InvalidOverrideKey, + load as _load_user_overrides, + validate_key as _validate_overrides_key, + ) + + _final_override_layout = args.override_layout + try: + _ov_key = _validate_overrides_key(Path(args.mdx_path).stem) + except InvalidOverrideKey as _exc: + print( + f"[user_overrides] warning: cannot derive persistence key from " + f"mdx_path {args.mdx_path!r}: {_exc}; skipping fallback.", + file=sys.stderr, + ) + _ov_key = None + if _ov_key is not None: + _persisted = _load_user_overrides(_ov_key) + # layout — CLI None → fill from file (must be str). + if _final_override_layout is None: + _file_layout = _persisted.get("layout") + if isinstance(_file_layout, str) and _file_layout: + _final_override_layout = _file_layout + # frames — CLI empty → fill from file (must be dict[str, str]). + if not overrides_frames: + _file_frames = _persisted.get("frames") + if isinstance(_file_frames, dict): + overrides_frames = { + str(k): str(v) + for k, v in _file_frames.items() + if isinstance(k, str) and isinstance(v, str) + } + # zone_geometries — CLI empty → fill from file (dict[str, dict]). + if not overrides_geoms: + _file_geoms = _persisted.get("zone_geometries") + if isinstance(_file_geoms, dict): + _accepted: dict[str, dict] = {} + for _zid, _g in _file_geoms.items(): + if ( + isinstance(_zid, str) + and isinstance(_g, dict) + and all(k in _g for k in ("x", "y", "w", "h")) + ): + try: + _accepted[_zid] = { + "x": float(_g["x"]), + "y": float(_g["y"]), + "w": float(_g["w"]), + "h": float(_g["h"]), + } + except (TypeError, ValueError): + continue + overrides_geoms = _accepted + # zone_sections — CLI empty → fill from file (dict[str, list[str]]). + if not overrides_section_assignments: + _file_sections = _persisted.get("zone_sections") + if isinstance(_file_sections, dict): + _accepted_sec: dict[str, list[str]] = {} + for _zid, _sec_list in _file_sections.items(): + if isinstance(_zid, str) and isinstance(_sec_list, list): + _sids = [s for s in _sec_list if isinstance(s, str) and s] + if _sids: + _accepted_sec[_zid] = _sids + overrides_section_assignments = _accepted_sec + run_phase_z2_mvp1( args.mdx_path, args.run_id, - override_layout=args.override_layout, + override_layout=_final_override_layout, override_frames=overrides_frames or None, override_zone_geometries=overrides_geoms or None, override_section_assignments=overrides_section_assignments or None, diff --git a/src/user_overrides_io.py b/src/user_overrides_io.py new file mode 100644 index 0000000..c461482 --- /dev/null +++ b/src/user_overrides_io.py @@ -0,0 +1,162 @@ +"""IMP-52 (#80) u1 — user_overrides.json persistence layer (backend IO). + +Persists the four CLI-wired override axes per MDX so a subsequent render +auto-restores user choices without re-clicking. Source of truth = MDX-keyed +file (stem of the MDX path), NOT ``data/runs//`` which mints a fresh +run_id per ``/api/run`` invocation. + +Schema (4 axes; stable order): + + { + "layout": , + "zone_geometries": {: {"x": float, "y": float, "w": float, "h": float}}, + "zone_sections": {: [, ...]}, + "frames": {: } + } + +``unit_id`` is the convention already used by ``--override-frame`` : +``"+".join(source_section_ids)`` (e.g., ``"03-1"`` or ``"03-1+03-2"``). + +Behavior : +- ``load(key)`` — file missing or corrupt → ``{}`` (warning to stderr on corrupt). +- ``save(key, partial)`` — merges only the supplied axes onto the existing + file, preserving (a) unknown top-level keys (foreign-key preserve) and + (b) axes not present in the partial payload. Atomic write via tmp+rename. +- ``override_path(key, root=None)`` — resolves the persistence path under + ``data/user_overrides/.json``. + +Guardrails (refs : ``user_overrides_io`` Stage 2 lock) : +- Deterministic code, no AI fallback. +- ``key`` validation rejects path traversal / separators / dot-prefix. +- ``save`` is a deep-shallow merge — per-axis dict mutation does not delete + prior keys unless caller passes ``None`` for that axis (explicit clear). +""" +from __future__ import annotations + +import json +import os +import re +import sys +import tempfile +from pathlib import Path +from typing import Any, Optional + +# Persistence root — MDX-keyed, decoupled from data/runs//. +# Resolved at call time so tests can monkeypatch via ``root=`` parameter. +_PKG_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides" + +# The four in-scope axes. 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, image_overrides) without a schema bump here. +KNOWN_AXES: tuple[str, ...] = ("layout", "zone_geometries", "zone_sections", "frames") + +# Key validation — MDX stem must be safe for filesystem use. Allow +# alphanumerics, underscore, hyphen, and dot in the middle (sample stems +# are e.g. ``01``, ``03``, ``03__DX...``). Reject leading dot, path +# separators, and traversal. +_KEY_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$") + + +class InvalidOverrideKey(ValueError): + """Raised when ``key`` is not a safe MDX stem.""" + + +def validate_key(key: str) -> str: + """Validate that ``key`` is a safe MDX stem; return it unchanged. + + Rejects empty strings, path separators (``/`` ``\\``), traversal + (``..``), and leading dot. Callers should pass ``Path(mdx_path).stem``. + """ + if not isinstance(key, str) or not key: + raise InvalidOverrideKey(f"key must be a non-empty string, got: {key!r}") + if not _KEY_RE.match(key): + raise InvalidOverrideKey( + f"key must match {_KEY_RE.pattern!r} (alphanumerics, '_', '-', '.'; " + f"no leading dot, no separators); got: {key!r}" + ) + if ".." in key: + raise InvalidOverrideKey(f"key must not contain '..'; got: {key!r}") + return key + + +def override_path(key: str, root: Optional[Path] = None) -> Path: + """Resolve the on-disk path for ``key``'s override file.""" + validate_key(key) + base = Path(root) if root is not None else DEFAULT_OVERRIDES_ROOT + return base / f"{key}.json" + + +def load(key: str, root: Optional[Path] = None) -> dict[str, Any]: + """Load persisted overrides for ``key``. + + Missing file → ``{}``. Corrupt JSON → warning to stderr + ``{}``. + Returns the raw mapping (including any foreign keys); callers should + pick the four KNOWN_AXES they care about. + """ + path = override_path(key, root=root) + if not path.exists(): + return {} + try: + with path.open("r", encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as exc: + print( + f"[user_overrides_io] warning: failed to read {path} ({exc}); " + f"treating as empty.", + file=sys.stderr, + ) + return {} + if not isinstance(data, dict): + print( + f"[user_overrides_io] warning: {path} is not a JSON object " + f"(got {type(data).__name__}); treating as empty.", + file=sys.stderr, + ) + return {} + return data + + +def save(key: str, partial: dict[str, Any], root: Optional[Path] = None) -> Path: + """Merge ``partial`` onto the persisted overrides for ``key`` and write atomically. + + Merge semantics : + - Only keys present in ``partial`` are mutated. Other axes (including + foreign keys outside KNOWN_AXES) are preserved verbatim. + - For each axis present in ``partial``, the new value REPLACES the prior + value (no per-zone deep-merge). Callers that want to add a single + zone must read → mutate → save with the full updated axis dict. + - Pass ``None`` for an axis to clear it (remove the key from the file). + """ + if not isinstance(partial, dict): + raise TypeError( + f"partial must be a dict, got {type(partial).__name__}: {partial!r}" + ) + path = override_path(key, root=root) + path.parent.mkdir(parents=True, exist_ok=True) + current = load(key, root=root) + for axis_key, axis_value in partial.items(): + if axis_value is None: + current.pop(axis_key, None) + else: + current[axis_key] = axis_value + _atomic_write_json(path, current) + return path + + +def _atomic_write_json(path: Path, data: dict[str, Any]) -> None: + """Write ``data`` to ``path`` atomically via tmp file + os.replace.""" + fd, tmp_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent) + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True) + f.write("\n") + os.replace(tmp_name, path) + except BaseException: + try: + os.unlink(tmp_name) + except OSError: + pass + raise diff --git a/tests/test_user_overrides_io.py b/tests/test_user_overrides_io.py new file mode 100644 index 0000000..5c6c89e --- /dev/null +++ b/tests/test_user_overrides_io.py @@ -0,0 +1,229 @@ +"""IMP-52 (#80) u8 — backend tests for ``src.user_overrides_io``. + +Covers the four axes called out in the Stage 2 plan: + +1. Round-trip ``save`` → ``load`` (4 KNOWN_AXES + foreign top-level keys). +2. Unknown-key passthrough (foreign axes preserved across partial merges). +3. Missing / corrupt / non-object behavior (graceful ``{}`` + stderr warning). +4. Invalid keys (``InvalidOverrideKey`` raised on traversal / separators / + leading dot / empty). + +All tests inject ``root=tmp_path`` so they never touch the real +``data/user_overrides/`` directory. +""" +from __future__ import annotations + +import json + +import pytest + +from src.user_overrides_io import ( + DEFAULT_OVERRIDES_ROOT, + InvalidOverrideKey, + KNOWN_AXES, + load, + override_path, + save, + validate_key, +) + + +# -- key validation --------------------------------------------------------- + + +def test_validate_key_accepts_typical_mdx_stems(): + for key in ("01", "03", "03__DX_master", "sample.v2", "a-b_c.1"): + assert validate_key(key) == key + + +@pytest.mark.parametrize( + "bad_key", + [ + "", + "..", + "../escape", + "sub/dir", + "sub\\dir", + ".hidden", + "-leading-dash", + ".", + "name with space", + "name?", + ], +) +def test_validate_key_rejects_unsafe(bad_key): + with pytest.raises(InvalidOverrideKey): + validate_key(bad_key) + + +def test_validate_key_rejects_non_string(): + for bad in (None, 123, b"bytes", ["list"], {"d": 1}): + with pytest.raises(InvalidOverrideKey): + validate_key(bad) # type: ignore[arg-type] + + +# -- override_path ---------------------------------------------------------- + + +def test_override_path_uses_default_root_when_unspecified(): + p = override_path("sample") + assert p.parent == DEFAULT_OVERRIDES_ROOT + assert p.name == "sample.json" + + +def test_override_path_honors_explicit_root(tmp_path): + p = override_path("sample", root=tmp_path) + assert p == tmp_path / "sample.json" + + +# -- load: missing / corrupt / non-object ----------------------------------- + + +def test_load_missing_file_returns_empty_dict(tmp_path): + assert load("nope", root=tmp_path) == {} + + +def test_load_corrupt_json_warns_and_returns_empty(tmp_path, capsys): + path = override_path("corrupt", root=tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{ this is not valid json", encoding="utf-8") + result = load("corrupt", root=tmp_path) + assert result == {} + captured = capsys.readouterr() + assert "failed to read" in captured.err + assert str(path) in captured.err + + +def test_load_non_object_json_warns_and_returns_empty(tmp_path, capsys): + path = override_path("list_root", root=tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("[1, 2, 3]", encoding="utf-8") + result = load("list_root", root=tmp_path) + assert result == {} + captured = capsys.readouterr() + assert "not a JSON object" in captured.err + + +# -- save: round-trip + partial-merge + foreign-key preserve ---------------- + + +def _full_payload() -> dict: + return { + "layout": "sidebar-right", + "zone_geometries": { + "zone-top": {"x": 40.0, "y": 50.0, "w": 1200.0, "h": 120.0}, + }, + "zone_sections": {"zone-top": ["03-1", "03-2"]}, + "frames": {"03-1+03-2": "frame_two_way_compare"}, + } + + +def test_save_then_load_round_trip(tmp_path): + key = "03" + payload = _full_payload() + written = save(key, payload, root=tmp_path) + assert written.exists() + assert written == tmp_path / "03.json" + + loaded = load(key, root=tmp_path) + for axis in KNOWN_AXES: + assert loaded[axis] == payload[axis], f"axis {axis!r} did not round-trip" + + +def test_save_partial_payload_preserves_other_axes(tmp_path): + key = "03" + save(key, _full_payload(), root=tmp_path) + + save(key, {"layout": "two-column"}, root=tmp_path) + loaded = load(key, root=tmp_path) + + assert loaded["layout"] == "two-column" + assert loaded["zone_geometries"] == _full_payload()["zone_geometries"] + assert loaded["zone_sections"] == _full_payload()["zone_sections"] + assert loaded["frames"] == _full_payload()["frames"] + + +def test_save_axis_replaces_not_deep_merges(tmp_path): + key = "03" + save(key, {"frames": {"03-1": "frame_a", "03-2": "frame_b"}}, root=tmp_path) + save(key, {"frames": {"03-3": "frame_c"}}, root=tmp_path) + loaded = load(key, root=tmp_path) + assert loaded["frames"] == {"03-3": "frame_c"} + + +def test_save_none_clears_axis(tmp_path): + key = "03" + save(key, _full_payload(), root=tmp_path) + save(key, {"layout": None}, root=tmp_path) + loaded = load(key, root=tmp_path) + assert "layout" not in loaded + assert loaded["zone_geometries"] == _full_payload()["zone_geometries"] + assert loaded["frames"] == _full_payload()["frames"] + + +def test_save_preserves_foreign_top_level_keys(tmp_path): + """Forward-compat: axes outside KNOWN_AXES (zone_sizes, image_overrides, + schema_version, ...) must survive a partial merge on a known axis.""" + key = "03" + path = override_path(key, root=tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + pre_seed = { + "layout": "single-column", + "image_overrides": {"img-1": {"position": "right", "size": "small"}}, + "zone_sizes": {"zone-top": "tall"}, + "schema_version": "experimental-1", + } + path.write_text(json.dumps(pre_seed), encoding="utf-8") + + save(key, {"layout": "sidebar-right"}, root=tmp_path) + + loaded = load(key, root=tmp_path) + assert loaded["layout"] == "sidebar-right" + assert loaded["image_overrides"] == pre_seed["image_overrides"] + assert loaded["zone_sizes"] == pre_seed["zone_sizes"] + assert loaded["schema_version"] == pre_seed["schema_version"] + + +def test_save_creates_parent_directory(tmp_path): + nested = tmp_path / "deep" / "nest" + assert not nested.exists() + save("03", {"layout": "two-column"}, root=nested) + assert (nested / "03.json").exists() + + +def test_save_writes_pretty_sorted_json_for_diffability(tmp_path): + key = "03" + save(key, _full_payload(), root=tmp_path) + raw = (tmp_path / "03.json").read_text(encoding="utf-8") + # sort_keys=True → KNOWN_AXES come out alphabetically + pos_frames = raw.index('"frames"') + pos_layout = raw.index('"layout"') + pos_zg = raw.index('"zone_geometries"') + pos_zs = raw.index('"zone_sections"') + assert pos_frames < pos_layout < pos_zg < pos_zs + + +def test_save_leaves_no_tmp_file_on_success(tmp_path): + save("03", _full_payload(), root=tmp_path) + leftovers = [p for p in tmp_path.iterdir() if p.name != "03.json"] + assert leftovers == [], f"tmp files leaked: {leftovers!r}" + + +def test_save_rejects_non_dict_partial(tmp_path): + with pytest.raises(TypeError): + save("03", ["not", "a", "dict"], root=tmp_path) # type: ignore[arg-type] + + +# -- save / load propagate key validation ----------------------------------- + + +@pytest.mark.parametrize("bad_key", ["", "..", "sub/dir", ".hidden"]) +def test_save_rejects_invalid_key(tmp_path, bad_key): + with pytest.raises(InvalidOverrideKey): + save(bad_key, {"layout": "two-column"}, root=tmp_path) + + +@pytest.mark.parametrize("bad_key", ["", "..", "sub/dir", ".hidden"]) +def test_load_rejects_invalid_key(tmp_path, bad_key): + with pytest.raises(InvalidOverrideKey): + load(bad_key, root=tmp_path) diff --git a/tests/test_user_overrides_pipeline_fallback.py b/tests/test_user_overrides_pipeline_fallback.py new file mode 100644 index 0000000..f25fec8 --- /dev/null +++ b/tests/test_user_overrides_pipeline_fallback.py @@ -0,0 +1,296 @@ +"""IMP-52 (#80) u9 — backend tests for the argparse persistence fallback. + +Stage 2 u9 scope (per the Exit Report): + +1. Per-axis fill — file value flows through when CLI omits the axis. +2. CLI-wins — CLI value beats file value on the same axis. +3. No-file noop — missing file → ``run_phase_z2_mvp1`` gets all-None. +4. Corrupt-file warn — invalid JSON / non-object → stderr warning + skip. +5. Invalid stem warn — ``Path(args.mdx_path).stem`` rejected by validator + → warning + fallback skipped wholesale. + +We exec the ``if __name__ == "__main__"`` block of +``src.phase_z2_pipeline`` directly inside the module's namespace, after +(a) monkeypatching ``src.user_overrides_io.DEFAULT_OVERRIDES_ROOT`` to a +tmp directory and (b) replacing ``run_phase_z2_mvp1`` with a recording +stub. This exercises the production fallback verbatim without the cost +of a real pipeline invocation. +""" +from __future__ import annotations + +import ast +import json +import sys +from pathlib import Path +from typing import Any + +import pytest + +import src.phase_z2_pipeline as _pz2 +import src.user_overrides_io as _io + + +# -- harness --------------------------------------------------------------- + + +def _exec_main_block( + captured: dict[str, Any], argv: list[str], monkeypatch +) -> None: + """Run the ``__main__`` body of phase_z2_pipeline.py with a fake + ``run_phase_z2_mvp1`` so its kwargs are observable.""" + + def _fake_run( + mdx_path, + run_id, + *, + override_layout=None, + override_frames=None, + override_zone_geometries=None, + override_section_assignments=None, + ): + captured["mdx_path"] = mdx_path + captured["run_id"] = run_id + captured["override_layout"] = override_layout + captured["override_frames"] = override_frames + captured["override_zone_geometries"] = override_zone_geometries + captured["override_section_assignments"] = override_section_assignments + + monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run) + monkeypatch.setattr(sys, "argv", argv) + + src_path = Path(_pz2.__file__) + source = src_path.read_text(encoding="utf-8") + tree = ast.parse(source) + for node in tree.body: + if ( + isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and isinstance(node.test.left, ast.Name) + and node.test.left.id == "__name__" + ): + block = ast.Module(body=node.body, type_ignores=[]) + exec(compile(block, str(src_path), "exec"), _pz2.__dict__) + return + raise AssertionError("no `if __name__ == '__main__'` block found") + + +def _redirect_overrides_root(tmp_path: Path, monkeypatch) -> None: + """Redirect the on-disk persistence root so tests never touch + ``data/user_overrides/``.""" + monkeypatch.setattr(_io, "DEFAULT_OVERRIDES_ROOT", tmp_path) + + +def _write_full_payload(tmp_path: Path, stem: str = "03") -> Path: + path = tmp_path / f"{stem}.json" + path.write_text( + json.dumps( + { + "layout": "sidebar-right", + "frames": {"03-1": "frame_file_a", "03-1+03-2": "frame_file_b"}, + "zone_geometries": { + "top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.3}, + "bottom": {"x": 0.0, "y": 0.3, "w": 1.0, "h": 0.7}, + }, + "zone_sections": { + "top": ["03-1"], + "bottom": ["03-2", "03-3"], + }, + } + ), + encoding="utf-8", + ) + return path + + +# -- 1. no-file noop ------------------------------------------------------- + + +def test_no_overrides_file_passes_none_overrides(tmp_path, monkeypatch): + _redirect_overrides_root(tmp_path, monkeypatch) + captured: dict[str, Any] = {} + _exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch) + + assert captured["override_layout"] is None + assert captured["override_frames"] is None + assert captured["override_zone_geometries"] is None + assert captured["override_section_assignments"] is None + # MDX path / run_id propagate untouched. + assert captured["mdx_path"] == Path("03.mdx") + assert captured["run_id"] is None + + +# -- 2. file fills every axis when CLI is empty ---------------------------- + + +def test_file_only_fills_all_four_axes_when_cli_empty(tmp_path, monkeypatch): + _redirect_overrides_root(tmp_path, monkeypatch) + _write_full_payload(tmp_path, "03") + + captured: dict[str, Any] = {} + _exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch) + + assert captured["override_layout"] == "sidebar-right" + assert captured["override_frames"] == { + "03-1": "frame_file_a", + "03-1+03-2": "frame_file_b", + } + assert captured["override_zone_geometries"] == { + "top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.3}, + "bottom": {"x": 0.0, "y": 0.3, "w": 1.0, "h": 0.7}, + } + assert captured["override_section_assignments"] == { + "top": ["03-1"], + "bottom": ["03-2", "03-3"], + } + + +# -- 3. CLI beats file on the same axis ----------------------------------- + + +def test_cli_layout_overrides_file_layout(tmp_path, monkeypatch): + _redirect_overrides_root(tmp_path, monkeypatch) + _write_full_payload(tmp_path, "03") + + captured: dict[str, Any] = {} + _exec_main_block( + captured, + ["src.phase_z2_pipeline", "03.mdx", "--override-layout", "two-column"], + monkeypatch, + ) + + # layout from CLI; remaining axes still filled from file. + assert captured["override_layout"] == "two-column" + assert captured["override_frames"] == { + "03-1": "frame_file_a", + "03-1+03-2": "frame_file_b", + } + assert captured["override_zone_geometries"] is not None + assert captured["override_section_assignments"] is not None + + +def test_cli_frames_overrides_file_frames(tmp_path, monkeypatch): + _redirect_overrides_root(tmp_path, monkeypatch) + _write_full_payload(tmp_path, "03") + + captured: dict[str, Any] = {} + _exec_main_block( + captured, + [ + "src.phase_z2_pipeline", + "03.mdx", + "--override-frame", + "03-1=cli_frame_x", + ], + monkeypatch, + ) + + # CLI ``frames`` payload wholly replaces file ``frames`` (per-axis win). + assert captured["override_frames"] == {"03-1": "cli_frame_x"} + # Other axes still come from the file. + assert captured["override_layout"] == "sidebar-right" + assert captured["override_zone_geometries"] is not None + assert captured["override_section_assignments"] is not None + + +# -- 4. corrupt / non-object file warns and skips fallback ---------------- + + +def test_corrupt_json_warns_and_skips_fallback(tmp_path, monkeypatch, capsys): + _redirect_overrides_root(tmp_path, monkeypatch) + (tmp_path / "03.json").write_text("{ not valid json", encoding="utf-8") + + captured: dict[str, Any] = {} + _exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch) + + err = capsys.readouterr().err + assert "failed to read" in err + # ``or None`` collapses empty dicts back to None on the call site. + assert captured["override_layout"] is None + assert captured["override_frames"] is None + assert captured["override_zone_geometries"] is None + assert captured["override_section_assignments"] is None + + +def test_non_object_top_level_warns_and_skips_fallback( + tmp_path, monkeypatch, capsys +): + _redirect_overrides_root(tmp_path, monkeypatch) + (tmp_path / "03.json").write_text("[1, 2, 3]", encoding="utf-8") + + captured: dict[str, Any] = {} + _exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch) + + err = capsys.readouterr().err + assert "not a JSON object" in err + assert captured["override_layout"] is None + assert captured["override_frames"] is None + assert captured["override_zone_geometries"] is None + assert captured["override_section_assignments"] is None + + +# -- 5. invalid MDX stem warns and skips fallback wholesale --------------- + + +def test_invalid_mdx_stem_warns_and_skips_fallback( + tmp_path, monkeypatch, capsys +): + _redirect_overrides_root(tmp_path, monkeypatch) + # Seed a file the loader would otherwise consume; the invalid stem must + # short-circuit before any read happens. + _write_full_payload(tmp_path, "03") + + captured: dict[str, Any] = {} + # ``Path(".hidden.mdx").stem`` == ".hidden" → leading dot → InvalidOverrideKey. + _exec_main_block( + captured, ["src.phase_z2_pipeline", ".hidden.mdx"], monkeypatch + ) + + err = capsys.readouterr().err + assert "cannot derive persistence key" in err + assert captured["override_layout"] is None + assert captured["override_frames"] is None + assert captured["override_zone_geometries"] is None + assert captured["override_section_assignments"] is None + + +# -- 6. per-axis partial fill (file fills only what CLI omits) ------------ + + +def test_per_axis_partial_fill_mixes_cli_and_file(tmp_path, monkeypatch): + """File carries frames + zone_geometries; CLI supplies layout only. + + Expected: ``override_layout`` = CLI value, ``override_frames`` and + ``override_zone_geometries`` = file values, ``override_section_assignments`` + = None (neither side provided it). + """ + _redirect_overrides_root(tmp_path, monkeypatch) + (tmp_path / "03.json").write_text( + json.dumps( + { + "frames": {"03-1": "frame_only_file"}, + "zone_geometries": { + "top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.5}, + }, + } + ), + encoding="utf-8", + ) + + captured: dict[str, Any] = {} + _exec_main_block( + captured, + [ + "src.phase_z2_pipeline", + "03.mdx", + "--override-layout", + "sidebar-right", + ], + monkeypatch, + ) + + assert captured["override_layout"] == "sidebar-right" + assert captured["override_frames"] == {"03-1": "frame_only_file"} + assert captured["override_zone_geometries"] == { + "top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.5}, + } + assert captured["override_section_assignments"] is None