Files
C.E.L_Slide_test2/Front/client/tests/user_overrides_write.test.ts
kyeongmin 9388e25e76 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>
2026-05-22 11:47:11 +09:00

570 lines
22 KiB
TypeScript

// 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({});
});
});