feat(#80): IMP-52 user_overrides.json persistence (u1~u10 backend + frontend + tests)

4-axis MDX-stem keyed persistence so layout / zone_geometries / zone_sections / frames
survive across `/api/run` sessions. Auto-restore on MDX reopen; CLI > file precedence
on backend pipeline entry; 300ms-debounced PUT flushed before Generate.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 11:47:11 +09:00
parent ee97f4fc78
commit 9388e25e76
12 changed files with 3674 additions and 44 deletions

View File

@@ -2,7 +2,7 @@
* Home - 메인 페이지 (Zone-Centric 슬라이드 빌더) * Home - 메인 페이지 (Zone-Centric 슬라이드 빌더)
*/ */
import { useState, useCallback, useMemo, useEffect } from "react"; import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent"; import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent";
import { import {
@@ -15,6 +15,9 @@ import {
getSelectedRegion, getSelectedRegion,
moveSectionToZone, moveSectionToZone,
saveZoneSizes, saveZoneSizes,
deriveUserOverridesKey,
applyPersistedNonFrameOverrides,
remapPersistedFramesToZoneFrames,
} from "../utils/slidePlanUtils"; } from "../utils/slidePlanUtils";
import { import {
parseMdxFile, parseMdxFile,
@@ -25,6 +28,12 @@ import {
type RunMeta, type RunMeta,
type PipelineOverrides, type PipelineOverrides,
} from "../services/designAgentApi"; } from "../services/designAgentApi";
import {
flushUserOverrides,
getUserOverrides,
saveUserOverrides,
type UserOverrides,
} from "../services/userOverridesApi";
import LeftMdxPanel from "../components/LeftMdxPanel"; import LeftMdxPanel from "../components/LeftMdxPanel";
import SlideCanvas from "../components/SlideCanvas"; import SlideCanvas from "../components/SlideCanvas";
@@ -63,6 +72,14 @@ export default function Home() {
// section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시). // section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시).
const [pendingLayout, setPendingLayout] = useState<LayoutPresetId | null>(null); const [pendingLayout, setPendingLayout] = useState<LayoutPresetId | null>(null);
// IMP-52 u6 — restore-on-reopen: persisted user_overrides.json fetched at
// handleFileUpload time. layout / zone_geometries / zone_sections are
// seeded into userSelection immediately (so handleGenerate forwards them
// as CLI args). frames are stashed here because their on-disk key
// (unit_id = section_ids joined by "+") only maps to region.id after
// loadRun rebuilds the slidePlan — see handleGenerate post-loadRun.
const persistedOverridesRef = useRef<Partial<UserOverrides>>({});
// pendingLayout 활성 시 effective slidePlan = pendingZones 가 swap 된 plan. // pendingLayout 활성 시 effective slidePlan = pendingZones 가 swap 된 plan.
// 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect / // 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect /
// getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용. // getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용.
@@ -180,7 +197,19 @@ export default function Home() {
try { try {
const content = await parseMdxFile(file); 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}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`); toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -257,9 +286,11 @@ export default function Home() {
const overrides: PipelineOverrides = {}; const overrides: PipelineOverrides = {};
const sourcePlan = effectiveSlidePlan; const sourcePlan = effectiveSlidePlan;
if (sourcePlan && state.slidePlan) { 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; const overrideLayout = state.userSelection.overrides.layout_preset;
if (overrideLayout && overrideLayout !== defaultLayout) { if (overrideLayout) {
overrides.layout = overrideLayout; overrides.layout = overrideLayout;
} }
const frames: Record<string, string> = {}; const frames: Record<string, string> = {};
@@ -302,12 +333,9 @@ export default function Home() {
overrides.zoneGeometries = zoneGeometries; overrides.zoneGeometries = zoneGeometries;
} }
// IMP-08 B-3 : zoneSections forward only when the user diverged from // 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀.
// the auto plan. Codex Stage 3 R3 B3 fix : `createInitialUserSelection` // 시연 안정성 우선. section swap 은 별 path (수동 drag detection) 로 풀어야 함.
// seeds `zone_sections` with the default placement, so a literal copy // 임시 over-aggressive fix 가 default flow 깨뜨려 PARTIAL_COVERAGE 발생했음.
// 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.
const userZoneSections = state.userSelection.overrides.zone_sections; const userZoneSections = state.userSelection.overrides.zone_sections;
if (userZoneSections) { if (userZoneSections) {
const defaultByZone = new Map<string, string[]>(); const defaultByZone = new Map<string, string[]>();
@@ -349,6 +377,12 @@ export default function Home() {
toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`); toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`);
try { 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); const result = await runPipeline(state.uploadedFile, overrides);
if (!result.success || !result.final_html_exists) { if (!result.success || !result.final_html_exists) {
@@ -362,13 +396,42 @@ export default function Home() {
} }
const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id); const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id);
setState((p) => ({ // IMP-52 u6 — post-loadRun frame remap. persistedOverridesRef holds
...p, // the user_overrides.json read at handleFileUpload time. Frames there
normalizedContent, // 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, slidePlan,
userSelection: createInitialUserSelection(slidePlan), persistedOverridesRef.current.frames as Record<string, string> | undefined,
isLoading: false, );
})); 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); setRunMeta(runMeta);
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`); toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status); const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
@@ -386,10 +449,21 @@ export default function Home() {
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => { const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
setState((p) => { setState((p) => {
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId); const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
return { const finalSelection = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
...p, // IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk
userSelection: selectZone(newSelection, zoneId) // 이동된 존 자동 선택 // 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"); setRightTab("frame");
setHasPendingChanges(true); setHasPendingChanges(true);
@@ -415,10 +489,18 @@ export default function Home() {
// ── Layout 선택 ── // ── Layout 선택 ──
const handleLayoutSelect = useCallback((layoutId: string) => { const handleLayoutSelect = useCallback((layoutId: string) => {
setState((p) => ({ setState((p) => {
...p, const newSelection = applyLayout(p.userSelection, layoutId as LayoutPresetId);
userSelection: 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); setHasPendingChanges(true);
}, []); }, []);
@@ -431,19 +513,30 @@ export default function Home() {
}, []); }, []);
const handleZoneResize = useCallback((geometries: Record<string, { x: number; y: number; w: number; h: number }>) => { const handleZoneResize = useCallback((geometries: Record<string, { x: number; y: number; w: number; h: number }>) => {
setState((p) => ({ setState((p) => {
...p, const mergedGeometries = {
userSelection: { ...p.userSelection.overrides.zone_geometries,
...p.userSelection, ...geometries,
overrides: { };
...p.userSelection.overrides, // IMP-52 u7 — persist the merged zone_geometries snapshot. Resize
zone_geometries: { // gestures fire repeatedly during a drag; the 300ms u5 debounce
...p.userSelection.overrides.zone_geometries, // collapses them into a single PUT at gesture-end, so we don't
...geometries // 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); setHasPendingChanges(true);
}, []); }, []);
@@ -486,10 +579,40 @@ export default function Home() {
return; return;
} }
setState((p) => ({ setState((p) => {
...p, const newSelection = applyFrame(p.userSelection, region.id, frameId);
userSelection: applyFrame(p.userSelection, region.id, frameId) // IMP-52 u7 — persist frames keyed by `unit_id`. The on-disk schema
})); // uses `unit_id = zone.section_ids.join("+")` (the same convention
// handleGenerate uses when forwarding `overrides.frames` to the
// backend CLI). `zone_frames` is keyed by region.id, so we walk
// the effectiveSlidePlan zones to translate. Only true user
// overrides are persisted — `createInitialUserSelection` pre-fills
// `zone_frames[region.id]` with `region.frame_match_strategy.frame_id`
// (backend default) for every region, so we mirror handleGenerate's
// `overrideFrameId !== defaultFrameId` gate to avoid leaking defaults
// into user_overrides.json. Zones with no sections are skipped.
if (p.uploadedFile && effectiveSlidePlan) {
const framesByUnitId: Record<string, string> = {};
for (const z of effectiveSlidePlan.zones) {
const r = z.internal_regions[0];
if (!r) continue;
if (!Array.isArray(z.section_ids) || z.section_ids.length === 0) continue;
const unitId = z.section_ids.join("+");
const overrideId = newSelection.overrides.zone_frames?.[r.id];
const defaultFrameId = r.frame_match_strategy.frame_id;
if (
typeof overrideId === "string" &&
overrideId.length > 0 &&
overrideId !== defaultFrameId
) {
framesByUnitId[unitId] = overrideId;
}
}
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { frames: framesByUnitId });
}
return { ...p, userSelection: newSelection };
});
setHasPendingChanges(true); setHasPendingChanges(true);
}, [effectiveSlidePlan, state.userSelection]); }, [effectiveSlidePlan, state.userSelection]);

View File

@@ -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<UserOverrides>` from
// the GET endpoint. Missing / corrupt / non-object payloads degrade to
// `{}` so the frontend reopen flow never crashes on a fresh MDX.
// • Typed `saveUserOverrides(key, partial)` → schedules a 300ms-debounced
// PUT carrying ONLY the axes the user has mutated since the last flush.
// Per-axis coalescing: a later call overwrites the same axis in the
// pending payload; axes the user did not mutate are NOT sent (the
// server-side merge in u4 preserves them on disk).
// • Per-key debounce buckets — rapid edits to MDX "03" do not delay the
// flush for MDX "04".
// • Explicit clear sentinel: `partial[axis] = null` forwards to the PUT
// body verbatim so u4 `mergeUserOverrides` can `delete` the axis on disk.
// • `flushUserOverrides()` / `flushUserOverrides(key)` force an immediate
// PUT (used by tests + Home.tsx Generate flow to ensure outstanding
// writes commit before pipeline run).
const ENDPOINT_BASE = "/api/user-overrides";
const DEBOUNCE_MS = 300;
// ── Schema (mirror of backend KNOWN_AXES; see header comment) ───────────────
/** unit_id → template_id. unit_id = source_section_ids joined by "+". */
export type FramesOverride = Record<string, string>;
/** zone_id → 0-1 normalized geometry inside slide-body. */
export type ZoneGeometryOverride = {
x: number;
y: number;
w: number;
h: number;
};
export type ZoneGeometriesOverride = Record<string, ZoneGeometryOverride>;
/** zone_id → ordered list of section_ids assigned to that zone. */
export type ZoneSectionsOverride = Record<string, string[]>;
/** 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<typeof setTimeout> | null;
waiters: Array<{
resolve: (merged: Partial<UserOverrides>) => void;
reject: (err: unknown) => void;
}>;
};
const buckets = new Map<string, PendingBucket>();
function getBucket(key: string): PendingBucket {
let b = buckets.get(key);
if (!b) {
b = { partial: {}, timer: null, waiters: [] };
buckets.set(key, b);
}
return b;
}
// ── GET ─────────────────────────────────────────────────────────────────────
/**
* Fetch the persisted user_overrides for `key` (MDX stem). Returns `{}` on
* any failure mode (network error, 4xx/5xx, non-object body) so the caller
* can use it unconditionally during MDX reopen without branching on
* error paths.
*/
export async function getUserOverrides(
key: string,
): Promise<Partial<UserOverrides>> {
let res: Response;
try {
res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
method: "GET",
headers: { Accept: "application/json" },
});
} catch {
return {};
}
if (!res.ok) return {};
let parsed: unknown;
try {
parsed = await res.json();
} catch {
return {};
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return {};
}
return parsed as Partial<UserOverrides>;
}
// ── PUT (debounced) ─────────────────────────────────────────────────────────
async function flushBucket(
key: string,
bucket: PendingBucket,
): Promise<void> {
const payload = bucket.partial;
const waiters = bucket.waiters;
bucket.partial = {};
bucket.timer = null;
bucket.waiters = [];
let merged: Partial<UserOverrides> = {};
try {
const res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) {
try {
const parsed = (await res.json()) as unknown;
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
merged = parsed as Partial<UserOverrides>;
}
} catch {
// server returned 200 with non-JSON body → treat as empty merged
}
} else {
const err = new Error(`PUT ${ENDPOINT_BASE}/${key}${res.status}`);
waiters.forEach((w) => w.reject(err));
return;
}
} catch (err) {
waiters.forEach((w) => w.reject(err));
return;
}
waiters.forEach((w) => w.resolve(merged));
}
/**
* Schedule a debounced PUT to persist the mutated axes. Resolves with the
* server-side merged document when the debounced PUT eventually fires.
* Multiple rapid calls for the same `key` coalesce into a single PUT;
* a later call's value for a given axis overrides an earlier pending value.
* Calls for different `key`s are isolated.
*/
export function saveUserOverrides(
key: string,
partial: UserOverridesPartial,
): Promise<Partial<UserOverrides>> {
const bucket = getBucket(key);
// Per-axis coalescing — later mutations replace earlier pending values.
for (const axis of Object.keys(partial) as Array<keyof UserOverridesPartial>) {
bucket.partial[axis] = partial[axis] as never;
}
const p = new Promise<Partial<UserOverrides>>((resolve, reject) => {
bucket.waiters.push({ resolve, reject });
});
if (bucket.timer !== null) clearTimeout(bucket.timer);
bucket.timer = setTimeout(() => {
void flushBucket(key, bucket);
}, DEBOUNCE_MS);
return p;
}
/**
* Force-flush pending debounced writes. With no arg, flushes ALL pending
* keys (used before pipeline runs so the backend reads the latest file).
* With a key, flushes only that key's bucket.
*
* Resolves after every flushed bucket's PUT completes. Per-bucket errors
* are swallowed at the flush level — the original caller's
* saveUserOverrides() promise still rejects to its owner via the waiter.
*/
export async function flushUserOverrides(key?: string): Promise<void> {
const targets: Array<[string, PendingBucket]> = [];
if (key !== undefined) {
const b = buckets.get(key);
if (b && b.timer !== null) targets.push([key, b]);
} else {
buckets.forEach((b, k) => {
if (b.timer !== null) targets.push([k, b]);
});
}
const flushPromises = targets.map(([k, b]) => {
if (b.timer !== null) {
clearTimeout(b.timer);
b.timer = null;
}
return flushBucket(k, b);
});
await Promise.all(flushPromises);
}
/** Test-only — clears all pending buckets without firing PUTs. */
export function __resetUserOverridesBuckets_FOR_TEST(): void {
buckets.forEach((b) => {
if (b.timer !== null) clearTimeout(b.timer);
});
buckets.clear();
}

View File

@@ -1,4 +1,108 @@
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent"; import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
import type { UserOverrides } from "../services/userOverridesApi";
// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ────
// These helpers compose persisted `user_overrides.json` payloads (typed by
// the u5 service) onto the in-memory `UserSelection`. They live here rather
// than inline in Home.tsx so vitest can drive them in a node environment
// without booting React or pulling in the radix-ui / lucide UI deps that
// Home.tsx requires. Home.tsx wires these into:
// • handleFileUpload (pre-Generate layout / zone_geometries / zone_sections
// seed so handleGenerate's CLI-args build picks them up)
// • handleGenerate post-loadRun (frame remap unit_id → region.id over the
// freshly built slidePlan)
// The on-disk schema and clear-sentinel semantics are owned by:
// • src/user_overrides_io.py (KNOWN_AXES, u1)
// • Front/vite.config.ts mergeUserOverrides (u4)
// • Front/client/src/services/userOverridesApi.ts (UserOverrides type, u5)
// Any KNOWN_AXES drift must land in those files first.
/**
* Derive the `/api/user-overrides/:key` MDX-stem key from a filename.
* Strips a trailing `.mdx` (case-insensitive). The key matches the Python
* `Path(args.mdx_path).stem` derivation used by the backend fallback (u2),
* so the same persisted file is read from both ends without translation.
*/
export function deriveUserOverridesKey(filename: string): string {
return filename.replace(/\.mdx$/i, "");
}
const LAYOUT_PRESET_IDS = new Set<string>([
"single",
"horizontal-2",
"vertical-2",
"top-1-bottom-2",
"top-2-bottom-1",
"left-1-right-2",
"left-2-right-1",
"grid-2x2",
]);
/**
* Layer the three non-frame axes from a persisted `user_overrides.json`
* payload onto an existing `UserSelection`. Foreign / unrecognized payload
* shapes are silently ignored — the u5 GET path already returns `{}` on
* corrupt files, but we revalidate here so hand-edited files or future
* forward-compat axes cannot poison the in-memory state.
*
* Frames are NOT layered here because the on-disk key (`unit_id` =
* section_ids joined by `+`) only resolves after the slidePlan zones are
* known. Use `remapPersistedFramesToZoneFrames` in the post-loadRun step.
*/
export function applyPersistedNonFrameOverrides(
selection: UserSelection,
persisted: Partial<UserOverrides> | null | undefined,
): UserSelection {
if (!persisted || typeof persisted !== "object") return selection;
const next = { ...selection.overrides };
if (typeof persisted.layout === "string" && LAYOUT_PRESET_IDS.has(persisted.layout)) {
next.layout_preset = persisted.layout as LayoutPresetId;
}
if (
persisted.zone_geometries &&
typeof persisted.zone_geometries === "object" &&
!Array.isArray(persisted.zone_geometries)
) {
next.zone_geometries = { ...persisted.zone_geometries };
}
if (
persisted.zone_sections &&
typeof persisted.zone_sections === "object" &&
!Array.isArray(persisted.zone_sections)
) {
next.zone_sections = { ...persisted.zone_sections };
}
return { ...selection, overrides: next };
}
/**
* Remap persisted frames (`unit_id` → template_id) to the in-memory
* `zone_frames` (region.id → template_id) using the freshly built
* slidePlan zones. `unit_id` follows handleGenerate's convention:
* `zone.section_ids.join("+")`. Persisted entries whose unit_id no longer
* matches any zone (e.g. user changed zone_sections between sessions) are
* silently dropped.
*/
export function remapPersistedFramesToZoneFrames(
slidePlan: SlidePlan | null | undefined,
framesByUnitId: Record<string, string> | null | undefined,
): Record<string, string> {
if (!slidePlan || !framesByUnitId || typeof framesByUnitId !== "object") {
return {};
}
const out: Record<string, string> = {};
for (const zone of slidePlan.zones) {
const region = zone.internal_regions[0];
if (!region) continue;
if (!Array.isArray(zone.section_ids) || zone.section_ids.length === 0) continue;
const unitId = zone.section_ids.join("+");
const templateId = framesByUnitId[unitId];
if (typeof templateId === "string" && templateId.length > 0) {
out[region.id] = templateId;
}
}
return out;
}
/** /**
* Phase Z 초기 선택 상태 생성 * Phase Z 초기 선택 상태 생성

View File

@@ -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 <root>/data/user_overrides/<key>.json.
// 3) handleGetUserOverrides: method != GET → false (next chained for
// PUT); invalid key → 400; missing file → 200 {}; corrupt JSON /
// non-object root → 200 {} (graceful degrade per u1 load contract);
// valid object JSON → 200 with parsed payload echoed back.
//
// u4 (write path):
// 4) mergeUserOverrides: only KNOWN_USER_OVERRIDES_AXES mutated;
// foreign top-level keys preserved; null clears axis; non-axis
// partial keys dropped (allowlist).
// 5) atomicWriteUserOverrides: tmp + rename; parent dir auto-created.
// 6) handlePutUserOverrides: method != PUT → false (next chained);
// invalid key → 400; invalid JSON → 400; non-object body → 400;
// success → 200 with merged result; partial-merge preserves axes
// not in payload; foreign-key preserve on disk; allowlist drops
// unknown payload keys; explicit null clears; corrupt existing →
// recover to clean state.
//
// Tests exercise the pure handlers with mock req/res — no real vite server.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
KNOWN_USER_OVERRIDES_AXES,
USER_OVERRIDES_KEY_RE,
atomicWriteUserOverrides,
handleGetUserOverrides,
handlePutUserOverrides,
isValidUserOverridesKey,
mergeUserOverrides,
userOverridesPath,
} from "../../vite.config";
// ---------------------------------------------------------------------------
// mock res helper — captures writeHead(status, headers) + end(body) so the
// handler can be invoked synchronously without spawning a TCP socket.
// ---------------------------------------------------------------------------
function makeMockRes() {
const state = {
statusCode: 0,
headers: {} as Record<string, string>,
body: "",
ended: false,
};
return {
state,
res: {
writeHead(status: number, headers?: Record<string, string>) {
state.statusCode = status;
if (headers) state.headers = headers;
},
end(body?: string) {
state.body = body ?? "";
state.ended = true;
},
},
};
}
describe("USER_OVERRIDES_KEY_RE (IMP-52 u3)", () => {
it("matches Python validate_key regex literally", () => {
// The pattern locked in src/user_overrides_io.py:_KEY_RE — any drift here
// means backend pipeline fallback (u2) and the vite endpoint disagree on
// which keys are routable, which is the single failure mode that would
// silently lose persisted overrides.
expect(USER_OVERRIDES_KEY_RE.source).toBe(
"^[A-Za-z0-9_][A-Za-z0-9_.\\-]*$",
);
});
});
describe("isValidUserOverridesKey (IMP-52 u3)", () => {
it("accepts MDX-stem-style keys actually used in samples/mdx/", () => {
// 03 / 04 / 05 are the wired sample MDXs (vite.config.ts:SAMPLE_MDX_MAP).
expect(isValidUserOverridesKey("03")).toBe(true);
expect(isValidUserOverridesKey("04")).toBe(true);
expect(isValidUserOverridesKey("05")).toBe(true);
// Stage 1 EVIDENCE references 03__DX_BIM... — must round-trip.
expect(isValidUserOverridesKey("03__DX_BIM")).toBe(true);
expect(isValidUserOverridesKey("a-b.c")).toBe(true);
expect(isValidUserOverridesKey("a")).toBe(true);
expect(isValidUserOverridesKey("_leading_underscore")).toBe(true);
expect(isValidUserOverridesKey("9starts_with_digit")).toBe(true);
});
it("rejects empty and whitespace-only keys", () => {
expect(isValidUserOverridesKey("")).toBe(false);
});
it("rejects path-traversal substrings", () => {
// `..` rejected explicitly even if the rest of the regex would allow it
// — `a..b` would otherwise pass the char class.
expect(isValidUserOverridesKey("..")).toBe(false);
expect(isValidUserOverridesKey("a..b")).toBe(false);
expect(isValidUserOverridesKey("../escape")).toBe(false);
});
it("rejects path separators", () => {
expect(isValidUserOverridesKey("a/b")).toBe(false);
expect(isValidUserOverridesKey("a\\b")).toBe(false);
expect(isValidUserOverridesKey("/")).toBe(false);
expect(isValidUserOverridesKey("\\")).toBe(false);
});
it("rejects keys starting with a non-word character", () => {
expect(isValidUserOverridesKey(".hidden")).toBe(false);
expect(isValidUserOverridesKey("-leading-dash")).toBe(false);
});
it("rejects characters outside [A-Za-z0-9_.-]", () => {
expect(isValidUserOverridesKey("a b")).toBe(false);
expect(isValidUserOverridesKey("a:b")).toBe(false);
expect(isValidUserOverridesKey("a*b")).toBe(false);
expect(isValidUserOverridesKey("a%2Fb")).toBe(false);
});
});
describe("userOverridesPath (IMP-52 u3)", () => {
it("resolves <root>/data/user_overrides/<key>.json regardless of OS sep", () => {
const root = path.join("X:", "design_agent");
const got = userOverridesPath(root, "03");
expect(got).toBe(path.join(root, "data", "user_overrides", "03.json"));
});
});
describe("handleGetUserOverrides (IMP-52 u3)", () => {
let tmpRoot: string;
let overridesDir: string;
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u3-"));
overridesDir = path.join(tmpRoot, "data", "user_overrides");
fs.mkdirSync(overridesDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
it("returns false (next chained) when method != GET", () => {
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "PUT", url: "/03" },
res,
tmpRoot,
);
expect(handled).toBe(false);
// Crucial for u4: PUT must reach its own middleware unobstructed.
expect(state.ended).toBe(false);
expect(state.statusCode).toBe(0);
});
it("returns 400 on invalid key (path traversal)", () => {
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/../escape" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(400);
expect(JSON.parse(state.body)).toEqual({ error: "invalid key" });
});
it("returns 400 on invalid key (missing key segment)", () => {
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(400);
});
it("returns 200 {} on missing file (graceful degrade)", () => {
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/03" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(200);
expect(state.body).toBe("{}");
});
it("returns 200 {} on corrupt JSON (graceful degrade)", () => {
fs.writeFileSync(path.join(overridesDir, "03.json"), "{not json", "utf-8");
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/03" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(200);
expect(state.body).toBe("{}");
});
it("returns 200 {} when JSON root is not an object", () => {
// Mirrors u1 load() which treats non-object roots as corrupt — covers
// both arrays and primitives so the frontend never receives a shape
// the typed service (u5) can't deserialize.
fs.writeFileSync(
path.join(overridesDir, "arr.json"),
JSON.stringify([1, 2, 3]),
"utf-8",
);
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/arr" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(200);
expect(state.body).toBe("{}");
fs.writeFileSync(path.join(overridesDir, "num.json"), "42", "utf-8");
const { res: res2, state: state2 } = makeMockRes();
handleGetUserOverrides({ method: "GET", url: "/num" }, res2, tmpRoot);
expect(state2.statusCode).toBe(200);
expect(state2.body).toBe("{}");
});
it("returns 200 with parsed JSON object on hit", () => {
const payload = {
layout: "two_zone_split",
frames: { "03-1+03-2": "frame_07" },
zone_geometries: {
top: { x: 0.05, y: 0.1, w: 0.9, h: 0.3 },
},
zone_sections: { top: ["03-1", "03-2"] },
};
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify(payload),
"utf-8",
);
const { res, state } = makeMockRes();
const handled = handleGetUserOverrides(
{ method: "GET", url: "/03" },
res,
tmpRoot,
);
expect(handled).toBe(true);
expect(state.statusCode).toBe(200);
expect(state.headers["Content-Type"]).toBe(
"application/json; charset=utf-8",
);
expect(JSON.parse(state.body)).toEqual(payload);
});
it("preserves foreign top-level keys in the response", () => {
// Forward-compat with future axes (e.g., zone_sizes, image_overrides).
// u1 save() preserves them on the disk side; u3 GET must surface them
// so the frontend service (u5) can decide whether to act on them.
const payload = {
layout: "single_zone",
zone_sizes: { top: 0.42 }, // not part of KNOWN_AXES yet
custom_extension: { foo: "bar" },
};
fs.writeFileSync(
path.join(overridesDir, "future.json"),
JSON.stringify(payload),
"utf-8",
);
const { res, state } = makeMockRes();
handleGetUserOverrides({ method: "GET", url: "/future" }, res, tmpRoot);
expect(state.statusCode).toBe(200);
expect(JSON.parse(state.body)).toEqual(payload);
});
it("strips the leading slash and ignores query string when keying", () => {
fs.writeFileSync(
path.join(overridesDir, "03.json"),
JSON.stringify({ layout: "x" }),
"utf-8",
);
const { res, state } = makeMockRes();
handleGetUserOverrides(
{ method: "GET", url: "/03?ts=1747884800" },
res,
tmpRoot,
);
expect(state.statusCode).toBe(200);
expect(JSON.parse(state.body)).toEqual({ layout: "x" });
});
});
// ---------------------------------------------------------------------------
// IMP-52 u4 — PUT endpoint coverage
// ---------------------------------------------------------------------------
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => {
it("matches the Python KNOWN_AXES tuple in src/user_overrides_io.py", () => {
// The on-disk schema is shared with backend pipeline fallback (u2).
// Any drift here means a PUT could write an axis that the Python
// load() ignores, or vice-versa, silently losing user overrides.
expect(KNOWN_USER_OVERRIDES_AXES).toEqual([
"layout",
"zone_geometries",
"zone_sections",
"frames",
]);
});
});
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" },
});
});
});

View File

@@ -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["overrides"]>): 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<string, { x: number; y: number; w: number; h: number }>,
});
expect(next.overrides.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.5 },
});
});
it("returns the selection unchanged when persisted is null / undefined / non-object", () => {
const sel = makeSelection({ layout_preset: "single" });
expect(applyPersistedNonFrameOverrides(sel, null)).toEqual(sel);
expect(applyPersistedNonFrameOverrides(sel, undefined)).toEqual(sel);
});
it("returns the selection unchanged when persisted is empty {}", () => {
const sel = makeSelection({ layout_preset: "single" });
const next = applyPersistedNonFrameOverrides(sel, {});
expect(next.overrides.layout_preset).toBe("single");
expect(next.overrides.zone_geometries).toEqual({});
expect(next.overrides.zone_sections).toEqual({});
});
it("returns a NEW selection object (no mutation of input)", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, { layout: "vertical-2" });
expect(next).not.toBe(sel);
expect(next.overrides).not.toBe(sel.overrides);
// Input still pristine.
expect(sel.overrides.layout_preset).toBeUndefined();
});
});
// ─── remapPersistedFramesToZoneFrames ───────────────────────────────────────
describe("remapPersistedFramesToZoneFrames (IMP-52 u6)", () => {
it("maps unit_id (section_ids joined by +) to region.id", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
makeZone({ id: "z-bot", zone_id: "bottom", section_ids: ["03-2", "03-3"], region_id: "r-bot" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"03-1": "tpl-a",
"03-2+03-3": "tpl-b",
});
expect(remapped).toEqual({
"r-top": "tpl-a",
"r-bot": "tpl-b",
});
});
it("silently drops persisted entries whose unit_id matches no zone", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"03-1": "tpl-a",
"stale-section-id": "tpl-stale", // user changed zone_sections between sessions
});
expect(remapped).toEqual({ "r-top": "tpl-a" });
});
it("returns {} when slidePlan is null / undefined", () => {
expect(remapPersistedFramesToZoneFrames(null, { "03-1": "tpl-a" })).toEqual({});
expect(remapPersistedFramesToZoneFrames(undefined, { "03-1": "tpl-a" })).toEqual({});
});
it("returns {} when framesByUnitId is null / undefined / {}", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
expect(remapPersistedFramesToZoneFrames(plan, null)).toEqual({});
expect(remapPersistedFramesToZoneFrames(plan, undefined)).toEqual({});
expect(remapPersistedFramesToZoneFrames(plan, {})).toEqual({});
});
it("skips zones with empty section_ids (no unit_id to derive)", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-empty", zone_id: "empty", section_ids: [], region_id: "r-empty" }),
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"": "tpl-should-not-match-empty-join",
"03-1": "tpl-a",
});
expect(remapped).toEqual({ "r-top": "tpl-a" });
});
it("skips zones without internal_regions[0]", () => {
const plan: SlidePlan = {
id: "plan-x",
title: "no regions",
layout_preset: "single",
zones: [
{
id: "z-bare",
zone_id: "bare",
section_ids: ["03-1"],
position: { x: 0, y: 0, width: 1, height: 1 },
internal_regions: [],
},
],
};
expect(remapPersistedFramesToZoneFrames(plan, { "03-1": "tpl-a" })).toEqual({});
});
it("ignores persisted entries with empty / non-string template_id", () => {
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"03-1": "" as unknown as string,
});
expect(remapped).toEqual({});
});
it("preserves the user-selected template even when slidePlan layout would imply a different default", () => {
// Backend u2 fallback should already have applied the user's frame
// override via CLI args, but if the plan's default frame_match_strategy
// disagrees, the post-loadRun remap still surfaces the user's choice
// for the SlideCanvas override-vs-default preview indicator.
const plan = makeSlidePlan([
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
]);
const remapped = remapPersistedFramesToZoneFrames(plan, {
"03-1": "user-chosen-tpl",
});
expect(remapped["r-top"]).toBe("user-chosen-tpl");
});
});

View File

@@ -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<unknown>;
};
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
return {
ok,
status,
json: async () => body,
};
}
let fetchMock: Mock;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
vi.useFakeTimers();
__resetUserOverridesBuckets_FOR_TEST();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
__resetUserOverridesBuckets_FOR_TEST();
});
// Microtask-flushing helper. vi.advanceTimersByTime fires timers, but the
// promise chain inside flushBucket (await fetch → await res.json() → resolve
// waiters) needs the microtask queue to drain before assertions run.
async function drainMicrotasks(): Promise<void> {
// Multiple ticks because each `await` in flushBucket adds another tick.
for (let i = 0; i < 4; i++) {
await Promise.resolve();
}
}
function lastPutBody(): unknown {
const lastCall = fetchMock.mock.calls.at(-1);
if (!lastCall) throw new Error("fetch was not called");
const init = lastCall[1] as RequestInit | undefined;
if (!init?.body) throw new Error("fetch was called without a body");
return JSON.parse(String(init.body));
}
function putCallsCount(): number {
return fetchMock.mock.calls.filter(
(call) => (call[1] as RequestInit | undefined)?.method === "PUT",
).length;
}
// ============================================================================
// getUserOverrides
// ============================================================================
describe("getUserOverrides (IMP-52 u5)", () => {
it("issues GET against /api/user-overrides/<key>", async () => {
fetchMock.mockResolvedValueOnce(mockResponse({ layout: "x" }));
await getUserOverrides("03");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/user-overrides/03");
expect((init as RequestInit).method).toBe("GET");
});
it("returns the parsed object on 200 with object body", async () => {
const payload = {
layout: "two_zone_split",
frames: { "03-1+03-2": "frame_07" },
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
zone_sections: { top: ["03-1", "03-2"] },
};
fetchMock.mockResolvedValueOnce(mockResponse(payload));
const got = await getUserOverrides("03");
expect(got).toEqual(payload);
});
it("returns {} when JSON root is an array (mirrors u3 graceful degrade)", async () => {
fetchMock.mockResolvedValueOnce(mockResponse([1, 2, 3]));
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} when JSON root is a primitive", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(42));
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} when JSON root is null", async () => {
fetchMock.mockResolvedValueOnce(mockResponse(null));
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} on 4xx (invalid key path from u3)", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({ error: "invalid key" }, false, 400),
);
expect(await getUserOverrides("..")).toEqual({});
});
it("returns {} on 5xx", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({ error: "boom" }, false, 500),
);
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} when response.json() throws (non-JSON body)", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError("Unexpected token");
},
});
expect(await getUserOverrides("03")).toEqual({});
});
it("returns {} when fetch rejects (network error) — does NOT throw", async () => {
fetchMock.mockRejectedValueOnce(new Error("network down"));
await expect(getUserOverrides("03")).resolves.toEqual({});
});
});
// ============================================================================
// saveUserOverrides — debounce + coalescing
// ============================================================================
describe("saveUserOverrides (IMP-52 u5) — debounce", () => {
it("does NOT fire fetch before 300 ms have elapsed", async () => {
fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" }));
void saveUserOverrides("03", { layout: "two_zone_split" });
vi.advanceTimersByTime(299);
await drainMicrotasks();
expect(putCallsCount()).toBe(0);
});
it("fires exactly one PUT at the 300 ms boundary", async () => {
fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" }));
void saveUserOverrides("03", { layout: "two_zone_split" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
const lastCall = fetchMock.mock.calls.at(-1)!;
expect(lastCall[0]).toBe("/api/user-overrides/03");
expect((lastCall[1] as RequestInit).method).toBe("PUT");
expect((lastCall[1] as RequestInit).headers).toMatchObject({
"Content-Type": "application/json",
});
expect(lastPutBody()).toEqual({ layout: "two_zone_split" });
});
it("PUT body contains ONLY the mutated axis (not a full snapshot)", async () => {
// The frontend handler only knows the axis it just mutated; the server
// is responsible for partial-merge against axes already on disk.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", {
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["zone_geometries"]);
expect("layout" in body).toBe(false);
expect("frames" in body).toBe(false);
expect("zone_sections" in body).toBe(false);
});
it("coalesces N rapid calls into a SINGLE PUT after the debounce", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "old" });
vi.advanceTimersByTime(100);
void saveUserOverrides("03", { frames: { "03-1": "frame_01" } });
vi.advanceTimersByTime(100);
void saveUserOverrides("03", { zone_sections: { top: ["03-1"] } });
vi.advanceTimersByTime(100);
// After 300 ms total (but the timer was reset each call to start the
// 300 ms window over), so we need one more 300 ms to fire.
expect(putCallsCount()).toBe(0);
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
// All three axes accumulated.
const body = lastPutBody() as Record<string, unknown>;
expect(body).toEqual({
layout: "old",
frames: { "03-1": "frame_01" },
zone_sections: { top: ["03-1"] },
});
});
it("per-axis later-wins: same axis mutated twice keeps the LAST value", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "first" });
void saveUserOverrides("03", { layout: "second" });
void saveUserOverrides("03", { layout: "final" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({ layout: "final" });
});
it("forwards null sentinel verbatim (explicit clear)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: null });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(lastPutBody()).toEqual({ layout: null });
});
it("null can override a prior non-null pending value for the same axis", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "two_zone_split" });
void saveUserOverrides("03", { layout: null });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(lastPutBody()).toEqual({ layout: null });
});
it("resolves the caller promise with the server-merged document", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({
layout: "two_zone_split",
// server's view includes axes preserved on disk that the partial
// PUT did NOT carry — confirms we surface the full merged state.
frames: { "03-1": "frame_01" },
}),
);
const p = saveUserOverrides("03", { layout: "two_zone_split" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
await expect(p).resolves.toEqual({
layout: "two_zone_split",
frames: { "03-1": "frame_01" },
});
});
it("rejects all coalesced waiters on 5xx response", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({ error: "write failed" }, false, 500),
);
const p1 = saveUserOverrides("03", { layout: "x" });
const p2 = saveUserOverrides("03", { frames: { "03-1": "f01" } });
vi.advanceTimersByTime(300);
await drainMicrotasks();
await expect(p1).rejects.toThrow(/500/);
await expect(p2).rejects.toThrow(/500/);
});
it("rejects waiters on fetch network error", async () => {
fetchMock.mockRejectedValueOnce(new Error("ECONNRESET"));
const p = saveUserOverrides("03", { layout: "x" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
await expect(p).rejects.toThrow("ECONNRESET");
});
it("after a successful flush, a new save starts a fresh debounce window", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "first" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({ layout: "first" });
void saveUserOverrides("03", { layout: "second" });
vi.advanceTimersByTime(299);
await drainMicrotasks();
expect(putCallsCount()).toBe(1); // not fired yet
vi.advanceTimersByTime(1);
await drainMicrotasks();
expect(putCallsCount()).toBe(2);
expect(lastPutBody()).toEqual({ layout: "second" });
});
});
// ============================================================================
// saveUserOverrides — per-key isolation
// ============================================================================
describe("saveUserOverrides (IMP-52 u5) — per-key isolation", () => {
it("rapid edits to key A do not delay key B's flush", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
// Schedule a save on "03"
void saveUserOverrides("03", { layout: "x" });
// Schedule a save on "04" at t=0
void saveUserOverrides("04", { layout: "y" });
vi.advanceTimersByTime(150);
// Keep extending "03"'s window
void saveUserOverrides("03", { layout: "x2" });
// "04" should still fire at t=300 (untouched after first call)
vi.advanceTimersByTime(150); // t=300
await drainMicrotasks();
const puts = fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
);
expect(puts.length).toBe(1);
expect(puts[0][0]).toBe("/api/user-overrides/04");
expect(JSON.parse(String((puts[0][1] as RequestInit).body))).toEqual({
layout: "y",
});
});
it("each key's PUT carries only that key's mutated axes", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "for-03" });
void saveUserOverrides("04", { frames: { "04-1": "frame_05" } });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const puts = fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
);
expect(puts.length).toBe(2);
const byUrl = new Map(
puts.map((c) => [
c[0],
JSON.parse(String((c[1] as RequestInit).body)) as Record<
string,
unknown
>,
]),
);
expect(byUrl.get("/api/user-overrides/03")).toEqual({ layout: "for-03" });
expect(byUrl.get("/api/user-overrides/04")).toEqual({
frames: { "04-1": "frame_05" },
});
});
});
// ============================================================================
// flushUserOverrides
// ============================================================================
describe("flushUserOverrides (IMP-52 u5)", () => {
it("with no arg, flushes ALL pending buckets immediately (no 300 ms wait)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "x" });
void saveUserOverrides("04", { layout: "y" });
expect(putCallsCount()).toBe(0);
const flushP = flushUserOverrides();
await drainMicrotasks();
await flushP;
expect(putCallsCount()).toBe(2);
});
it("with a key arg, flushes only that bucket; others stay pending", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { layout: "x" });
void saveUserOverrides("04", { layout: "y" });
await flushUserOverrides("03");
await drainMicrotasks();
const puts = fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
);
expect(puts.length).toBe(1);
expect(puts[0][0]).toBe("/api/user-overrides/03");
// "04" should still fire at the regular 300 ms boundary.
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(2);
});
it("is a no-op when no buckets are pending", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
await flushUserOverrides();
expect(fetchMock).not.toHaveBeenCalled();
});
it("resolves the original saveUserOverrides promise via the in-flight PUT", async () => {
fetchMock.mockResolvedValueOnce(mockResponse({ layout: "flushed" }));
const savePromise = saveUserOverrides("03", { layout: "flushed" });
const flushPromise = flushUserOverrides();
await drainMicrotasks();
await flushPromise;
await expect(savePromise).resolves.toEqual({ layout: "flushed" });
});
it("propagates PUT failure as caller rejection (flush itself swallows)", async () => {
fetchMock.mockResolvedValueOnce(
mockResponse({ error: "boom" }, false, 500),
);
const savePromise = saveUserOverrides("03", { layout: "x" });
// flush itself should not throw — the original waiter takes the rejection.
const flushPromise = flushUserOverrides();
await drainMicrotasks();
await expect(flushPromise).resolves.toBeUndefined();
await expect(savePromise).rejects.toThrow(/500/);
});
});
// ============================================================================
// type-level export sanity check (compile-time evidence; runtime no-op)
// ============================================================================
describe("UserOverridesPartial type (IMP-52 u5)", () => {
it("permits per-axis null sentinels and partial keys", () => {
// Compile-time only — if any of these stops being a valid assignment,
// the test suite fails at build with a TS error before this assertion
// runs. The expect() is a placebo to keep vitest happy.
const a: UserOverridesPartial = { layout: "x" };
const b: UserOverridesPartial = { layout: null };
const c: UserOverridesPartial = { frames: { unit: "tmpl" } };
const d: UserOverridesPartial = {};
expect([a, b, c, d]).toHaveLength(4);
});
});

View File

@@ -0,0 +1,569 @@
// IMP-52 u10 — Frontend write-side regression coverage.
//
// Stage 2 unit u10 contract:
// 1) All 4 in-scope mutation handlers persist their axis.
// 2) zone_sizes is NOT persisted (handleLayoutResize stays in-memory).
// 3) Write-before-Generate ordering — flushUserOverrides forces pending
// PUTs to commit before the pipeline run begins.
// 4) Restore-on-reopen end-to-end — getUserOverrides → non-frame layering
// and post-loadRun frame remap compose into a single restored state.
//
// React Testing Library is NOT installed in this repo (devDependencies has
// vitest only). Home.tsx's mutation handlers live inside `useCallback`
// closures so they cannot be invoked from a test without mounting the
// component. We cover them with two complementary tactics:
// • Source-pattern grep on Home.tsx that pins the exact wiring shape per
// handler. A regression that drops or rewires a `saveUserOverrides`
// call fails here loudly.
// • End-to-end mocked-fetch tests on the `userOverridesApi` flow with the
// payload shapes that Home.tsx produces — proves the contract the
// handlers depend on still holds.
//
// File extension is `.ts` (no JSX). All tests run in vitest's default node
// environment; fetch is stubbed with vi.stubGlobal and timers are faked so
// the 300ms debounce in `saveUserOverrides` is deterministic.
import * as fs from "node:fs";
import * as path from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type Mock,
} from "vitest";
import {
__resetUserOverridesBuckets_FOR_TEST,
flushUserOverrides,
getUserOverrides,
saveUserOverrides,
type UserOverridesPartial,
} from "../src/services/userOverridesApi";
import {
applyPersistedNonFrameOverrides,
createInitialUserSelection,
deriveUserOverridesKey,
remapPersistedFramesToZoneFrames,
} from "../src/utils/slidePlanUtils";
import type { SlidePlan, Zone } from "../src/types/designAgent";
// ─── Source-pattern regression ─────────────────────────────────────────────
// Without RTL we can't dispatch a click and read `fetch.mock.calls`. Instead
// we read Home.tsx as text and assert each in-scope handler closure contains
// the exact wiring that Stage 2 u7 specified. This is brittle in a good way:
// if a handler is renamed or its `saveUserOverrides` call is moved/removed,
// the assertion fires with a clear "X handler does not persist Y axis"
// message instead of silently regressing in prod.
const HOME_TSX_PATH = path.resolve(
__dirname,
"..",
"src",
"pages",
"Home.tsx",
);
const HOME_TSX = fs.readFileSync(HOME_TSX_PATH, "utf-8");
/**
* Slice the `const <name> = useCallback(...)` block out of Home.tsx. The
* handlers are well-formed and end either at the next `const handle...`
* declaration or at the next top-level `const ` at 2-space indent.
*/
function sliceHandler(source: string, name: string): string {
const start = source.indexOf(`const ${name} = useCallback(`);
if (start === -1) {
throw new Error(`handler "${name}" not found in Home.tsx`);
}
// Find the next handler / top-level const after `start`.
const nextHandler = source.indexOf("\n const handle", start + 1);
const nextConst = source.indexOf("\n const ", start + 1);
const candidates = [nextHandler, nextConst].filter((i) => i > start);
const end = candidates.length > 0 ? Math.min(...candidates) : source.length;
return source.slice(start, end);
}
describe("Home.tsx write-side wiring (IMP-52 u10) — source pattern", () => {
it("handleSectionDrop persists zone_sections behind uploadedFile gate", () => {
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
// gate
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
// axis key + value source
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?zone_sections:\s*finalSelection\.overrides\.zone_sections/,
);
// key derivation
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
});
it("handleLayoutSelect persists `layout` axis behind uploadedFile gate", () => {
const block = sliceHandler(HOME_TSX, "handleLayoutSelect");
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?layout:\s*layoutId\s*\}/,
);
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
});
it("handleZoneResize persists merged zone_geometries behind uploadedFile gate", () => {
const block = sliceHandler(HOME_TSX, "handleZoneResize");
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
// merged geometry (not the partial delta) is persisted so the on-disk
// axis is a complete snapshot of all currently-resized zones.
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?zone_geometries:\s*mergedGeometries/,
);
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
});
it("handleFrameSelect persists frames-by-unit_id with default-frame gate", () => {
const block = sliceHandler(HOME_TSX, "handleFrameSelect");
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*&&\s*effectiveSlidePlan\s*\)/);
// unit_id derivation matches handleGenerate's CLI-forwarding contract
expect(block).toMatch(/z\.section_ids\.join\(\s*"\+"\s*\)/);
// default-frame gate (rewind fix from Codex #17 / Claude #18)
expect(block).toMatch(/overrideId\s*!==\s*defaultFrameId/);
// axis key
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?frames:\s*framesByUnitId/,
);
});
it("handleLayoutResize does NOT call saveUserOverrides (zone_sizes excluded)", () => {
const block = sliceHandler(HOME_TSX, "handleLayoutResize");
expect(block).not.toMatch(/saveUserOverrides/);
// Sanity: handleLayoutResize still writes zone_sizes in-memory.
expect(block).toMatch(/saveZoneSizes/);
});
it("handleGenerate does NOT call saveUserOverrides (read-only re: persistence layer)", () => {
const block = sliceHandler(HOME_TSX, "handleGenerate");
// handleGenerate forwards overrides through runPipeline → /api/run, not
// through /api/user-overrides. The persistence layer is owned by the
// four mutation handlers; Generate must not introduce a competing
// write path that could clobber a partially-edited bucket.
expect(block).not.toMatch(/saveUserOverrides\(/);
});
it("no handler in Home.tsx persists the zone_sizes axis", () => {
// Top-level regression: searching the whole file rules out a future
// accidental wiring inside a new handler we forgot to enumerate above.
expect(HOME_TSX).not.toMatch(
/saveUserOverrides\([\s\S]{0,200}?zone_sizes\s*:/,
);
});
});
// ─── Payload-shape contract via mocked fetch ───────────────────────────────
// Drive `saveUserOverrides` with the exact payload shapes each in-scope
// handler produces in Home.tsx. Asserts that (a) the PUT body matches what
// the on-disk schema (u1 / u4) accepts and (b) the partial-axis contract
// holds — only the mutated axis is sent, never a full snapshot.
type MockResponse = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
};
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
return { ok, status, json: async () => body };
}
let fetchMock: Mock;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
vi.useFakeTimers();
__resetUserOverridesBuckets_FOR_TEST();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
__resetUserOverridesBuckets_FOR_TEST();
});
async function drainMicrotasks(): Promise<void> {
for (let i = 0; i < 4; i++) {
await Promise.resolve();
}
}
function lastPutBody(): unknown {
const lastCall = fetchMock.mock.calls.at(-1);
if (!lastCall) throw new Error("fetch was not called");
const init = lastCall[1] as RequestInit | undefined;
if (!init?.body) throw new Error("fetch called without a body");
return JSON.parse(String(init.body));
}
describe("save payload contract per axis (IMP-52 u10)", () => {
it("section-drop payload: PUT body carries only zone_sections", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
// Shape produced by handleSectionDrop after moveSectionToZone.
const payload: UserOverridesPartial = {
zone_sections: {
top: ["03-1", "03-2"],
bottom: ["03-3"],
},
};
void saveUserOverrides("03_demo", payload);
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["zone_sections"]);
expect(body.zone_sections).toEqual(payload.zone_sections);
});
it("layout-select payload: PUT body carries only `layout` (string)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { layout: "two-column" });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["layout"]);
expect(body.layout).toBe("two-column");
});
it("zone-resize payload: PUT body carries only zone_geometries (merged snapshot)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
const merged = {
top: { x: 0, y: 0, w: 1, h: 0.42 },
bottom_l: { x: 0, y: 0.42, w: 0.5, h: 0.58 },
bottom_r: { x: 0.5, y: 0.42, w: 0.5, h: 0.58 },
};
void saveUserOverrides("03_demo", { zone_geometries: merged });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["zone_geometries"]);
expect(body.zone_geometries).toEqual(merged);
});
it("frame-select payload: PUT body carries only frames (unit_id → template_id)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
// Shape produced by handleFrameSelect after the default-frame gate:
// only zones the user explicitly chose a non-default frame for.
const framesByUnitId = {
"03-1": "process_product_two_way",
"03-2+03-3": "three_parallel_requirements",
};
void saveUserOverrides("03_demo", { frames: framesByUnitId });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["frames"]);
expect(body.frames).toEqual(framesByUnitId);
});
it("frame-select payload with empty framesByUnitId still PUTs (replaces axis with {})", async () => {
// When the user reverts the last frame override back to the backend
// default, handleFrameSelect computes `framesByUnitId = {}`. The PUT
// path still fires so the on-disk `frames` axis is cleared to the empty
// object via u4's partial-merge replace semantics. Foreign axes
// (layout / zone_geometries / zone_sections) remain on disk.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { frames: {} });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(lastPutBody()).toEqual({ frames: {} });
});
});
// ─── zone_sizes axis is not part of the on-disk schema ─────────────────────
describe("zone_sizes axis exclusion (IMP-52 u10)", () => {
it("UserOverridesPartial type does not include zone_sizes at compile time", () => {
// Compile-time check: this assignment must be a TS error. The runtime
// assertion below is a placebo; the meaningful evidence is that the
// suite *builds*. If a future schema bump adds zone_sizes to
// UserOverrides, this comment serves as the migration touchpoint.
// @ts-expect-error — zone_sizes is intentionally not part of UserOverridesPartial
const _bad: UserOverridesPartial = { zone_sizes: { layout_group_1: [0.5, 0.5] } };
void _bad;
expect(true).toBe(true);
});
it("Home.tsx never imports a write helper that would persist zone_sizes", () => {
// handleLayoutResize delegates to saveZoneSizes (in-memory), not
// saveUserOverrides. Cross-check the import line and the handler body.
expect(HOME_TSX).toMatch(/import\s*\{[^}]*\bsaveZoneSizes\b[^}]*\}\s*from\s*"\.\.\/utils\/slidePlanUtils"/);
const block = sliceHandler(HOME_TSX, "handleLayoutResize");
expect(block).toMatch(/saveZoneSizes\(/);
expect(block).not.toMatch(/saveUserOverrides/);
});
});
// ─── Write-before-Generate ordering ────────────────────────────────────────
// The four mutation handlers schedule debounced PUTs (300ms). If the user
// hits Generate before the debounce fires, the persistence layer must not
// drop the pending writes. `flushUserOverrides` is the contract: callers can
// force-commit pending buckets before pipeline kickoff so the backend u2
// fallback reads the latest file.
describe("write-before-Generate ordering (IMP-52 u10)", () => {
// The service-level tests below prove the `flushUserOverrides` contract in
// isolation. The two source-pattern checks here pin the *real* Generate
// call site so a future refactor that drops the flush — re-exposing the
// 300ms debounce race against `runPipeline` / the u2 backend fallback —
// fails loudly. Without React Testing Library we cannot dispatch a click
// on the Generate button, so we read Home.tsx as text and assert (a) the
// import names `flushUserOverrides`, (b) the `handleGenerate` closure
// awaits the flush before it awaits `runPipeline`.
it("Home.tsx imports flushUserOverrides from userOverridesApi", () => {
expect(HOME_TSX).toMatch(
/import\s*\{[^}]*\bflushUserOverrides\b[^}]*\}\s*from\s*"\.\.\/services\/userOverridesApi"/,
);
});
it("handleGenerate awaits flushUserOverrides before awaiting runPipeline", () => {
const block = sliceHandler(HOME_TSX, "handleGenerate");
expect(block).toMatch(/await\s+flushUserOverrides\s*\(\s*\)/);
expect(block).toMatch(/await\s+runPipeline\s*\(/);
const flushIdx = block.search(/await\s+flushUserOverrides\s*\(/);
const runIdx = block.search(/await\s+runPipeline\s*\(/);
expect(flushIdx).toBeGreaterThan(-1);
expect(runIdx).toBeGreaterThan(-1);
expect(flushIdx).toBeLessThan(runIdx);
});
it("flushUserOverrides commits a pending PUT before its 300ms debounce fires", async () => {
fetchMock.mockResolvedValue(mockResponse({ layout: "two-column" }));
const savePromise = saveUserOverrides("03_demo", { layout: "two-column" });
// Without flush, the PUT would not fire for another 300ms.
expect(fetchMock).not.toHaveBeenCalled();
const flushPromise = flushUserOverrides();
await drainMicrotasks();
await flushPromise;
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/user-overrides/03_demo");
expect((init as RequestInit).method).toBe("PUT");
// Caller's promise resolves with the server-merged document — so a
// pre-Generate `await flushUserOverrides()` can be paired with
// `await savePromise` for stronger ordering if needed.
await expect(savePromise).resolves.toEqual({ layout: "two-column" });
});
it("flushUserOverrides (no arg) flushes pending writes across multiple MDX keys", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { layout: "two-column" });
void saveUserOverrides("04_demo", { frames: { "04-1": "tpl_a" } });
void saveUserOverrides("05_demo", {
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
});
await flushUserOverrides();
await drainMicrotasks();
const putUrls = fetchMock.mock.calls
.filter((c) => (c[1] as RequestInit).method === "PUT")
.map((c) => c[0]);
expect(putUrls).toEqual(
expect.arrayContaining([
"/api/user-overrides/03_demo",
"/api/user-overrides/04_demo",
"/api/user-overrides/05_demo",
]),
);
expect(putUrls).toHaveLength(3);
});
it("flushUserOverrides is a no-op when no writes are pending", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
await flushUserOverrides();
expect(fetchMock).not.toHaveBeenCalled();
});
it("post-flush, a new save schedules a fresh 300ms debounce window", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { layout: "two-column" });
await flushUserOverrides();
await drainMicrotasks();
expect(
fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
),
).toHaveLength(1);
// Second save after Generate completes — must not piggy-back on the
// already-flushed bucket; must re-arm a fresh debounce.
void saveUserOverrides("03_demo", { layout: "hero-detail" });
vi.advanceTimersByTime(299);
await drainMicrotasks();
expect(
fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
),
).toHaveLength(1);
vi.advanceTimersByTime(1);
await drainMicrotasks();
expect(
fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit).method === "PUT",
),
).toHaveLength(2);
});
});
// ─── Restore-on-reopen — end-to-end compose ────────────────────────────────
// u6 covers the helpers in isolation. This test wires them together with a
// mocked GET response in the order Home.tsx invokes them at file-upload
// time (key derive → fetch persisted → layer non-frame axes pre-loadRun →
// remap frames post-loadRun) to pin the integration contract.
function makeZone(partial: {
id: string;
zone_id: string;
section_ids: string[];
default_frame_id?: string | null;
}): Zone {
return {
id: partial.id,
zone_id: partial.zone_id,
section_ids: partial.section_ids,
position: { x: 0, y: 0, width: 1, height: 1 },
internal_regions: [
{
id: `${partial.id}-r0`,
region_id: "region-single",
role: "primary",
content_type: "text_block",
ratio_estimate: 1,
content_unit_ids: [],
frame_match_strategy: {
kind: "frame_match",
frame_id: partial.default_frame_id ?? null,
display_strategy: "inline_full",
},
frame_candidates: [],
},
],
};
}
describe("restore-on-reopen end-to-end (IMP-52 u10)", () => {
it("getUserOverrides → non-frame layer + post-load frame remap composes a restored selection", async () => {
// GET returns the persisted file for "03_demo". The `layout` value
// must be a real LayoutPresetId — applyPersistedNonFrameOverrides
// validates against the 8-preset whitelist (slidePlanUtils.ts:30).
fetchMock.mockResolvedValueOnce(
mockResponse({
layout: "horizontal-2",
frames: { "03-1": "process_product_two_way" },
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.42 } },
zone_sections: { top: ["03-1"], bottom: ["03-2", "03-3"] },
}),
);
const key = deriveUserOverridesKey("03_demo.mdx");
expect(key).toBe("03_demo");
// Step 1: Home.tsx fetches at handleFileUpload time.
const persisted = await getUserOverrides(key);
expect(persisted.layout).toBe("horizontal-2");
// Step 2: pre-loadRun layering applies layout / zone_geometries /
// zone_sections onto a fresh selection. Frames are deferred because
// the unit_id key cannot be remapped without a slidePlan yet.
const seededSelection = applyPersistedNonFrameOverrides(
createInitialUserSelection(null),
persisted,
);
expect(seededSelection.overrides.layout_preset).toBe("horizontal-2");
expect(seededSelection.overrides.zone_geometries).toEqual({
top: { x: 0, y: 0, w: 1, h: 0.42 },
});
expect(seededSelection.overrides.zone_sections).toEqual({
top: ["03-1"],
bottom: ["03-2", "03-3"],
});
// Frames must NOT have been layered at this stage.
expect(seededSelection.overrides.zone_frames).toEqual({});
// Step 3: post-loadRun, Home.tsx has a slidePlan. Remap unit_id-keyed
// frames to region.id-keyed frames against the rebuilt plan.
const plan: SlidePlan = {
id: "plan-3",
title: "demo",
layout_preset: "horizontal-2",
zones: [
makeZone({
id: "z-top",
zone_id: "top",
section_ids: ["03-1"],
default_frame_id: "some_default_frame",
}),
makeZone({
id: "z-bot",
zone_id: "bottom",
section_ids: ["03-2", "03-3"],
default_frame_id: null,
}),
],
};
const remapped = remapPersistedFramesToZoneFrames(
plan,
persisted.frames,
);
expect(remapped).toEqual({
"z-top-r0": "process_product_two_way",
});
// Step 4: post-loadRun merge — Home.tsx layers `remapped` onto
// `createInitialUserSelection(slidePlan)` so the SlideCanvas
// override-vs-default preview indicator surfaces the restored choice.
const finalSelection = {
...applyPersistedNonFrameOverrides(
createInitialUserSelection(plan),
persisted,
),
};
finalSelection.overrides = {
...finalSelection.overrides,
zone_frames: { ...finalSelection.overrides.zone_frames, ...remapped },
};
expect(finalSelection.overrides.zone_frames["z-top-r0"]).toBe(
"process_product_two_way",
);
expect(finalSelection.overrides.layout_preset).toBe("horizontal-2");
expect(finalSelection.overrides.zone_sections).toEqual({
top: ["03-1"],
bottom: ["03-2", "03-3"],
});
});
it("missing persisted file (GET returns {}) leaves the selection at backend defaults", async () => {
fetchMock.mockResolvedValueOnce(mockResponse({}));
const persisted = await getUserOverrides(deriveUserOverridesKey("new_file.mdx"));
expect(persisted).toEqual({});
const plan: SlidePlan = {
id: "plan-x",
title: "fresh",
layout_preset: "single",
zones: [
makeZone({ id: "z-only", zone_id: "main", section_ids: ["x-1"] }),
],
};
const seeded = applyPersistedNonFrameOverrides(
createInitialUserSelection(plan),
persisted,
);
// No override applied → layout_preset, geometries, sections all from
// the slidePlan defaults; remap yields {} so no frames layered.
expect(seeded.overrides.layout_preset).toBe("single");
expect(seeded.overrides.zone_geometries).toEqual({});
expect(remapPersistedFramesToZoneFrames(plan, persisted.frames)).toEqual({});
});
});

View File

@@ -204,12 +204,307 @@ function vitePluginStorageProxy(): Plugin {
}; };
} }
// =============================================================================
// IMP-52 u3/u4 — user_overrides.json persistence (MDX-stem keyed store).
//
// On-disk layout: <DESIGN_AGENT_ROOT>/data/user_overrides/<key>.json. Mirrors
// the Python contract in src/user_overrides_io.py — same validate_key regex,
// same graceful-degrade (corrupt → {}) so backend pipeline entry fallback
// (u2) and the vite endpoints (u3 GET, u4 PUT) agree on every file.
//
// Helpers are named exports so vitest can drive handleGetUserOverrides /
// handlePutUserOverrides with mock req/res without booting a real dev
// server. vite still consumes the default `defineConfig` export below.
// =============================================================================
export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/;
// The 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<string, string>) => void;
end: (body?: string) => void;
};
// IMP-52 u3 — GET /api/user-overrides/:key handler. Returns true when the
// handler took over the response, false when the caller should `next()`.
// Invariants:
// • method != GET → false (chain continues; u4 PUT may handle)
// • invalid key → 400 {"error":"invalid key"}
// • file missing → 200 {}
// • file unreadable/corrupt → 200 {} (graceful degrade, mirrors u1 load)
// • non-object JSON root → 200 {} (mirrors u1 load)
// • valid object JSON → 200 with parsed JSON body
export function handleGetUserOverrides(
req: GetReqLike,
res: ResLike,
root: string,
): boolean {
if (req.method !== "GET") return false;
const url = req.url || "";
const key = url.split("?")[0].replace(/^\//, "");
if (!isValidUserOverridesKey(key)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid key" }));
return true;
}
const filePath = userOverridesPath(root, key);
if (!fs.existsSync(filePath)) {
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end("{}");
return true;
}
let parsed: unknown;
try {
const raw = fs.readFileSync(filePath, "utf-8");
parsed = JSON.parse(raw);
} catch {
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end("{}");
return true;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end("{}");
return true;
}
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(parsed));
return true;
}
// IMP-52 u4 — pure merge function. Mirrors src/user_overrides_io.save():
// • Only KNOWN_USER_OVERRIDES_AXES present in `partial` are mutated.
// • Axes absent from `partial` are preserved verbatim from `existing`.
// • Foreign top-level keys in `existing` (future axes like zone_sizes
// 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<string, unknown>,
partial: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> = { ...existing };
for (const axis of KNOWN_USER_OVERRIDES_AXES) {
if (!(axis in partial)) continue;
const value = partial[axis];
if (value === null) {
delete merged[axis];
} else {
merged[axis] = value;
}
}
return merged;
}
// IMP-52 u4 — atomic file write via tmp + rename. Mirrors the
// `_atomic_write_json` semantics in src/user_overrides_io.py so a
// crashed/interrupted PUT cannot leave a half-written .json on disk
// (the next GET / pipeline-entry read would otherwise return {} via
// graceful degrade, silently losing the user's prior overrides).
export function atomicWriteUserOverrides(
filePath: string,
data: Record<string, unknown>,
): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const tmpName = path.join(
dir,
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
);
try {
fs.writeFileSync(
tmpName,
JSON.stringify(data, null, 2) + "\n",
"utf-8",
);
fs.renameSync(tmpName, filePath);
} catch (err) {
try {
fs.unlinkSync(tmpName);
} catch {
// best-effort cleanup; the rename source may not exist on early failure
}
throw err;
}
}
// IMP-52 u4 — PUT /api/user-overrides/:key handler. Returns true when the
// handler took over the response, false when the caller should `next()`.
// Invariants:
// • method != PUT → false (chain continues; GET runs first)
// • invalid key → 400 {"error":"invalid key"}
// • body > 1MB → 413 {"error":"payload too large"}
// • invalid JSON → 400 {"error":"invalid JSON"}
// • non-object JSON root → 400 {"error":"body must be a JSON object"}
// • write failure → 500 {"error":"write failed: ..."}
// • success → 200 with merged JSON body
//
// Existing-file read uses the same graceful-degrade rules as GET (corrupt
// JSON / non-object root → treat as empty {}) so a PUT cannot fail solely
// because a prior file is unparseable — the new payload replaces it.
export function handlePutUserOverrides(
req: PutReqLike,
res: ResLike,
root: string,
): boolean {
if (req.method !== "PUT") return false;
const url = req.url || "";
const key = url.split("?")[0].replace(/^\//, "");
if (!isValidUserOverridesKey(key)) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "invalid key" }));
return true;
}
let body = "";
let aborted = false;
req.on("data", (chunk: Buffer | string) => {
if (aborted) return;
body += typeof chunk === "string" ? chunk : chunk.toString();
if (body.length > USER_OVERRIDES_PUT_MAX_BYTES) {
aborted = true;
res.writeHead(413, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: "payload too large" }));
}
});
req.on("end", () => {
if (aborted) return;
let parsed: unknown;
try {
parsed = body.length > 0 ? JSON.parse(body) : {};
} catch {
res.writeHead(400, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: "invalid JSON" }));
return;
}
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
res.writeHead(400, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: "body must be a JSON object" }));
return;
}
const partial = parsed as Record<string, unknown>;
const filePath = userOverridesPath(root, key);
// Load existing — corrupt / non-object → {} so the PUT still succeeds
// and recovers the file to a clean state. Mirrors u1 load() graceful
// degrade.
let existing: Record<string, unknown> = {};
if (fs.existsSync(filePath)) {
try {
const raw = fs.readFileSync(filePath, "utf-8");
const ex = JSON.parse(raw);
if (
typeof ex === "object" &&
ex !== null &&
!Array.isArray(ex)
) {
existing = ex as Record<string, unknown>;
}
} catch {
// corrupt → treat as empty
}
}
const merged = mergeUserOverrides(existing, partial);
try {
atomicWriteUserOverrides(filePath, merged);
} catch (err) {
res.writeHead(500, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: `write failed: ${String(err)}` }));
return;
}
res.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify(merged));
});
req.on("error", () => {
if (aborted) return;
aborted = true;
res.writeHead(500, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({ error: "request error" }));
});
return true;
}
// ============================================================================= // =============================================================================
// Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출 // Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출
// //
// Endpoints (vite dev middleware) : // Endpoints (vite dev middleware) :
// POST /api/run multipart/JSON body {filename, content} → run_id // 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 /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 // 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); 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} ── // ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ──
server.middlewares.use("/data/runs", (req, res, next) => { server.middlewares.use("/data/runs", (req, res, next) => {
if (req.method !== "GET") return next(); if (req.method !== "GET") return next();

View File

@@ -1689,6 +1689,14 @@ def _override_to_grid_tracks(
R = len(rows_grid) R = len(rows_grid)
C = len(rows_grid[0]) 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]]] = [] occupancy: list[tuple[dict, set[int], set[int]]] = []
for z in zones_data: for z in zones_data:
pos = z["position"] pos = z["position"]
@@ -1703,17 +1711,19 @@ def _override_to_grid_tracks(
single = [z for z, rr, _cc in occupancy if rr == {idx}] single = [z for z, rr, _cc in occupancy if rr == {idx}]
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))] allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
key = "h" key = "h"
_fallback = (_default_heights[idx] / _sum_h) if idx < len(_default_heights) and _sum_h else (1.0 / R)
else: else:
single = [z for z, _rr, cc in occupancy if cc == {idx}] single = [z for z, _rr, cc in occupancy if cc == {idx}]
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))] allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
key = "w" key = "w"
_fallback = (_default_widths[idx] / _sum_w) if idx < len(_default_widths) and _sum_w else (1.0 / C)
candidates = single or allspan candidates = single or allspan
vals = [ vals = [
float(override_zone_geometries[z["position"]][key]) float(override_zone_geometries[z["position"]][key])
for z in candidates for z in candidates
if z["position"] in override_zone_geometries 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)] row_values = [_track_value(r, "row") for r in range(R)]
col_values = [_track_value(c, "col") for c in range(C)] 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 override_zone_geometries:
if layout_preset == "horizontal-2": if layout_preset == "horizontal-2":
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배. # 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 = [] ratios = []
for pos in positions: for pos in positions:
geom = override_zone_geometries.get(pos) 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) total = sum(ratios)
if total > 0: if total > 0:
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios] 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). # cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
# PR 1 keeps fr-string cols for legacy preserve; widths_px is # PR 1 keeps fr-string cols for legacy preserve; widths_px is
# populated in pixels for _compute_per_zone_geometry length contract. # 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 = [] ratios = []
for pos in positions: for pos in positions:
geom = override_zone_geometries.get(pos) 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) total = sum(ratios)
if total > 0: if total > 0:
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios) 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 _seen_sections_across_zones[sid] = zid
overrides_section_assignments[zid] = section_ids 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( run_phase_z2_mvp1(
args.mdx_path, args.mdx_path,
args.run_id, args.run_id,
override_layout=args.override_layout, override_layout=_final_override_layout,
override_frames=overrides_frames or None, override_frames=overrides_frames or None,
override_zone_geometries=overrides_geoms or None, override_zone_geometries=overrides_geoms or None,
override_section_assignments=overrides_section_assignments or None, override_section_assignments=overrides_section_assignments or None,

162
src/user_overrides_io.py Normal file
View File

@@ -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/<run_id>/`` which mints a fresh
run_id per ``/api/run`` invocation.
Schema (4 axes; stable order):
{
"layout": <string|null>,
"zone_geometries": {<zone_id>: {"x": float, "y": float, "w": float, "h": float}},
"zone_sections": {<zone_id>: [<section_id>, ...]},
"frames": {<unit_id>: <template_id>}
}
``unit_id`` is the convention already used by ``--override-frame`` :
``"+".join(source_section_ids)`` (e.g., ``"03-1"`` or ``"03-1+03-2"``).
Behavior :
- ``load(key)`` — file missing or corrupt → ``{}`` (warning to stderr on corrupt).
- ``save(key, partial)`` — merges only the supplied axes onto the existing
file, preserving (a) unknown top-level keys (foreign-key preserve) and
(b) axes not present in the partial payload. Atomic write via tmp+rename.
- ``override_path(key, root=None)`` — resolves the persistence path under
``data/user_overrides/<key>.json``.
Guardrails (refs : ``user_overrides_io`` Stage 2 lock) :
- Deterministic code, no AI fallback.
- ``key`` validation rejects path traversal / separators / dot-prefix.
- ``save`` is a deep-shallow merge — per-axis dict mutation does not delete
prior keys unless caller passes ``None`` for that axis (explicit clear).
"""
from __future__ import annotations
import json
import os
import re
import sys
import tempfile
from pathlib import Path
from typing import Any, Optional
# Persistence root — MDX-keyed, decoupled from data/runs/<run_id>/.
# Resolved at call time so tests can monkeypatch via ``root=`` parameter.
_PKG_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
# The 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

View File

@@ -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)

View File

@@ -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