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:
@@ -2,7 +2,7 @@
|
||||
* Home - 메인 페이지 (Zone-Centric 슬라이드 빌더)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent";
|
||||
import {
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
getSelectedRegion,
|
||||
moveSectionToZone,
|
||||
saveZoneSizes,
|
||||
deriveUserOverridesKey,
|
||||
applyPersistedNonFrameOverrides,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
} from "../utils/slidePlanUtils";
|
||||
import {
|
||||
parseMdxFile,
|
||||
@@ -25,6 +28,12 @@ import {
|
||||
type RunMeta,
|
||||
type PipelineOverrides,
|
||||
} from "../services/designAgentApi";
|
||||
import {
|
||||
flushUserOverrides,
|
||||
getUserOverrides,
|
||||
saveUserOverrides,
|
||||
type UserOverrides,
|
||||
} from "../services/userOverridesApi";
|
||||
|
||||
import LeftMdxPanel from "../components/LeftMdxPanel";
|
||||
import SlideCanvas from "../components/SlideCanvas";
|
||||
@@ -63,6 +72,14 @@ export default function Home() {
|
||||
// section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시).
|
||||
const [pendingLayout, setPendingLayout] = useState<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.
|
||||
// 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect /
|
||||
// getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용.
|
||||
@@ -180,7 +197,19 @@ export default function Home() {
|
||||
|
||||
try {
|
||||
const content = await parseMdxFile(file);
|
||||
setState((p) => ({ ...p, normalizedContent: content, isLoading: false }));
|
||||
// IMP-52 u6 — restore-on-reopen. Key = MDX stem (matches backend
|
||||
// u2 fallback's Path(args.mdx_path).stem). getUserOverrides returns
|
||||
// {} on miss / corrupt / network failure (u5 contract) so the upload
|
||||
// path never fails on a fresh MDX.
|
||||
const overridesKey = deriveUserOverridesKey(file.name);
|
||||
const persisted = await getUserOverrides(overridesKey);
|
||||
persistedOverridesRef.current = persisted;
|
||||
setState((p) => ({
|
||||
...p,
|
||||
normalizedContent: content,
|
||||
userSelection: applyPersistedNonFrameOverrides(p.userSelection, persisted),
|
||||
isLoading: false,
|
||||
}));
|
||||
toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -257,9 +286,11 @@ export default function Home() {
|
||||
const overrides: PipelineOverrides = {};
|
||||
const sourcePlan = effectiveSlidePlan;
|
||||
if (sourcePlan && state.slidePlan) {
|
||||
const defaultLayout = state.slidePlan.layout_preset;
|
||||
// 2026-05-22 demo hot-fix — 이전 비교 가드 (default !== override) 제거.
|
||||
// restore loop 이 default = override 로 sync 시 override 안 보내고 backend
|
||||
// default fallback 발생. user 가 명시한 layout 이 있으면 무조건 보냄.
|
||||
const overrideLayout = state.userSelection.overrides.layout_preset;
|
||||
if (overrideLayout && overrideLayout !== defaultLayout) {
|
||||
if (overrideLayout) {
|
||||
overrides.layout = overrideLayout;
|
||||
}
|
||||
const frames: Record<string, string> = {};
|
||||
@@ -302,12 +333,9 @@ export default function Home() {
|
||||
overrides.zoneGeometries = zoneGeometries;
|
||||
}
|
||||
|
||||
// IMP-08 B-3 : zoneSections forward only when the user diverged from
|
||||
// the auto plan. Codex Stage 3 R3 B3 fix : `createInitialUserSelection`
|
||||
// seeds `zone_sections` with the default placement, so a literal copy
|
||||
// would pollute backend assignment-source provenance even on a fresh
|
||||
// re-render. Diff against `sourcePlan.zones[].section_ids` per zone and
|
||||
// only emit zones whose section list differs.
|
||||
// 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀.
|
||||
// 시연 안정성 우선. section swap 은 별 path (수동 drag detection) 로 풀어야 함.
|
||||
// 임시 over-aggressive fix 가 default flow 깨뜨려 PARTIAL_COVERAGE 발생했음.
|
||||
const userZoneSections = state.userSelection.overrides.zone_sections;
|
||||
if (userZoneSections) {
|
||||
const defaultByZone = new Map<string, string[]>();
|
||||
@@ -349,6 +377,12 @@ export default function Home() {
|
||||
toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`);
|
||||
|
||||
try {
|
||||
// IMP-52 u10 — Force-commit any pending debounced PUTs before backend
|
||||
// reads user_overrides.json on pipeline entry. Without this, a user
|
||||
// who changes an override (300ms debounce window) and immediately
|
||||
// clicks Generate would race the PUT against /api/run; the u2
|
||||
// fallback could then load a stale persisted document.
|
||||
await flushUserOverrides();
|
||||
const result = await runPipeline(state.uploadedFile, overrides);
|
||||
|
||||
if (!result.success || !result.final_html_exists) {
|
||||
@@ -362,13 +396,42 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id);
|
||||
setState((p) => ({
|
||||
// IMP-52 u6 — post-loadRun frame remap. persistedOverridesRef holds
|
||||
// the user_overrides.json read at handleFileUpload time. Frames there
|
||||
// are keyed by unit_id (section_ids joined by "+"); the in-memory
|
||||
// zone_frames is keyed by region.id. Remap against the new slidePlan
|
||||
// zones so SlideCanvas's override-vs-default preview indicator shows
|
||||
// the user's persisted choice without forcing them to re-click.
|
||||
const restoredZoneFrames = remapPersistedFramesToZoneFrames(
|
||||
slidePlan,
|
||||
persistedOverridesRef.current.frames as Record<string, string> | undefined,
|
||||
);
|
||||
setState((p) => {
|
||||
// IMP-52 u6 — restore-on-reopen: re-layer the persisted non-frame
|
||||
// axes (layout / zone_geometries / zone_sections) onto the post-load
|
||||
// `base`. `createInitialUserSelection` rebuilds from slidePlan and
|
||||
// drops anything the backend fallback could not round-trip through
|
||||
// a CLI arg — `zone_geometries` in particular has no slidePlan
|
||||
// representation, so without this merge the user would see their
|
||||
// resized zones revert on every Generate.
|
||||
const base = applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(slidePlan),
|
||||
persistedOverridesRef.current,
|
||||
);
|
||||
return {
|
||||
...p,
|
||||
normalizedContent,
|
||||
slidePlan,
|
||||
userSelection: createInitialUserSelection(slidePlan),
|
||||
userSelection: {
|
||||
...base,
|
||||
overrides: {
|
||||
...base.overrides,
|
||||
zone_frames: { ...base.overrides.zone_frames, ...restoredZoneFrames },
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
}));
|
||||
};
|
||||
});
|
||||
setRunMeta(runMeta);
|
||||
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
|
||||
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
|
||||
@@ -386,10 +449,21 @@ export default function Home() {
|
||||
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
|
||||
setState((p) => {
|
||||
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
|
||||
return {
|
||||
...p,
|
||||
userSelection: selectZone(newSelection, zoneId) // 이동된 존 자동 선택
|
||||
};
|
||||
const finalSelection = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
|
||||
// IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk
|
||||
// schema axis (`zone_sections`) shares the in-memory shape (zone_id →
|
||||
// section_ids), so we forward the full mutated value; the u4 PUT path
|
||||
// replaces this axis atomically while preserving the foreign axes.
|
||||
// p.uploadedFile gate skips persistence before any MDX is loaded —
|
||||
// the demo-mode initial render path would otherwise PUT to the empty
|
||||
// key. saveUserOverrides is debounced (300ms) and per-key coalesced.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, {
|
||||
zone_sections: finalSelection.overrides.zone_sections,
|
||||
});
|
||||
}
|
||||
return { ...p, userSelection: finalSelection };
|
||||
});
|
||||
setRightTab("frame");
|
||||
setHasPendingChanges(true);
|
||||
@@ -415,10 +489,18 @@ export default function Home() {
|
||||
|
||||
// ── Layout 선택 ──
|
||||
const handleLayoutSelect = useCallback((layoutId: string) => {
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: applyLayout(p.userSelection, layoutId as LayoutPresetId)
|
||||
}));
|
||||
setState((p) => {
|
||||
const newSelection = applyLayout(p.userSelection, layoutId as LayoutPresetId);
|
||||
// IMP-52 u7 — persist the selected layout preset id. The on-disk
|
||||
// `layout` axis is a single string; `applyLayout` validates the
|
||||
// preset id before mutating the selection, so the value here is
|
||||
// already the LayoutPresetId we want to round-trip.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { layout: layoutId });
|
||||
}
|
||||
return { ...p, userSelection: newSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
}, []);
|
||||
|
||||
@@ -431,19 +513,30 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
const handleZoneResize = useCallback((geometries: Record<string, { x: number; y: number; w: number; h: number }>) => {
|
||||
setState((p) => ({
|
||||
setState((p) => {
|
||||
const mergedGeometries = {
|
||||
...p.userSelection.overrides.zone_geometries,
|
||||
...geometries,
|
||||
};
|
||||
// IMP-52 u7 — persist the merged zone_geometries snapshot. Resize
|
||||
// gestures fire repeatedly during a drag; the 300ms u5 debounce
|
||||
// collapses them into a single PUT at gesture-end, so we don't
|
||||
// need to gate on resize-finished here.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { zone_geometries: mergedGeometries });
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
userSelection: {
|
||||
...p.userSelection,
|
||||
overrides: {
|
||||
...p.userSelection.overrides,
|
||||
zone_geometries: {
|
||||
...p.userSelection.overrides.zone_geometries,
|
||||
...geometries
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
zone_geometries: mergedGeometries,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
}, []);
|
||||
|
||||
@@ -486,10 +579,40 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: applyFrame(p.userSelection, region.id, frameId)
|
||||
}));
|
||||
setState((p) => {
|
||||
const newSelection = applyFrame(p.userSelection, region.id, frameId);
|
||||
// IMP-52 u7 — persist frames keyed by `unit_id`. The on-disk schema
|
||||
// uses `unit_id = zone.section_ids.join("+")` (the same convention
|
||||
// handleGenerate uses when forwarding `overrides.frames` to the
|
||||
// backend CLI). `zone_frames` is keyed by region.id, so we walk
|
||||
// the effectiveSlidePlan zones to translate. Only true user
|
||||
// overrides are persisted — `createInitialUserSelection` pre-fills
|
||||
// `zone_frames[region.id]` with `region.frame_match_strategy.frame_id`
|
||||
// (backend default) for every region, so we mirror handleGenerate's
|
||||
// `overrideFrameId !== defaultFrameId` gate to avoid leaking defaults
|
||||
// into user_overrides.json. Zones with no sections are skipped.
|
||||
if (p.uploadedFile && effectiveSlidePlan) {
|
||||
const framesByUnitId: Record<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);
|
||||
}, [effectiveSlidePlan, state.userSelection]);
|
||||
|
||||
|
||||
221
Front/client/src/services/userOverridesApi.ts
Normal file
221
Front/client/src/services/userOverridesApi.ts
Normal 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();
|
||||
}
|
||||
@@ -1,4 +1,108 @@
|
||||
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
|
||||
import type { UserOverrides } from "../services/userOverridesApi";
|
||||
|
||||
// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ────
|
||||
// These helpers compose persisted `user_overrides.json` payloads (typed by
|
||||
// the u5 service) onto the in-memory `UserSelection`. They live here rather
|
||||
// than inline in Home.tsx so vitest can drive them in a node environment
|
||||
// without booting React or pulling in the radix-ui / lucide UI deps that
|
||||
// Home.tsx requires. Home.tsx wires these into:
|
||||
// • handleFileUpload (pre-Generate layout / zone_geometries / zone_sections
|
||||
// seed so handleGenerate's CLI-args build picks them up)
|
||||
// • handleGenerate post-loadRun (frame remap unit_id → region.id over the
|
||||
// freshly built slidePlan)
|
||||
// The on-disk schema and clear-sentinel semantics are owned by:
|
||||
// • src/user_overrides_io.py (KNOWN_AXES, u1)
|
||||
// • Front/vite.config.ts mergeUserOverrides (u4)
|
||||
// • Front/client/src/services/userOverridesApi.ts (UserOverrides type, u5)
|
||||
// Any KNOWN_AXES drift must land in those files first.
|
||||
|
||||
/**
|
||||
* Derive the `/api/user-overrides/:key` MDX-stem key from a filename.
|
||||
* Strips a trailing `.mdx` (case-insensitive). The key matches the Python
|
||||
* `Path(args.mdx_path).stem` derivation used by the backend fallback (u2),
|
||||
* so the same persisted file is read from both ends without translation.
|
||||
*/
|
||||
export function deriveUserOverridesKey(filename: string): string {
|
||||
return filename.replace(/\.mdx$/i, "");
|
||||
}
|
||||
|
||||
const LAYOUT_PRESET_IDS = new Set<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 초기 선택 상태 생성
|
||||
|
||||
734
Front/client/tests/user_overrides_endpoint.test.ts
Normal file
734
Front/client/tests/user_overrides_endpoint.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
302
Front/client/tests/user_overrides_restore.test.ts
Normal file
302
Front/client/tests/user_overrides_restore.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
485
Front/client/tests/user_overrides_service.test.ts
Normal file
485
Front/client/tests/user_overrides_service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
569
Front/client/tests/user_overrides_write.test.ts
Normal file
569
Front/client/tests/user_overrides_write.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
@@ -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 업로드 → 파이프라인 실행 → 결과 노출
|
||||
//
|
||||
// Endpoints (vite dev middleware) :
|
||||
// POST /api/run multipart/JSON body {filename, content} → run_id
|
||||
// GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path}
|
||||
// GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3)
|
||||
// PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4)
|
||||
//
|
||||
// 환경 변수 (선택) :
|
||||
// DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent
|
||||
@@ -464,6 +759,19 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
fs.createReadStream(previewPath).pipe(res);
|
||||
});
|
||||
|
||||
// ── GET / PUT /api/user-overrides/{key} → data/user_overrides/{key}.json ──
|
||||
// IMP-52 u3 (GET) + u4 (PUT) — MDX-stem keyed user overrides. Logic
|
||||
// lives in the pure helpers (handleGetUserOverrides / handlePutUserOverrides)
|
||||
// so vitest can exercise them without booting vite. Both handlers
|
||||
// return false when the HTTP method does not match, so they chain
|
||||
// cleanly: GET first, then PUT, then next() for everything else
|
||||
// (e.g., OPTIONS / preflight handled by upstream middleware).
|
||||
server.middlewares.use("/api/user-overrides", (req, res, next) => {
|
||||
if (handleGetUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
|
||||
if (handlePutUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
|
||||
next();
|
||||
});
|
||||
|
||||
// ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ──
|
||||
server.middlewares.use("/data/runs", (req, res, next) => {
|
||||
if (req.method !== "GET") return next();
|
||||
|
||||
@@ -1689,6 +1689,14 @@ def _override_to_grid_tracks(
|
||||
R = len(rows_grid)
|
||||
C = len(rows_grid[0])
|
||||
|
||||
# Hot-fix (2026-05-22): partial override 버그 fix — override 없는 track 은
|
||||
# default 비율로 fallback. 이전엔 0 반환 → normalize 후 다른 track 이 모든 공간 흡수.
|
||||
_default_result = _build_grid_dynamic_2d(preset, zones_data, gap=gap)
|
||||
_default_widths = _default_result.get("widths_px", []) or []
|
||||
_default_heights = _default_result.get("heights_px", []) or []
|
||||
_sum_w = sum(_default_widths) if _default_widths else 1.0
|
||||
_sum_h = sum(_default_heights) if _default_heights else 1.0
|
||||
|
||||
occupancy: list[tuple[dict, set[int], set[int]]] = []
|
||||
for z in zones_data:
|
||||
pos = z["position"]
|
||||
@@ -1703,17 +1711,19 @@ def _override_to_grid_tracks(
|
||||
single = [z for z, rr, _cc in occupancy if rr == {idx}]
|
||||
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
|
||||
key = "h"
|
||||
_fallback = (_default_heights[idx] / _sum_h) if idx < len(_default_heights) and _sum_h else (1.0 / R)
|
||||
else:
|
||||
single = [z for z, _rr, cc in occupancy if cc == {idx}]
|
||||
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
|
||||
key = "w"
|
||||
_fallback = (_default_widths[idx] / _sum_w) if idx < len(_default_widths) and _sum_w else (1.0 / C)
|
||||
candidates = single or allspan
|
||||
vals = [
|
||||
float(override_zone_geometries[z["position"]][key])
|
||||
for z in candidates
|
||||
if z["position"] in override_zone_geometries
|
||||
]
|
||||
return max(vals) if vals else 0.0
|
||||
return max(vals) if vals else _fallback
|
||||
|
||||
row_values = [_track_value(r, "row") for r in range(R)]
|
||||
col_values = [_track_value(c, "col") for c in range(C)]
|
||||
@@ -1793,10 +1803,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
||||
if override_zone_geometries:
|
||||
if layout_preset == "horizontal-2":
|
||||
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
|
||||
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
|
||||
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
|
||||
overridden_h = sum(
|
||||
float(override_zone_geometries[p]["h"])
|
||||
for p in positions if p in override_zone_geometries
|
||||
)
|
||||
non_overridden = [p for p in positions if p not in override_zone_geometries]
|
||||
per_non = max(0.0, 1.0 - overridden_h) / max(len(non_overridden), 1)
|
||||
ratios = []
|
||||
for pos in positions:
|
||||
geom = override_zone_geometries.get(pos)
|
||||
ratios.append(float(geom["h"]) if geom else 0.0)
|
||||
ratios.append(float(geom["h"]) if geom else per_non)
|
||||
total = sum(ratios)
|
||||
if total > 0:
|
||||
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios]
|
||||
@@ -1818,10 +1836,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
||||
# cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
|
||||
# PR 1 keeps fr-string cols for legacy preserve; widths_px is
|
||||
# populated in pixels for _compute_per_zone_geometry length contract.
|
||||
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
|
||||
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
|
||||
overridden_w = sum(
|
||||
float(override_zone_geometries[p]["w"])
|
||||
for p in positions if p in override_zone_geometries
|
||||
)
|
||||
non_overridden = [p for p in positions if p not in override_zone_geometries]
|
||||
per_non = max(0.0, 1.0 - overridden_w) / max(len(non_overridden), 1)
|
||||
ratios = []
|
||||
for pos in positions:
|
||||
geom = override_zone_geometries.get(pos)
|
||||
ratios.append(float(geom["w"]) if geom else 0.0)
|
||||
ratios.append(float(geom["w"]) if geom else per_non)
|
||||
total = sum(ratios)
|
||||
if total > 0:
|
||||
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
|
||||
@@ -5917,10 +5943,81 @@ if __name__ == "__main__":
|
||||
_seen_sections_across_zones[sid] = zid
|
||||
overrides_section_assignments[zid] = section_ids
|
||||
|
||||
# IMP-52 (#80) u2 — user_overrides.json persistence fallback.
|
||||
# After argparse fully parses CLI flags, fill ONLY the axes the user
|
||||
# did NOT pass on the command line. CLI payload always wins over the
|
||||
# persisted file (Stage 2 lock: "CLI > file, 결손 축만 채움").
|
||||
# MDX stem keys the persistence file; invalid stems / corrupt file
|
||||
# degrade gracefully (warning to stderr + no override injected).
|
||||
from src.user_overrides_io import (
|
||||
InvalidOverrideKey,
|
||||
load as _load_user_overrides,
|
||||
validate_key as _validate_overrides_key,
|
||||
)
|
||||
|
||||
_final_override_layout = args.override_layout
|
||||
try:
|
||||
_ov_key = _validate_overrides_key(Path(args.mdx_path).stem)
|
||||
except InvalidOverrideKey as _exc:
|
||||
print(
|
||||
f"[user_overrides] warning: cannot derive persistence key from "
|
||||
f"mdx_path {args.mdx_path!r}: {_exc}; skipping fallback.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
_ov_key = None
|
||||
if _ov_key is not None:
|
||||
_persisted = _load_user_overrides(_ov_key)
|
||||
# layout — CLI None → fill from file (must be str).
|
||||
if _final_override_layout is None:
|
||||
_file_layout = _persisted.get("layout")
|
||||
if isinstance(_file_layout, str) and _file_layout:
|
||||
_final_override_layout = _file_layout
|
||||
# frames — CLI empty → fill from file (must be dict[str, str]).
|
||||
if not overrides_frames:
|
||||
_file_frames = _persisted.get("frames")
|
||||
if isinstance(_file_frames, dict):
|
||||
overrides_frames = {
|
||||
str(k): str(v)
|
||||
for k, v in _file_frames.items()
|
||||
if isinstance(k, str) and isinstance(v, str)
|
||||
}
|
||||
# zone_geometries — CLI empty → fill from file (dict[str, dict]).
|
||||
if not overrides_geoms:
|
||||
_file_geoms = _persisted.get("zone_geometries")
|
||||
if isinstance(_file_geoms, dict):
|
||||
_accepted: dict[str, dict] = {}
|
||||
for _zid, _g in _file_geoms.items():
|
||||
if (
|
||||
isinstance(_zid, str)
|
||||
and isinstance(_g, dict)
|
||||
and all(k in _g for k in ("x", "y", "w", "h"))
|
||||
):
|
||||
try:
|
||||
_accepted[_zid] = {
|
||||
"x": float(_g["x"]),
|
||||
"y": float(_g["y"]),
|
||||
"w": float(_g["w"]),
|
||||
"h": float(_g["h"]),
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
overrides_geoms = _accepted
|
||||
# zone_sections — CLI empty → fill from file (dict[str, list[str]]).
|
||||
if not overrides_section_assignments:
|
||||
_file_sections = _persisted.get("zone_sections")
|
||||
if isinstance(_file_sections, dict):
|
||||
_accepted_sec: dict[str, list[str]] = {}
|
||||
for _zid, _sec_list in _file_sections.items():
|
||||
if isinstance(_zid, str) and isinstance(_sec_list, list):
|
||||
_sids = [s for s in _sec_list if isinstance(s, str) and s]
|
||||
if _sids:
|
||||
_accepted_sec[_zid] = _sids
|
||||
overrides_section_assignments = _accepted_sec
|
||||
|
||||
run_phase_z2_mvp1(
|
||||
args.mdx_path,
|
||||
args.run_id,
|
||||
override_layout=args.override_layout,
|
||||
override_layout=_final_override_layout,
|
||||
override_frames=overrides_frames or None,
|
||||
override_zone_geometries=overrides_geoms or None,
|
||||
override_section_assignments=overrides_section_assignments or None,
|
||||
|
||||
162
src/user_overrides_io.py
Normal file
162
src/user_overrides_io.py
Normal 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
|
||||
229
tests/test_user_overrides_io.py
Normal file
229
tests/test_user_overrides_io.py
Normal 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)
|
||||
296
tests/test_user_overrides_pipeline_fallback.py
Normal file
296
tests/test_user_overrides_pipeline_fallback.py
Normal 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
|
||||
Reference in New Issue
Block a user