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>
570 lines
22 KiB
TypeScript
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({});
|
|
});
|
|
});
|