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) => ({
|
||||
...p,
|
||||
normalizedContent,
|
||||
// IMP-52 u6 — post-loadRun frame remap. persistedOverridesRef holds
|
||||
// the user_overrides.json read at handleFileUpload time. Frames there
|
||||
// are keyed by unit_id (section_ids joined by "+"); the in-memory
|
||||
// zone_frames is keyed by region.id. Remap against the new slidePlan
|
||||
// zones so SlideCanvas's override-vs-default preview indicator shows
|
||||
// the user's persisted choice without forcing them to re-click.
|
||||
const restoredZoneFrames = remapPersistedFramesToZoneFrames(
|
||||
slidePlan,
|
||||
userSelection: createInitialUserSelection(slidePlan),
|
||||
isLoading: false,
|
||||
}));
|
||||
persistedOverridesRef.current.frames as Record<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: {
|
||||
...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) => ({
|
||||
...p,
|
||||
userSelection: {
|
||||
...p.userSelection,
|
||||
overrides: {
|
||||
...p.userSelection.overrides,
|
||||
zone_geometries: {
|
||||
...p.userSelection.overrides.zone_geometries,
|
||||
...geometries
|
||||
}
|
||||
}
|
||||
setState((p) => {
|
||||
const mergedGeometries = {
|
||||
...p.userSelection.overrides.zone_geometries,
|
||||
...geometries,
|
||||
};
|
||||
// IMP-52 u7 — persist the merged zone_geometries snapshot. Resize
|
||||
// gestures fire repeatedly during a drag; the 300ms u5 debounce
|
||||
// collapses them into a single PUT at gesture-end, so we don't
|
||||
// need to gate on resize-finished here.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { zone_geometries: mergedGeometries });
|
||||
}
|
||||
}));
|
||||
return {
|
||||
...p,
|
||||
userSelection: {
|
||||
...p.userSelection,
|
||||
overrides: {
|
||||
...p.userSelection.overrides,
|
||||
zone_geometries: mergedGeometries,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
}, []);
|
||||
|
||||
@@ -486,10 +579,40 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: applyFrame(p.userSelection, region.id, frameId)
|
||||
}));
|
||||
setState((p) => {
|
||||
const newSelection = applyFrame(p.userSelection, region.id, frameId);
|
||||
// IMP-52 u7 — persist frames keyed by `unit_id`. The on-disk schema
|
||||
// uses `unit_id = zone.section_ids.join("+")` (the same convention
|
||||
// handleGenerate uses when forwarding `overrides.frames` to the
|
||||
// backend CLI). `zone_frames` is keyed by region.id, so we walk
|
||||
// the effectiveSlidePlan zones to translate. Only true user
|
||||
// overrides are persisted — `createInitialUserSelection` pre-fills
|
||||
// `zone_frames[region.id]` with `region.frame_match_strategy.frame_id`
|
||||
// (backend default) for every region, so we mirror handleGenerate's
|
||||
// `overrideFrameId !== defaultFrameId` gate to avoid leaking defaults
|
||||
// into user_overrides.json. Zones with no sections are skipped.
|
||||
if (p.uploadedFile && effectiveSlidePlan) {
|
||||
const framesByUnitId: Record<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();
|
||||
|
||||
Reference in New Issue
Block a user