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 슬라이드 빌더)
|
* Home - 메인 페이지 (Zone-Centric 슬라이드 빌더)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent";
|
import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
getSelectedRegion,
|
getSelectedRegion,
|
||||||
moveSectionToZone,
|
moveSectionToZone,
|
||||||
saveZoneSizes,
|
saveZoneSizes,
|
||||||
|
deriveUserOverridesKey,
|
||||||
|
applyPersistedNonFrameOverrides,
|
||||||
|
remapPersistedFramesToZoneFrames,
|
||||||
} from "../utils/slidePlanUtils";
|
} from "../utils/slidePlanUtils";
|
||||||
import {
|
import {
|
||||||
parseMdxFile,
|
parseMdxFile,
|
||||||
@@ -25,6 +28,12 @@ import {
|
|||||||
type RunMeta,
|
type RunMeta,
|
||||||
type PipelineOverrides,
|
type PipelineOverrides,
|
||||||
} from "../services/designAgentApi";
|
} from "../services/designAgentApi";
|
||||||
|
import {
|
||||||
|
flushUserOverrides,
|
||||||
|
getUserOverrides,
|
||||||
|
saveUserOverrides,
|
||||||
|
type UserOverrides,
|
||||||
|
} from "../services/userOverridesApi";
|
||||||
|
|
||||||
import LeftMdxPanel from "../components/LeftMdxPanel";
|
import LeftMdxPanel from "../components/LeftMdxPanel";
|
||||||
import SlideCanvas from "../components/SlideCanvas";
|
import SlideCanvas from "../components/SlideCanvas";
|
||||||
@@ -63,6 +72,14 @@ export default function Home() {
|
|||||||
// section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시).
|
// section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시).
|
||||||
const [pendingLayout, setPendingLayout] = useState<LayoutPresetId | null>(null);
|
const [pendingLayout, setPendingLayout] = useState<LayoutPresetId | null>(null);
|
||||||
|
|
||||||
|
// IMP-52 u6 — restore-on-reopen: persisted user_overrides.json fetched at
|
||||||
|
// handleFileUpload time. layout / zone_geometries / zone_sections are
|
||||||
|
// seeded into userSelection immediately (so handleGenerate forwards them
|
||||||
|
// as CLI args). frames are stashed here because their on-disk key
|
||||||
|
// (unit_id = section_ids joined by "+") only maps to region.id after
|
||||||
|
// loadRun rebuilds the slidePlan — see handleGenerate post-loadRun.
|
||||||
|
const persistedOverridesRef = useRef<Partial<UserOverrides>>({});
|
||||||
|
|
||||||
// pendingLayout 활성 시 effective slidePlan = pendingZones 가 swap 된 plan.
|
// pendingLayout 활성 시 effective slidePlan = pendingZones 가 swap 된 plan.
|
||||||
// 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect /
|
// 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect /
|
||||||
// getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용.
|
// getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용.
|
||||||
@@ -180,7 +197,19 @@ export default function Home() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await parseMdxFile(file);
|
const content = await parseMdxFile(file);
|
||||||
setState((p) => ({ ...p, normalizedContent: content, isLoading: false }));
|
// IMP-52 u6 — restore-on-reopen. Key = MDX stem (matches backend
|
||||||
|
// u2 fallback's Path(args.mdx_path).stem). getUserOverrides returns
|
||||||
|
// {} on miss / corrupt / network failure (u5 contract) so the upload
|
||||||
|
// path never fails on a fresh MDX.
|
||||||
|
const overridesKey = deriveUserOverridesKey(file.name);
|
||||||
|
const persisted = await getUserOverrides(overridesKey);
|
||||||
|
persistedOverridesRef.current = persisted;
|
||||||
|
setState((p) => ({
|
||||||
|
...p,
|
||||||
|
normalizedContent: content,
|
||||||
|
userSelection: applyPersistedNonFrameOverrides(p.userSelection, persisted),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`);
|
toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -257,9 +286,11 @@ export default function Home() {
|
|||||||
const overrides: PipelineOverrides = {};
|
const overrides: PipelineOverrides = {};
|
||||||
const sourcePlan = effectiveSlidePlan;
|
const sourcePlan = effectiveSlidePlan;
|
||||||
if (sourcePlan && state.slidePlan) {
|
if (sourcePlan && state.slidePlan) {
|
||||||
const defaultLayout = state.slidePlan.layout_preset;
|
// 2026-05-22 demo hot-fix — 이전 비교 가드 (default !== override) 제거.
|
||||||
|
// restore loop 이 default = override 로 sync 시 override 안 보내고 backend
|
||||||
|
// default fallback 발생. user 가 명시한 layout 이 있으면 무조건 보냄.
|
||||||
const overrideLayout = state.userSelection.overrides.layout_preset;
|
const overrideLayout = state.userSelection.overrides.layout_preset;
|
||||||
if (overrideLayout && overrideLayout !== defaultLayout) {
|
if (overrideLayout) {
|
||||||
overrides.layout = overrideLayout;
|
overrides.layout = overrideLayout;
|
||||||
}
|
}
|
||||||
const frames: Record<string, string> = {};
|
const frames: Record<string, string> = {};
|
||||||
@@ -302,12 +333,9 @@ export default function Home() {
|
|||||||
overrides.zoneGeometries = zoneGeometries;
|
overrides.zoneGeometries = zoneGeometries;
|
||||||
}
|
}
|
||||||
|
|
||||||
// IMP-08 B-3 : zoneSections forward only when the user diverged from
|
// 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀.
|
||||||
// the auto plan. Codex Stage 3 R3 B3 fix : `createInitialUserSelection`
|
// 시연 안정성 우선. section swap 은 별 path (수동 drag detection) 로 풀어야 함.
|
||||||
// seeds `zone_sections` with the default placement, so a literal copy
|
// 임시 over-aggressive fix 가 default flow 깨뜨려 PARTIAL_COVERAGE 발생했음.
|
||||||
// would pollute backend assignment-source provenance even on a fresh
|
|
||||||
// re-render. Diff against `sourcePlan.zones[].section_ids` per zone and
|
|
||||||
// only emit zones whose section list differs.
|
|
||||||
const userZoneSections = state.userSelection.overrides.zone_sections;
|
const userZoneSections = state.userSelection.overrides.zone_sections;
|
||||||
if (userZoneSections) {
|
if (userZoneSections) {
|
||||||
const defaultByZone = new Map<string, string[]>();
|
const defaultByZone = new Map<string, string[]>();
|
||||||
@@ -349,6 +377,12 @@ export default function Home() {
|
|||||||
toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`);
|
toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// IMP-52 u10 — Force-commit any pending debounced PUTs before backend
|
||||||
|
// reads user_overrides.json on pipeline entry. Without this, a user
|
||||||
|
// who changes an override (300ms debounce window) and immediately
|
||||||
|
// clicks Generate would race the PUT against /api/run; the u2
|
||||||
|
// fallback could then load a stale persisted document.
|
||||||
|
await flushUserOverrides();
|
||||||
const result = await runPipeline(state.uploadedFile, overrides);
|
const result = await runPipeline(state.uploadedFile, overrides);
|
||||||
|
|
||||||
if (!result.success || !result.final_html_exists) {
|
if (!result.success || !result.final_html_exists) {
|
||||||
@@ -362,13 +396,42 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id);
|
const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id);
|
||||||
setState((p) => ({
|
// IMP-52 u6 — post-loadRun frame remap. persistedOverridesRef holds
|
||||||
...p,
|
// the user_overrides.json read at handleFileUpload time. Frames there
|
||||||
normalizedContent,
|
// are keyed by unit_id (section_ids joined by "+"); the in-memory
|
||||||
|
// zone_frames is keyed by region.id. Remap against the new slidePlan
|
||||||
|
// zones so SlideCanvas's override-vs-default preview indicator shows
|
||||||
|
// the user's persisted choice without forcing them to re-click.
|
||||||
|
const restoredZoneFrames = remapPersistedFramesToZoneFrames(
|
||||||
slidePlan,
|
slidePlan,
|
||||||
userSelection: createInitialUserSelection(slidePlan),
|
persistedOverridesRef.current.frames as Record<string, string> | undefined,
|
||||||
isLoading: false,
|
);
|
||||||
}));
|
setState((p) => {
|
||||||
|
// IMP-52 u6 — restore-on-reopen: re-layer the persisted non-frame
|
||||||
|
// axes (layout / zone_geometries / zone_sections) onto the post-load
|
||||||
|
// `base`. `createInitialUserSelection` rebuilds from slidePlan and
|
||||||
|
// drops anything the backend fallback could not round-trip through
|
||||||
|
// a CLI arg — `zone_geometries` in particular has no slidePlan
|
||||||
|
// representation, so without this merge the user would see their
|
||||||
|
// resized zones revert on every Generate.
|
||||||
|
const base = applyPersistedNonFrameOverrides(
|
||||||
|
createInitialUserSelection(slidePlan),
|
||||||
|
persistedOverridesRef.current,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
normalizedContent,
|
||||||
|
slidePlan,
|
||||||
|
userSelection: {
|
||||||
|
...base,
|
||||||
|
overrides: {
|
||||||
|
...base.overrides,
|
||||||
|
zone_frames: { ...base.overrides.zone_frames, ...restoredZoneFrames },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
setRunMeta(runMeta);
|
setRunMeta(runMeta);
|
||||||
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
|
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
|
||||||
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
|
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
|
||||||
@@ -386,10 +449,21 @@ export default function Home() {
|
|||||||
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
|
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
|
||||||
setState((p) => {
|
setState((p) => {
|
||||||
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
|
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
|
||||||
return {
|
const finalSelection = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
|
||||||
...p,
|
// IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk
|
||||||
userSelection: selectZone(newSelection, zoneId) // 이동된 존 자동 선택
|
// schema axis (`zone_sections`) shares the in-memory shape (zone_id →
|
||||||
};
|
// section_ids), so we forward the full mutated value; the u4 PUT path
|
||||||
|
// replaces this axis atomically while preserving the foreign axes.
|
||||||
|
// p.uploadedFile gate skips persistence before any MDX is loaded —
|
||||||
|
// the demo-mode initial render path would otherwise PUT to the empty
|
||||||
|
// key. saveUserOverrides is debounced (300ms) and per-key coalesced.
|
||||||
|
if (p.uploadedFile) {
|
||||||
|
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||||
|
void saveUserOverrides(key, {
|
||||||
|
zone_sections: finalSelection.overrides.zone_sections,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ...p, userSelection: finalSelection };
|
||||||
});
|
});
|
||||||
setRightTab("frame");
|
setRightTab("frame");
|
||||||
setHasPendingChanges(true);
|
setHasPendingChanges(true);
|
||||||
@@ -415,10 +489,18 @@ export default function Home() {
|
|||||||
|
|
||||||
// ── Layout 선택 ──
|
// ── Layout 선택 ──
|
||||||
const handleLayoutSelect = useCallback((layoutId: string) => {
|
const handleLayoutSelect = useCallback((layoutId: string) => {
|
||||||
setState((p) => ({
|
setState((p) => {
|
||||||
...p,
|
const newSelection = applyLayout(p.userSelection, layoutId as LayoutPresetId);
|
||||||
userSelection: applyLayout(p.userSelection, layoutId as LayoutPresetId)
|
// IMP-52 u7 — persist the selected layout preset id. The on-disk
|
||||||
}));
|
// `layout` axis is a single string; `applyLayout` validates the
|
||||||
|
// preset id before mutating the selection, so the value here is
|
||||||
|
// already the LayoutPresetId we want to round-trip.
|
||||||
|
if (p.uploadedFile) {
|
||||||
|
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||||
|
void saveUserOverrides(key, { layout: layoutId });
|
||||||
|
}
|
||||||
|
return { ...p, userSelection: newSelection };
|
||||||
|
});
|
||||||
setHasPendingChanges(true);
|
setHasPendingChanges(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -431,19 +513,30 @@ export default function Home() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleZoneResize = useCallback((geometries: Record<string, { x: number; y: number; w: number; h: number }>) => {
|
const handleZoneResize = useCallback((geometries: Record<string, { x: number; y: number; w: number; h: number }>) => {
|
||||||
setState((p) => ({
|
setState((p) => {
|
||||||
...p,
|
const mergedGeometries = {
|
||||||
userSelection: {
|
...p.userSelection.overrides.zone_geometries,
|
||||||
...p.userSelection,
|
...geometries,
|
||||||
overrides: {
|
};
|
||||||
...p.userSelection.overrides,
|
// IMP-52 u7 — persist the merged zone_geometries snapshot. Resize
|
||||||
zone_geometries: {
|
// gestures fire repeatedly during a drag; the 300ms u5 debounce
|
||||||
...p.userSelection.overrides.zone_geometries,
|
// collapses them into a single PUT at gesture-end, so we don't
|
||||||
...geometries
|
// need to gate on resize-finished here.
|
||||||
}
|
if (p.uploadedFile) {
|
||||||
}
|
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||||
|
void saveUserOverrides(key, { zone_geometries: mergedGeometries });
|
||||||
}
|
}
|
||||||
}));
|
return {
|
||||||
|
...p,
|
||||||
|
userSelection: {
|
||||||
|
...p.userSelection,
|
||||||
|
overrides: {
|
||||||
|
...p.userSelection.overrides,
|
||||||
|
zone_geometries: mergedGeometries,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
setHasPendingChanges(true);
|
setHasPendingChanges(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -486,10 +579,40 @@ export default function Home() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState((p) => ({
|
setState((p) => {
|
||||||
...p,
|
const newSelection = applyFrame(p.userSelection, region.id, frameId);
|
||||||
userSelection: applyFrame(p.userSelection, region.id, frameId)
|
// IMP-52 u7 — persist frames keyed by `unit_id`. The on-disk schema
|
||||||
}));
|
// uses `unit_id = zone.section_ids.join("+")` (the same convention
|
||||||
|
// handleGenerate uses when forwarding `overrides.frames` to the
|
||||||
|
// backend CLI). `zone_frames` is keyed by region.id, so we walk
|
||||||
|
// the effectiveSlidePlan zones to translate. Only true user
|
||||||
|
// overrides are persisted — `createInitialUserSelection` pre-fills
|
||||||
|
// `zone_frames[region.id]` with `region.frame_match_strategy.frame_id`
|
||||||
|
// (backend default) for every region, so we mirror handleGenerate's
|
||||||
|
// `overrideFrameId !== defaultFrameId` gate to avoid leaking defaults
|
||||||
|
// into user_overrides.json. Zones with no sections are skipped.
|
||||||
|
if (p.uploadedFile && effectiveSlidePlan) {
|
||||||
|
const framesByUnitId: Record<string, string> = {};
|
||||||
|
for (const z of effectiveSlidePlan.zones) {
|
||||||
|
const r = z.internal_regions[0];
|
||||||
|
if (!r) continue;
|
||||||
|
if (!Array.isArray(z.section_ids) || z.section_ids.length === 0) continue;
|
||||||
|
const unitId = z.section_ids.join("+");
|
||||||
|
const overrideId = newSelection.overrides.zone_frames?.[r.id];
|
||||||
|
const defaultFrameId = r.frame_match_strategy.frame_id;
|
||||||
|
if (
|
||||||
|
typeof overrideId === "string" &&
|
||||||
|
overrideId.length > 0 &&
|
||||||
|
overrideId !== defaultFrameId
|
||||||
|
) {
|
||||||
|
framesByUnitId[unitId] = overrideId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||||
|
void saveUserOverrides(key, { frames: framesByUnitId });
|
||||||
|
}
|
||||||
|
return { ...p, userSelection: newSelection };
|
||||||
|
});
|
||||||
setHasPendingChanges(true);
|
setHasPendingChanges(true);
|
||||||
}, [effectiveSlidePlan, state.userSelection]);
|
}, [effectiveSlidePlan, state.userSelection]);
|
||||||
|
|
||||||
|
|||||||
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 { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
|
||||||
|
import type { UserOverrides } from "../services/userOverridesApi";
|
||||||
|
|
||||||
|
// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ────
|
||||||
|
// These helpers compose persisted `user_overrides.json` payloads (typed by
|
||||||
|
// the u5 service) onto the in-memory `UserSelection`. They live here rather
|
||||||
|
// than inline in Home.tsx so vitest can drive them in a node environment
|
||||||
|
// without booting React or pulling in the radix-ui / lucide UI deps that
|
||||||
|
// Home.tsx requires. Home.tsx wires these into:
|
||||||
|
// • handleFileUpload (pre-Generate layout / zone_geometries / zone_sections
|
||||||
|
// seed so handleGenerate's CLI-args build picks them up)
|
||||||
|
// • handleGenerate post-loadRun (frame remap unit_id → region.id over the
|
||||||
|
// freshly built slidePlan)
|
||||||
|
// The on-disk schema and clear-sentinel semantics are owned by:
|
||||||
|
// • src/user_overrides_io.py (KNOWN_AXES, u1)
|
||||||
|
// • Front/vite.config.ts mergeUserOverrides (u4)
|
||||||
|
// • Front/client/src/services/userOverridesApi.ts (UserOverrides type, u5)
|
||||||
|
// Any KNOWN_AXES drift must land in those files first.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the `/api/user-overrides/:key` MDX-stem key from a filename.
|
||||||
|
* Strips a trailing `.mdx` (case-insensitive). The key matches the Python
|
||||||
|
* `Path(args.mdx_path).stem` derivation used by the backend fallback (u2),
|
||||||
|
* so the same persisted file is read from both ends without translation.
|
||||||
|
*/
|
||||||
|
export function deriveUserOverridesKey(filename: string): string {
|
||||||
|
return filename.replace(/\.mdx$/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYOUT_PRESET_IDS = new Set<string>([
|
||||||
|
"single",
|
||||||
|
"horizontal-2",
|
||||||
|
"vertical-2",
|
||||||
|
"top-1-bottom-2",
|
||||||
|
"top-2-bottom-1",
|
||||||
|
"left-1-right-2",
|
||||||
|
"left-2-right-1",
|
||||||
|
"grid-2x2",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer the three non-frame axes from a persisted `user_overrides.json`
|
||||||
|
* payload onto an existing `UserSelection`. Foreign / unrecognized payload
|
||||||
|
* shapes are silently ignored — the u5 GET path already returns `{}` on
|
||||||
|
* corrupt files, but we revalidate here so hand-edited files or future
|
||||||
|
* forward-compat axes cannot poison the in-memory state.
|
||||||
|
*
|
||||||
|
* Frames are NOT layered here because the on-disk key (`unit_id` =
|
||||||
|
* section_ids joined by `+`) only resolves after the slidePlan zones are
|
||||||
|
* known. Use `remapPersistedFramesToZoneFrames` in the post-loadRun step.
|
||||||
|
*/
|
||||||
|
export function applyPersistedNonFrameOverrides(
|
||||||
|
selection: UserSelection,
|
||||||
|
persisted: Partial<UserOverrides> | null | undefined,
|
||||||
|
): UserSelection {
|
||||||
|
if (!persisted || typeof persisted !== "object") return selection;
|
||||||
|
const next = { ...selection.overrides };
|
||||||
|
if (typeof persisted.layout === "string" && LAYOUT_PRESET_IDS.has(persisted.layout)) {
|
||||||
|
next.layout_preset = persisted.layout as LayoutPresetId;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
persisted.zone_geometries &&
|
||||||
|
typeof persisted.zone_geometries === "object" &&
|
||||||
|
!Array.isArray(persisted.zone_geometries)
|
||||||
|
) {
|
||||||
|
next.zone_geometries = { ...persisted.zone_geometries };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
persisted.zone_sections &&
|
||||||
|
typeof persisted.zone_sections === "object" &&
|
||||||
|
!Array.isArray(persisted.zone_sections)
|
||||||
|
) {
|
||||||
|
next.zone_sections = { ...persisted.zone_sections };
|
||||||
|
}
|
||||||
|
return { ...selection, overrides: next };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remap persisted frames (`unit_id` → template_id) to the in-memory
|
||||||
|
* `zone_frames` (region.id → template_id) using the freshly built
|
||||||
|
* slidePlan zones. `unit_id` follows handleGenerate's convention:
|
||||||
|
* `zone.section_ids.join("+")`. Persisted entries whose unit_id no longer
|
||||||
|
* matches any zone (e.g. user changed zone_sections between sessions) are
|
||||||
|
* silently dropped.
|
||||||
|
*/
|
||||||
|
export function remapPersistedFramesToZoneFrames(
|
||||||
|
slidePlan: SlidePlan | null | undefined,
|
||||||
|
framesByUnitId: Record<string, string> | null | undefined,
|
||||||
|
): Record<string, string> {
|
||||||
|
if (!slidePlan || !framesByUnitId || typeof framesByUnitId !== "object") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const zone of slidePlan.zones) {
|
||||||
|
const region = zone.internal_regions[0];
|
||||||
|
if (!region) continue;
|
||||||
|
if (!Array.isArray(zone.section_ids) || zone.section_ids.length === 0) continue;
|
||||||
|
const unitId = zone.section_ids.join("+");
|
||||||
|
const templateId = framesByUnitId[unitId];
|
||||||
|
if (typeof templateId === "string" && templateId.length > 0) {
|
||||||
|
out[region.id] = templateId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase Z 초기 선택 상태 생성
|
* Phase Z 초기 선택 상태 생성
|
||||||
|
|||||||
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 업로드 → 파이프라인 실행 → 결과 노출
|
// Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출
|
||||||
//
|
//
|
||||||
// Endpoints (vite dev middleware) :
|
// Endpoints (vite dev middleware) :
|
||||||
// POST /api/run multipart/JSON body {filename, content} → run_id
|
// POST /api/run multipart/JSON body {filename, content} → run_id
|
||||||
// GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path}
|
// GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path}
|
||||||
|
// GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3)
|
||||||
|
// PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4)
|
||||||
//
|
//
|
||||||
// 환경 변수 (선택) :
|
// 환경 변수 (선택) :
|
||||||
// DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent
|
// DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent
|
||||||
@@ -464,6 +759,19 @@ function vitePluginPhaseZApi(): Plugin {
|
|||||||
fs.createReadStream(previewPath).pipe(res);
|
fs.createReadStream(previewPath).pipe(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── GET / PUT /api/user-overrides/{key} → data/user_overrides/{key}.json ──
|
||||||
|
// IMP-52 u3 (GET) + u4 (PUT) — MDX-stem keyed user overrides. Logic
|
||||||
|
// lives in the pure helpers (handleGetUserOverrides / handlePutUserOverrides)
|
||||||
|
// so vitest can exercise them without booting vite. Both handlers
|
||||||
|
// return false when the HTTP method does not match, so they chain
|
||||||
|
// cleanly: GET first, then PUT, then next() for everything else
|
||||||
|
// (e.g., OPTIONS / preflight handled by upstream middleware).
|
||||||
|
server.middlewares.use("/api/user-overrides", (req, res, next) => {
|
||||||
|
if (handleGetUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
|
||||||
|
if (handlePutUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ──
|
// ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ──
|
||||||
server.middlewares.use("/data/runs", (req, res, next) => {
|
server.middlewares.use("/data/runs", (req, res, next) => {
|
||||||
if (req.method !== "GET") return next();
|
if (req.method !== "GET") return next();
|
||||||
|
|||||||
@@ -1689,6 +1689,14 @@ def _override_to_grid_tracks(
|
|||||||
R = len(rows_grid)
|
R = len(rows_grid)
|
||||||
C = len(rows_grid[0])
|
C = len(rows_grid[0])
|
||||||
|
|
||||||
|
# Hot-fix (2026-05-22): partial override 버그 fix — override 없는 track 은
|
||||||
|
# default 비율로 fallback. 이전엔 0 반환 → normalize 후 다른 track 이 모든 공간 흡수.
|
||||||
|
_default_result = _build_grid_dynamic_2d(preset, zones_data, gap=gap)
|
||||||
|
_default_widths = _default_result.get("widths_px", []) or []
|
||||||
|
_default_heights = _default_result.get("heights_px", []) or []
|
||||||
|
_sum_w = sum(_default_widths) if _default_widths else 1.0
|
||||||
|
_sum_h = sum(_default_heights) if _default_heights else 1.0
|
||||||
|
|
||||||
occupancy: list[tuple[dict, set[int], set[int]]] = []
|
occupancy: list[tuple[dict, set[int], set[int]]] = []
|
||||||
for z in zones_data:
|
for z in zones_data:
|
||||||
pos = z["position"]
|
pos = z["position"]
|
||||||
@@ -1703,17 +1711,19 @@ def _override_to_grid_tracks(
|
|||||||
single = [z for z, rr, _cc in occupancy if rr == {idx}]
|
single = [z for z, rr, _cc in occupancy if rr == {idx}]
|
||||||
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
|
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
|
||||||
key = "h"
|
key = "h"
|
||||||
|
_fallback = (_default_heights[idx] / _sum_h) if idx < len(_default_heights) and _sum_h else (1.0 / R)
|
||||||
else:
|
else:
|
||||||
single = [z for z, _rr, cc in occupancy if cc == {idx}]
|
single = [z for z, _rr, cc in occupancy if cc == {idx}]
|
||||||
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
|
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
|
||||||
key = "w"
|
key = "w"
|
||||||
|
_fallback = (_default_widths[idx] / _sum_w) if idx < len(_default_widths) and _sum_w else (1.0 / C)
|
||||||
candidates = single or allspan
|
candidates = single or allspan
|
||||||
vals = [
|
vals = [
|
||||||
float(override_zone_geometries[z["position"]][key])
|
float(override_zone_geometries[z["position"]][key])
|
||||||
for z in candidates
|
for z in candidates
|
||||||
if z["position"] in override_zone_geometries
|
if z["position"] in override_zone_geometries
|
||||||
]
|
]
|
||||||
return max(vals) if vals else 0.0
|
return max(vals) if vals else _fallback
|
||||||
|
|
||||||
row_values = [_track_value(r, "row") for r in range(R)]
|
row_values = [_track_value(r, "row") for r in range(R)]
|
||||||
col_values = [_track_value(c, "col") for c in range(C)]
|
col_values = [_track_value(c, "col") for c in range(C)]
|
||||||
@@ -1793,10 +1803,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
|||||||
if override_zone_geometries:
|
if override_zone_geometries:
|
||||||
if layout_preset == "horizontal-2":
|
if layout_preset == "horizontal-2":
|
||||||
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
|
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
|
||||||
|
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
|
||||||
|
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
|
||||||
|
overridden_h = sum(
|
||||||
|
float(override_zone_geometries[p]["h"])
|
||||||
|
for p in positions if p in override_zone_geometries
|
||||||
|
)
|
||||||
|
non_overridden = [p for p in positions if p not in override_zone_geometries]
|
||||||
|
per_non = max(0.0, 1.0 - overridden_h) / max(len(non_overridden), 1)
|
||||||
ratios = []
|
ratios = []
|
||||||
for pos in positions:
|
for pos in positions:
|
||||||
geom = override_zone_geometries.get(pos)
|
geom = override_zone_geometries.get(pos)
|
||||||
ratios.append(float(geom["h"]) if geom else 0.0)
|
ratios.append(float(geom["h"]) if geom else per_non)
|
||||||
total = sum(ratios)
|
total = sum(ratios)
|
||||||
if total > 0:
|
if total > 0:
|
||||||
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios]
|
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios]
|
||||||
@@ -1818,10 +1836,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
|||||||
# cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
|
# cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
|
||||||
# PR 1 keeps fr-string cols for legacy preserve; widths_px is
|
# PR 1 keeps fr-string cols for legacy preserve; widths_px is
|
||||||
# populated in pixels for _compute_per_zone_geometry length contract.
|
# populated in pixels for _compute_per_zone_geometry length contract.
|
||||||
|
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
|
||||||
|
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
|
||||||
|
overridden_w = sum(
|
||||||
|
float(override_zone_geometries[p]["w"])
|
||||||
|
for p in positions if p in override_zone_geometries
|
||||||
|
)
|
||||||
|
non_overridden = [p for p in positions if p not in override_zone_geometries]
|
||||||
|
per_non = max(0.0, 1.0 - overridden_w) / max(len(non_overridden), 1)
|
||||||
ratios = []
|
ratios = []
|
||||||
for pos in positions:
|
for pos in positions:
|
||||||
geom = override_zone_geometries.get(pos)
|
geom = override_zone_geometries.get(pos)
|
||||||
ratios.append(float(geom["w"]) if geom else 0.0)
|
ratios.append(float(geom["w"]) if geom else per_non)
|
||||||
total = sum(ratios)
|
total = sum(ratios)
|
||||||
if total > 0:
|
if total > 0:
|
||||||
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
|
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
|
||||||
@@ -5917,10 +5943,81 @@ if __name__ == "__main__":
|
|||||||
_seen_sections_across_zones[sid] = zid
|
_seen_sections_across_zones[sid] = zid
|
||||||
overrides_section_assignments[zid] = section_ids
|
overrides_section_assignments[zid] = section_ids
|
||||||
|
|
||||||
|
# IMP-52 (#80) u2 — user_overrides.json persistence fallback.
|
||||||
|
# After argparse fully parses CLI flags, fill ONLY the axes the user
|
||||||
|
# did NOT pass on the command line. CLI payload always wins over the
|
||||||
|
# persisted file (Stage 2 lock: "CLI > file, 결손 축만 채움").
|
||||||
|
# MDX stem keys the persistence file; invalid stems / corrupt file
|
||||||
|
# degrade gracefully (warning to stderr + no override injected).
|
||||||
|
from src.user_overrides_io import (
|
||||||
|
InvalidOverrideKey,
|
||||||
|
load as _load_user_overrides,
|
||||||
|
validate_key as _validate_overrides_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
_final_override_layout = args.override_layout
|
||||||
|
try:
|
||||||
|
_ov_key = _validate_overrides_key(Path(args.mdx_path).stem)
|
||||||
|
except InvalidOverrideKey as _exc:
|
||||||
|
print(
|
||||||
|
f"[user_overrides] warning: cannot derive persistence key from "
|
||||||
|
f"mdx_path {args.mdx_path!r}: {_exc}; skipping fallback.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
_ov_key = None
|
||||||
|
if _ov_key is not None:
|
||||||
|
_persisted = _load_user_overrides(_ov_key)
|
||||||
|
# layout — CLI None → fill from file (must be str).
|
||||||
|
if _final_override_layout is None:
|
||||||
|
_file_layout = _persisted.get("layout")
|
||||||
|
if isinstance(_file_layout, str) and _file_layout:
|
||||||
|
_final_override_layout = _file_layout
|
||||||
|
# frames — CLI empty → fill from file (must be dict[str, str]).
|
||||||
|
if not overrides_frames:
|
||||||
|
_file_frames = _persisted.get("frames")
|
||||||
|
if isinstance(_file_frames, dict):
|
||||||
|
overrides_frames = {
|
||||||
|
str(k): str(v)
|
||||||
|
for k, v in _file_frames.items()
|
||||||
|
if isinstance(k, str) and isinstance(v, str)
|
||||||
|
}
|
||||||
|
# zone_geometries — CLI empty → fill from file (dict[str, dict]).
|
||||||
|
if not overrides_geoms:
|
||||||
|
_file_geoms = _persisted.get("zone_geometries")
|
||||||
|
if isinstance(_file_geoms, dict):
|
||||||
|
_accepted: dict[str, dict] = {}
|
||||||
|
for _zid, _g in _file_geoms.items():
|
||||||
|
if (
|
||||||
|
isinstance(_zid, str)
|
||||||
|
and isinstance(_g, dict)
|
||||||
|
and all(k in _g for k in ("x", "y", "w", "h"))
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
_accepted[_zid] = {
|
||||||
|
"x": float(_g["x"]),
|
||||||
|
"y": float(_g["y"]),
|
||||||
|
"w": float(_g["w"]),
|
||||||
|
"h": float(_g["h"]),
|
||||||
|
}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
overrides_geoms = _accepted
|
||||||
|
# zone_sections — CLI empty → fill from file (dict[str, list[str]]).
|
||||||
|
if not overrides_section_assignments:
|
||||||
|
_file_sections = _persisted.get("zone_sections")
|
||||||
|
if isinstance(_file_sections, dict):
|
||||||
|
_accepted_sec: dict[str, list[str]] = {}
|
||||||
|
for _zid, _sec_list in _file_sections.items():
|
||||||
|
if isinstance(_zid, str) and isinstance(_sec_list, list):
|
||||||
|
_sids = [s for s in _sec_list if isinstance(s, str) and s]
|
||||||
|
if _sids:
|
||||||
|
_accepted_sec[_zid] = _sids
|
||||||
|
overrides_section_assignments = _accepted_sec
|
||||||
|
|
||||||
run_phase_z2_mvp1(
|
run_phase_z2_mvp1(
|
||||||
args.mdx_path,
|
args.mdx_path,
|
||||||
args.run_id,
|
args.run_id,
|
||||||
override_layout=args.override_layout,
|
override_layout=_final_override_layout,
|
||||||
override_frames=overrides_frames or None,
|
override_frames=overrides_frames or None,
|
||||||
override_zone_geometries=overrides_geoms or None,
|
override_zone_geometries=overrides_geoms or None,
|
||||||
override_section_assignments=overrides_section_assignments or None,
|
override_section_assignments=overrides_section_assignments or None,
|
||||||
|
|||||||
162
src/user_overrides_io.py
Normal file
162
src/user_overrides_io.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""IMP-52 (#80) u1 — user_overrides.json persistence layer (backend IO).
|
||||||
|
|
||||||
|
Persists the four CLI-wired override axes per MDX so a subsequent render
|
||||||
|
auto-restores user choices without re-clicking. Source of truth = MDX-keyed
|
||||||
|
file (stem of the MDX path), NOT ``data/runs/<run_id>/`` which mints a fresh
|
||||||
|
run_id per ``/api/run`` invocation.
|
||||||
|
|
||||||
|
Schema (4 axes; stable order):
|
||||||
|
|
||||||
|
{
|
||||||
|
"layout": <string|null>,
|
||||||
|
"zone_geometries": {<zone_id>: {"x": float, "y": float, "w": float, "h": float}},
|
||||||
|
"zone_sections": {<zone_id>: [<section_id>, ...]},
|
||||||
|
"frames": {<unit_id>: <template_id>}
|
||||||
|
}
|
||||||
|
|
||||||
|
``unit_id`` is the convention already used by ``--override-frame`` :
|
||||||
|
``"+".join(source_section_ids)`` (e.g., ``"03-1"`` or ``"03-1+03-2"``).
|
||||||
|
|
||||||
|
Behavior :
|
||||||
|
- ``load(key)`` — file missing or corrupt → ``{}`` (warning to stderr on corrupt).
|
||||||
|
- ``save(key, partial)`` — merges only the supplied axes onto the existing
|
||||||
|
file, preserving (a) unknown top-level keys (foreign-key preserve) and
|
||||||
|
(b) axes not present in the partial payload. Atomic write via tmp+rename.
|
||||||
|
- ``override_path(key, root=None)`` — resolves the persistence path under
|
||||||
|
``data/user_overrides/<key>.json``.
|
||||||
|
|
||||||
|
Guardrails (refs : ``user_overrides_io`` Stage 2 lock) :
|
||||||
|
- Deterministic code, no AI fallback.
|
||||||
|
- ``key`` validation rejects path traversal / separators / dot-prefix.
|
||||||
|
- ``save`` is a deep-shallow merge — per-axis dict mutation does not delete
|
||||||
|
prior keys unless caller passes ``None`` for that axis (explicit clear).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
# Persistence root — MDX-keyed, decoupled from data/runs/<run_id>/.
|
||||||
|
# Resolved at call time so tests can monkeypatch via ``root=`` parameter.
|
||||||
|
_PKG_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
|
||||||
|
|
||||||
|
# The four in-scope axes. Any other top-level key in the file is preserved
|
||||||
|
# but ignored by callers — keeps the file forward-compatible with future
|
||||||
|
# axes (e.g., zone_sizes, image_overrides) without a schema bump here.
|
||||||
|
KNOWN_AXES: tuple[str, ...] = ("layout", "zone_geometries", "zone_sections", "frames")
|
||||||
|
|
||||||
|
# Key validation — MDX stem must be safe for filesystem use. Allow
|
||||||
|
# alphanumerics, underscore, hyphen, and dot in the middle (sample stems
|
||||||
|
# are e.g. ``01``, ``03``, ``03__DX...``). Reject leading dot, path
|
||||||
|
# separators, and traversal.
|
||||||
|
_KEY_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidOverrideKey(ValueError):
|
||||||
|
"""Raised when ``key`` is not a safe MDX stem."""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_key(key: str) -> str:
|
||||||
|
"""Validate that ``key`` is a safe MDX stem; return it unchanged.
|
||||||
|
|
||||||
|
Rejects empty strings, path separators (``/`` ``\\``), traversal
|
||||||
|
(``..``), and leading dot. Callers should pass ``Path(mdx_path).stem``.
|
||||||
|
"""
|
||||||
|
if not isinstance(key, str) or not key:
|
||||||
|
raise InvalidOverrideKey(f"key must be a non-empty string, got: {key!r}")
|
||||||
|
if not _KEY_RE.match(key):
|
||||||
|
raise InvalidOverrideKey(
|
||||||
|
f"key must match {_KEY_RE.pattern!r} (alphanumerics, '_', '-', '.'; "
|
||||||
|
f"no leading dot, no separators); got: {key!r}"
|
||||||
|
)
|
||||||
|
if ".." in key:
|
||||||
|
raise InvalidOverrideKey(f"key must not contain '..'; got: {key!r}")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def override_path(key: str, root: Optional[Path] = None) -> Path:
|
||||||
|
"""Resolve the on-disk path for ``key``'s override file."""
|
||||||
|
validate_key(key)
|
||||||
|
base = Path(root) if root is not None else DEFAULT_OVERRIDES_ROOT
|
||||||
|
return base / f"{key}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load(key: str, root: Optional[Path] = None) -> dict[str, Any]:
|
||||||
|
"""Load persisted overrides for ``key``.
|
||||||
|
|
||||||
|
Missing file → ``{}``. Corrupt JSON → warning to stderr + ``{}``.
|
||||||
|
Returns the raw mapping (including any foreign keys); callers should
|
||||||
|
pick the four KNOWN_AXES they care about.
|
||||||
|
"""
|
||||||
|
path = override_path(key, root=root)
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
print(
|
||||||
|
f"[user_overrides_io] warning: failed to read {path} ({exc}); "
|
||||||
|
f"treating as empty.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
print(
|
||||||
|
f"[user_overrides_io] warning: {path} is not a JSON object "
|
||||||
|
f"(got {type(data).__name__}); treating as empty.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def save(key: str, partial: dict[str, Any], root: Optional[Path] = None) -> Path:
|
||||||
|
"""Merge ``partial`` onto the persisted overrides for ``key`` and write atomically.
|
||||||
|
|
||||||
|
Merge semantics :
|
||||||
|
- Only keys present in ``partial`` are mutated. Other axes (including
|
||||||
|
foreign keys outside KNOWN_AXES) are preserved verbatim.
|
||||||
|
- For each axis present in ``partial``, the new value REPLACES the prior
|
||||||
|
value (no per-zone deep-merge). Callers that want to add a single
|
||||||
|
zone must read → mutate → save with the full updated axis dict.
|
||||||
|
- Pass ``None`` for an axis to clear it (remove the key from the file).
|
||||||
|
"""
|
||||||
|
if not isinstance(partial, dict):
|
||||||
|
raise TypeError(
|
||||||
|
f"partial must be a dict, got {type(partial).__name__}: {partial!r}"
|
||||||
|
)
|
||||||
|
path = override_path(key, root=root)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
current = load(key, root=root)
|
||||||
|
for axis_key, axis_value in partial.items():
|
||||||
|
if axis_value is None:
|
||||||
|
current.pop(axis_key, None)
|
||||||
|
else:
|
||||||
|
current[axis_key] = axis_value
|
||||||
|
_atomic_write_json(path, current)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
|
||||||
|
"""Write ``data`` to ``path`` atomically via tmp file + os.replace."""
|
||||||
|
fd, tmp_name = tempfile.mkstemp(
|
||||||
|
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
f.write("\n")
|
||||||
|
os.replace(tmp_name, path)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_name)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
229
tests/test_user_overrides_io.py
Normal file
229
tests/test_user_overrides_io.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"""IMP-52 (#80) u8 — backend tests for ``src.user_overrides_io``.
|
||||||
|
|
||||||
|
Covers the four axes called out in the Stage 2 plan:
|
||||||
|
|
||||||
|
1. Round-trip ``save`` → ``load`` (4 KNOWN_AXES + foreign top-level keys).
|
||||||
|
2. Unknown-key passthrough (foreign axes preserved across partial merges).
|
||||||
|
3. Missing / corrupt / non-object behavior (graceful ``{}`` + stderr warning).
|
||||||
|
4. Invalid keys (``InvalidOverrideKey`` raised on traversal / separators /
|
||||||
|
leading dot / empty).
|
||||||
|
|
||||||
|
All tests inject ``root=tmp_path`` so they never touch the real
|
||||||
|
``data/user_overrides/`` directory.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.user_overrides_io import (
|
||||||
|
DEFAULT_OVERRIDES_ROOT,
|
||||||
|
InvalidOverrideKey,
|
||||||
|
KNOWN_AXES,
|
||||||
|
load,
|
||||||
|
override_path,
|
||||||
|
save,
|
||||||
|
validate_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- key validation ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_key_accepts_typical_mdx_stems():
|
||||||
|
for key in ("01", "03", "03__DX_master", "sample.v2", "a-b_c.1"):
|
||||||
|
assert validate_key(key) == key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad_key",
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"..",
|
||||||
|
"../escape",
|
||||||
|
"sub/dir",
|
||||||
|
"sub\\dir",
|
||||||
|
".hidden",
|
||||||
|
"-leading-dash",
|
||||||
|
".",
|
||||||
|
"name with space",
|
||||||
|
"name?",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_key_rejects_unsafe(bad_key):
|
||||||
|
with pytest.raises(InvalidOverrideKey):
|
||||||
|
validate_key(bad_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_key_rejects_non_string():
|
||||||
|
for bad in (None, 123, b"bytes", ["list"], {"d": 1}):
|
||||||
|
with pytest.raises(InvalidOverrideKey):
|
||||||
|
validate_key(bad) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# -- override_path ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_path_uses_default_root_when_unspecified():
|
||||||
|
p = override_path("sample")
|
||||||
|
assert p.parent == DEFAULT_OVERRIDES_ROOT
|
||||||
|
assert p.name == "sample.json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_path_honors_explicit_root(tmp_path):
|
||||||
|
p = override_path("sample", root=tmp_path)
|
||||||
|
assert p == tmp_path / "sample.json"
|
||||||
|
|
||||||
|
|
||||||
|
# -- load: missing / corrupt / non-object -----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_missing_file_returns_empty_dict(tmp_path):
|
||||||
|
assert load("nope", root=tmp_path) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_corrupt_json_warns_and_returns_empty(tmp_path, capsys):
|
||||||
|
path = override_path("corrupt", root=tmp_path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("{ this is not valid json", encoding="utf-8")
|
||||||
|
result = load("corrupt", root=tmp_path)
|
||||||
|
assert result == {}
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "failed to read" in captured.err
|
||||||
|
assert str(path) in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_non_object_json_warns_and_returns_empty(tmp_path, capsys):
|
||||||
|
path = override_path("list_root", root=tmp_path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("[1, 2, 3]", encoding="utf-8")
|
||||||
|
result = load("list_root", root=tmp_path)
|
||||||
|
assert result == {}
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "not a JSON object" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
# -- save: round-trip + partial-merge + foreign-key preserve ----------------
|
||||||
|
|
||||||
|
|
||||||
|
def _full_payload() -> dict:
|
||||||
|
return {
|
||||||
|
"layout": "sidebar-right",
|
||||||
|
"zone_geometries": {
|
||||||
|
"zone-top": {"x": 40.0, "y": 50.0, "w": 1200.0, "h": 120.0},
|
||||||
|
},
|
||||||
|
"zone_sections": {"zone-top": ["03-1", "03-2"]},
|
||||||
|
"frames": {"03-1+03-2": "frame_two_way_compare"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_then_load_round_trip(tmp_path):
|
||||||
|
key = "03"
|
||||||
|
payload = _full_payload()
|
||||||
|
written = save(key, payload, root=tmp_path)
|
||||||
|
assert written.exists()
|
||||||
|
assert written == tmp_path / "03.json"
|
||||||
|
|
||||||
|
loaded = load(key, root=tmp_path)
|
||||||
|
for axis in KNOWN_AXES:
|
||||||
|
assert loaded[axis] == payload[axis], f"axis {axis!r} did not round-trip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_partial_payload_preserves_other_axes(tmp_path):
|
||||||
|
key = "03"
|
||||||
|
save(key, _full_payload(), root=tmp_path)
|
||||||
|
|
||||||
|
save(key, {"layout": "two-column"}, root=tmp_path)
|
||||||
|
loaded = load(key, root=tmp_path)
|
||||||
|
|
||||||
|
assert loaded["layout"] == "two-column"
|
||||||
|
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
|
||||||
|
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
|
||||||
|
assert loaded["frames"] == _full_payload()["frames"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_axis_replaces_not_deep_merges(tmp_path):
|
||||||
|
key = "03"
|
||||||
|
save(key, {"frames": {"03-1": "frame_a", "03-2": "frame_b"}}, root=tmp_path)
|
||||||
|
save(key, {"frames": {"03-3": "frame_c"}}, root=tmp_path)
|
||||||
|
loaded = load(key, root=tmp_path)
|
||||||
|
assert loaded["frames"] == {"03-3": "frame_c"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_none_clears_axis(tmp_path):
|
||||||
|
key = "03"
|
||||||
|
save(key, _full_payload(), root=tmp_path)
|
||||||
|
save(key, {"layout": None}, root=tmp_path)
|
||||||
|
loaded = load(key, root=tmp_path)
|
||||||
|
assert "layout" not in loaded
|
||||||
|
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
|
||||||
|
assert loaded["frames"] == _full_payload()["frames"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_preserves_foreign_top_level_keys(tmp_path):
|
||||||
|
"""Forward-compat: axes outside KNOWN_AXES (zone_sizes, image_overrides,
|
||||||
|
schema_version, ...) must survive a partial merge on a known axis."""
|
||||||
|
key = "03"
|
||||||
|
path = override_path(key, root=tmp_path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pre_seed = {
|
||||||
|
"layout": "single-column",
|
||||||
|
"image_overrides": {"img-1": {"position": "right", "size": "small"}},
|
||||||
|
"zone_sizes": {"zone-top": "tall"},
|
||||||
|
"schema_version": "experimental-1",
|
||||||
|
}
|
||||||
|
path.write_text(json.dumps(pre_seed), encoding="utf-8")
|
||||||
|
|
||||||
|
save(key, {"layout": "sidebar-right"}, root=tmp_path)
|
||||||
|
|
||||||
|
loaded = load(key, root=tmp_path)
|
||||||
|
assert loaded["layout"] == "sidebar-right"
|
||||||
|
assert loaded["image_overrides"] == pre_seed["image_overrides"]
|
||||||
|
assert loaded["zone_sizes"] == pre_seed["zone_sizes"]
|
||||||
|
assert loaded["schema_version"] == pre_seed["schema_version"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_creates_parent_directory(tmp_path):
|
||||||
|
nested = tmp_path / "deep" / "nest"
|
||||||
|
assert not nested.exists()
|
||||||
|
save("03", {"layout": "two-column"}, root=nested)
|
||||||
|
assert (nested / "03.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_writes_pretty_sorted_json_for_diffability(tmp_path):
|
||||||
|
key = "03"
|
||||||
|
save(key, _full_payload(), root=tmp_path)
|
||||||
|
raw = (tmp_path / "03.json").read_text(encoding="utf-8")
|
||||||
|
# sort_keys=True → KNOWN_AXES come out alphabetically
|
||||||
|
pos_frames = raw.index('"frames"')
|
||||||
|
pos_layout = raw.index('"layout"')
|
||||||
|
pos_zg = raw.index('"zone_geometries"')
|
||||||
|
pos_zs = raw.index('"zone_sections"')
|
||||||
|
assert pos_frames < pos_layout < pos_zg < pos_zs
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_leaves_no_tmp_file_on_success(tmp_path):
|
||||||
|
save("03", _full_payload(), root=tmp_path)
|
||||||
|
leftovers = [p for p in tmp_path.iterdir() if p.name != "03.json"]
|
||||||
|
assert leftovers == [], f"tmp files leaked: {leftovers!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_rejects_non_dict_partial(tmp_path):
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
save("03", ["not", "a", "dict"], root=tmp_path) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# -- save / load propagate key validation -----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_key", ["", "..", "sub/dir", ".hidden"])
|
||||||
|
def test_save_rejects_invalid_key(tmp_path, bad_key):
|
||||||
|
with pytest.raises(InvalidOverrideKey):
|
||||||
|
save(bad_key, {"layout": "two-column"}, root=tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_key", ["", "..", "sub/dir", ".hidden"])
|
||||||
|
def test_load_rejects_invalid_key(tmp_path, bad_key):
|
||||||
|
with pytest.raises(InvalidOverrideKey):
|
||||||
|
load(bad_key, root=tmp_path)
|
||||||
296
tests/test_user_overrides_pipeline_fallback.py
Normal file
296
tests/test_user_overrides_pipeline_fallback.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
"""IMP-52 (#80) u9 — backend tests for the argparse persistence fallback.
|
||||||
|
|
||||||
|
Stage 2 u9 scope (per the Exit Report):
|
||||||
|
|
||||||
|
1. Per-axis fill — file value flows through when CLI omits the axis.
|
||||||
|
2. CLI-wins — CLI value beats file value on the same axis.
|
||||||
|
3. No-file noop — missing file → ``run_phase_z2_mvp1`` gets all-None.
|
||||||
|
4. Corrupt-file warn — invalid JSON / non-object → stderr warning + skip.
|
||||||
|
5. Invalid stem warn — ``Path(args.mdx_path).stem`` rejected by validator
|
||||||
|
→ warning + fallback skipped wholesale.
|
||||||
|
|
||||||
|
We exec the ``if __name__ == "__main__"`` block of
|
||||||
|
``src.phase_z2_pipeline`` directly inside the module's namespace, after
|
||||||
|
(a) monkeypatching ``src.user_overrides_io.DEFAULT_OVERRIDES_ROOT`` to a
|
||||||
|
tmp directory and (b) replacing ``run_phase_z2_mvp1`` with a recording
|
||||||
|
stub. This exercises the production fallback verbatim without the cost
|
||||||
|
of a real pipeline invocation.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import src.phase_z2_pipeline as _pz2
|
||||||
|
import src.user_overrides_io as _io
|
||||||
|
|
||||||
|
|
||||||
|
# -- harness ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_main_block(
|
||||||
|
captured: dict[str, Any], argv: list[str], monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""Run the ``__main__`` body of phase_z2_pipeline.py with a fake
|
||||||
|
``run_phase_z2_mvp1`` so its kwargs are observable."""
|
||||||
|
|
||||||
|
def _fake_run(
|
||||||
|
mdx_path,
|
||||||
|
run_id,
|
||||||
|
*,
|
||||||
|
override_layout=None,
|
||||||
|
override_frames=None,
|
||||||
|
override_zone_geometries=None,
|
||||||
|
override_section_assignments=None,
|
||||||
|
):
|
||||||
|
captured["mdx_path"] = mdx_path
|
||||||
|
captured["run_id"] = run_id
|
||||||
|
captured["override_layout"] = override_layout
|
||||||
|
captured["override_frames"] = override_frames
|
||||||
|
captured["override_zone_geometries"] = override_zone_geometries
|
||||||
|
captured["override_section_assignments"] = override_section_assignments
|
||||||
|
|
||||||
|
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
|
||||||
|
monkeypatch.setattr(sys, "argv", argv)
|
||||||
|
|
||||||
|
src_path = Path(_pz2.__file__)
|
||||||
|
source = src_path.read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
for node in tree.body:
|
||||||
|
if (
|
||||||
|
isinstance(node, ast.If)
|
||||||
|
and isinstance(node.test, ast.Compare)
|
||||||
|
and isinstance(node.test.left, ast.Name)
|
||||||
|
and node.test.left.id == "__name__"
|
||||||
|
):
|
||||||
|
block = ast.Module(body=node.body, type_ignores=[])
|
||||||
|
exec(compile(block, str(src_path), "exec"), _pz2.__dict__)
|
||||||
|
return
|
||||||
|
raise AssertionError("no `if __name__ == '__main__'` block found")
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect_overrides_root(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
"""Redirect the on-disk persistence root so tests never touch
|
||||||
|
``data/user_overrides/``."""
|
||||||
|
monkeypatch.setattr(_io, "DEFAULT_OVERRIDES_ROOT", tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_full_payload(tmp_path: Path, stem: str = "03") -> Path:
|
||||||
|
path = tmp_path / f"{stem}.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"layout": "sidebar-right",
|
||||||
|
"frames": {"03-1": "frame_file_a", "03-1+03-2": "frame_file_b"},
|
||||||
|
"zone_geometries": {
|
||||||
|
"top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.3},
|
||||||
|
"bottom": {"x": 0.0, "y": 0.3, "w": 1.0, "h": 0.7},
|
||||||
|
},
|
||||||
|
"zone_sections": {
|
||||||
|
"top": ["03-1"],
|
||||||
|
"bottom": ["03-2", "03-3"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# -- 1. no-file noop -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_overrides_file_passes_none_overrides(tmp_path, monkeypatch):
|
||||||
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
|
||||||
|
|
||||||
|
assert captured["override_layout"] is None
|
||||||
|
assert captured["override_frames"] is None
|
||||||
|
assert captured["override_zone_geometries"] is None
|
||||||
|
assert captured["override_section_assignments"] is None
|
||||||
|
# MDX path / run_id propagate untouched.
|
||||||
|
assert captured["mdx_path"] == Path("03.mdx")
|
||||||
|
assert captured["run_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# -- 2. file fills every axis when CLI is empty ----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_only_fills_all_four_axes_when_cli_empty(tmp_path, monkeypatch):
|
||||||
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||||
|
_write_full_payload(tmp_path, "03")
|
||||||
|
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
|
||||||
|
|
||||||
|
assert captured["override_layout"] == "sidebar-right"
|
||||||
|
assert captured["override_frames"] == {
|
||||||
|
"03-1": "frame_file_a",
|
||||||
|
"03-1+03-2": "frame_file_b",
|
||||||
|
}
|
||||||
|
assert captured["override_zone_geometries"] == {
|
||||||
|
"top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.3},
|
||||||
|
"bottom": {"x": 0.0, "y": 0.3, "w": 1.0, "h": 0.7},
|
||||||
|
}
|
||||||
|
assert captured["override_section_assignments"] == {
|
||||||
|
"top": ["03-1"],
|
||||||
|
"bottom": ["03-2", "03-3"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -- 3. CLI beats file on the same axis -----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_layout_overrides_file_layout(tmp_path, monkeypatch):
|
||||||
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||||
|
_write_full_payload(tmp_path, "03")
|
||||||
|
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
_exec_main_block(
|
||||||
|
captured,
|
||||||
|
["src.phase_z2_pipeline", "03.mdx", "--override-layout", "two-column"],
|
||||||
|
monkeypatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
# layout from CLI; remaining axes still filled from file.
|
||||||
|
assert captured["override_layout"] == "two-column"
|
||||||
|
assert captured["override_frames"] == {
|
||||||
|
"03-1": "frame_file_a",
|
||||||
|
"03-1+03-2": "frame_file_b",
|
||||||
|
}
|
||||||
|
assert captured["override_zone_geometries"] is not None
|
||||||
|
assert captured["override_section_assignments"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_frames_overrides_file_frames(tmp_path, monkeypatch):
|
||||||
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||||
|
_write_full_payload(tmp_path, "03")
|
||||||
|
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
_exec_main_block(
|
||||||
|
captured,
|
||||||
|
[
|
||||||
|
"src.phase_z2_pipeline",
|
||||||
|
"03.mdx",
|
||||||
|
"--override-frame",
|
||||||
|
"03-1=cli_frame_x",
|
||||||
|
],
|
||||||
|
monkeypatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CLI ``frames`` payload wholly replaces file ``frames`` (per-axis win).
|
||||||
|
assert captured["override_frames"] == {"03-1": "cli_frame_x"}
|
||||||
|
# Other axes still come from the file.
|
||||||
|
assert captured["override_layout"] == "sidebar-right"
|
||||||
|
assert captured["override_zone_geometries"] is not None
|
||||||
|
assert captured["override_section_assignments"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# -- 4. corrupt / non-object file warns and skips fallback ----------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_json_warns_and_skips_fallback(tmp_path, monkeypatch, capsys):
|
||||||
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||||
|
(tmp_path / "03.json").write_text("{ not valid json", encoding="utf-8")
|
||||||
|
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
|
||||||
|
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "failed to read" in err
|
||||||
|
# ``or None`` collapses empty dicts back to None on the call site.
|
||||||
|
assert captured["override_layout"] is None
|
||||||
|
assert captured["override_frames"] is None
|
||||||
|
assert captured["override_zone_geometries"] is None
|
||||||
|
assert captured["override_section_assignments"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_object_top_level_warns_and_skips_fallback(
|
||||||
|
tmp_path, monkeypatch, capsys
|
||||||
|
):
|
||||||
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||||
|
(tmp_path / "03.json").write_text("[1, 2, 3]", encoding="utf-8")
|
||||||
|
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
|
||||||
|
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "not a JSON object" in err
|
||||||
|
assert captured["override_layout"] is None
|
||||||
|
assert captured["override_frames"] is None
|
||||||
|
assert captured["override_zone_geometries"] is None
|
||||||
|
assert captured["override_section_assignments"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# -- 5. invalid MDX stem warns and skips fallback wholesale ---------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_mdx_stem_warns_and_skips_fallback(
|
||||||
|
tmp_path, monkeypatch, capsys
|
||||||
|
):
|
||||||
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||||
|
# Seed a file the loader would otherwise consume; the invalid stem must
|
||||||
|
# short-circuit before any read happens.
|
||||||
|
_write_full_payload(tmp_path, "03")
|
||||||
|
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
# ``Path(".hidden.mdx").stem`` == ".hidden" → leading dot → InvalidOverrideKey.
|
||||||
|
_exec_main_block(
|
||||||
|
captured, ["src.phase_z2_pipeline", ".hidden.mdx"], monkeypatch
|
||||||
|
)
|
||||||
|
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "cannot derive persistence key" in err
|
||||||
|
assert captured["override_layout"] is None
|
||||||
|
assert captured["override_frames"] is None
|
||||||
|
assert captured["override_zone_geometries"] is None
|
||||||
|
assert captured["override_section_assignments"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# -- 6. per-axis partial fill (file fills only what CLI omits) ------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_axis_partial_fill_mixes_cli_and_file(tmp_path, monkeypatch):
|
||||||
|
"""File carries frames + zone_geometries; CLI supplies layout only.
|
||||||
|
|
||||||
|
Expected: ``override_layout`` = CLI value, ``override_frames`` and
|
||||||
|
``override_zone_geometries`` = file values, ``override_section_assignments``
|
||||||
|
= None (neither side provided it).
|
||||||
|
"""
|
||||||
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||||
|
(tmp_path / "03.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"frames": {"03-1": "frame_only_file"},
|
||||||
|
"zone_geometries": {
|
||||||
|
"top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.5},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
_exec_main_block(
|
||||||
|
captured,
|
||||||
|
[
|
||||||
|
"src.phase_z2_pipeline",
|
||||||
|
"03.mdx",
|
||||||
|
"--override-layout",
|
||||||
|
"sidebar-right",
|
||||||
|
],
|
||||||
|
monkeypatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured["override_layout"] == "sidebar-right"
|
||||||
|
assert captured["override_frames"] == {"03-1": "frame_only_file"}
|
||||||
|
assert captured["override_zone_geometries"] == {
|
||||||
|
"top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.5},
|
||||||
|
}
|
||||||
|
assert captured["override_section_assignments"] is None
|
||||||
Reference in New Issue
Block a user