Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 9s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
803 lines
35 KiB
TypeScript
803 lines
35 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);
|
||
}
|
||
|
||
/**
|
||
* IMP-55 #93 u8 — strip JS/TS line + block comments so source-pattern
|
||
* regex checks assert against LIVE code only. The u5 / u7 docblocks in
|
||
* Home.tsx intentionally reference removed identifiers (e.g. `defaultByZone`,
|
||
* `sameAsDefault`, `zoneSectionsDiff`) and the marker axis name in prose to
|
||
* document the Stage 1 root cause for future readers — those references are
|
||
* documentation, not behavior, and must not trigger negative-match guards.
|
||
* Strips `// ...` to EOL and `/* ... */` (incl. multi-line) — keeps string
|
||
* literals intact because we only consume the result for regex-match tests.
|
||
*/
|
||
function stripComments(source: string): string {
|
||
return source
|
||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||
.replace(/\/\/.*$/gm, "");
|
||
}
|
||
|
||
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({});
|
||
});
|
||
});
|
||
|
||
// ─── IMP-55 #93 u8 — manual_section_assignment intent marker contract ─────
|
||
// Verifies four axes of the marker contract introduced in u3 (type) / u5
|
||
// (apply reset) / u6 (drag flip + co-PUT) / u7 (generate gate):
|
||
// 1) Drag dual-axis persistence — handleSectionDrop persists BOTH
|
||
// `zone_sections` AND `manual_section_assignment: true` in the SAME
|
||
// PUT body (co-PUT atomicity — disk never sees post-drop zone_sections
|
||
// without the marker).
|
||
// 2) Apply / cancel reset — handleApplyPendingLayout writes explicit
|
||
// `manual_section_assignment: false` after the `...overrides` spread,
|
||
// and handleCancelPendingLayout relies on createInitialUserSelection
|
||
// (which u3 seeds to `false`) to drop a prior `true`.
|
||
// 3) Marker-gated forwarding — handleGenerate gates `overrides.zoneSections`
|
||
// forwarding strictly on `manualMarker === true` (NOT truthiness, NOT
|
||
// `!= null`, NOT presence). u3-seeded `false` and absent values both
|
||
// skip forwarding.
|
||
// 4) sameAsDefault NOT required — the Stage 1 anti-pattern (defaultByZone
|
||
// / sameAsDefault / zoneSectionsDiff self-compare loop) is gone from
|
||
// `handleGenerate` entirely; the marker is the source of intent.
|
||
|
||
describe("IMP-55 #93 u8 — manual_section_assignment marker contract", () => {
|
||
it("handleSectionDrop sets marker true in-memory before persistence", () => {
|
||
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
|
||
// finalSelection literal (built from zoneSelected, then marker = true)
|
||
// must occur BEFORE the saveUserOverrides call so the in-memory state
|
||
// and the PUT body source from the same overrides shape.
|
||
const markerIdx = block.search(/manual_section_assignment:\s*true/);
|
||
const saveIdx = block.search(/saveUserOverrides\(/);
|
||
expect(markerIdx).toBeGreaterThan(-1);
|
||
expect(saveIdx).toBeGreaterThan(-1);
|
||
expect(markerIdx).toBeLessThan(saveIdx);
|
||
});
|
||
|
||
it("handleSectionDrop co-PUTs zone_sections + manual_section_assignment:true (single body)", () => {
|
||
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
|
||
// Single saveUserOverrides call carrying BOTH axes. The regex spans the
|
||
// call body to prove the two keys live in the same object literal — a
|
||
// future split into two PUTs would race the 300ms debounce and re-open
|
||
// the IMP-55 stale-disk window.
|
||
expect(block).toMatch(
|
||
/saveUserOverrides\([\s\S]*?zone_sections:[\s\S]*?manual_section_assignment:\s*true[\s\S]*?\)/,
|
||
);
|
||
// Exactly ONE saveUserOverrides call in the handler.
|
||
const calls = block.match(/saveUserOverrides\(/g) ?? [];
|
||
expect(calls.length).toBe(1);
|
||
});
|
||
|
||
it("handleApplyPendingLayout resets the marker to false in overrides literal", () => {
|
||
const block = sliceHandler(HOME_TSX, "handleApplyPendingLayout");
|
||
// After spreading `...p.userSelection.overrides`, the explicit
|
||
// `manual_section_assignment: false` overrides any prior-drag `true`.
|
||
// Without this the layout flip would carry the marker through, and u7
|
||
// would forward auto-carried assignments as user overrides → the
|
||
// PARTIAL_COVERAGE regression that motivated IMP-55.
|
||
expect(block).toMatch(/\.\.\.p\.userSelection\.overrides[\s\S]*?manual_section_assignment:\s*false/);
|
||
});
|
||
|
||
it("handleCancelPendingLayout uses createInitialUserSelection (u3 seeds false)", () => {
|
||
const block = sliceHandler(HOME_TSX, "handleCancelPendingLayout");
|
||
// Cancel discards all pending in-memory edits via the fresh-selection
|
||
// helper — the seed (u3) is the single source of truth for the
|
||
// in-memory marker on this path. u12 adds a separate disk-side
|
||
// saveUserOverrides PUT (covered by the u12 describe block below);
|
||
// the in-memory userSelection literal still has no explicit marker
|
||
// field — the seed handles it.
|
||
expect(block).toMatch(/createInitialUserSelection\(p\.slidePlan\)/);
|
||
// In-memory contract: no `manual_section_assignment` property appears
|
||
// inside the userSelection assignment. The only marker reference in
|
||
// live code lives inside the u12 saveUserOverrides(...) call body.
|
||
const codeOnly = stripComments(block);
|
||
expect(codeOnly).not.toMatch(
|
||
/userSelection:[\s\S]*?manual_section_assignment/,
|
||
);
|
||
});
|
||
|
||
it("handleGenerate gates overrides.zoneSections on manualMarker === true (strict bool)", () => {
|
||
const block = sliceHandler(HOME_TSX, "handleGenerate");
|
||
// Marker read AND strict-equality gate. `===` not `==`, not truthiness,
|
||
// not presence — so `false` / absent both skip forwarding (fail-closed).
|
||
expect(block).toMatch(/state\.userSelection\.overrides\.manual_section_assignment/);
|
||
expect(block).toMatch(/manualMarker\s*===\s*true/);
|
||
// The assignment to `overrides.zoneSections` must live INSIDE the
|
||
// marker-true branch.
|
||
const gateIdx = block.search(/if\s*\(\s*manualMarker\s*===\s*true\s*\)/);
|
||
const assignIdx = block.search(/overrides\.zoneSections\s*=/);
|
||
expect(gateIdx).toBeGreaterThan(-1);
|
||
expect(assignIdx).toBeGreaterThan(gateIdx);
|
||
});
|
||
|
||
it("handleGenerate filters forwarded zone_sections to valid zone_ids only (cross-layout safety)", () => {
|
||
const block = sliceHandler(HOME_TSX, "handleGenerate");
|
||
// A stale persisted layout could carry zone_ids that do not exist in
|
||
// the current sourcePlan (e.g. horizontal-2 `top`/`bottom` while the
|
||
// current layout is vertical-2 `left`/`right`). Those foreign keys
|
||
// must be dropped before reaching the backend `--override-section-
|
||
// assignment` so they cannot trigger PARTIAL_COVERAGE.
|
||
expect(block).toMatch(/validZoneIds\s*=\s*new Set\(\s*sourcePlan\.zones\.map\(\(z\)\s*=>\s*z\.zone_id\)/);
|
||
expect(block).toMatch(/if\s*\(!validZoneIds\.has\(zoneId\)\)\s*continue/);
|
||
});
|
||
|
||
it("handleGenerate no longer contains the IMP-08 B-3 self-compare anti-pattern", () => {
|
||
// Strip comments — the u7 docblock intentionally references the removed
|
||
// identifiers (`defaultByZone` / `sameAsDefault` / `zoneSectionsDiff`)
|
||
// in prose to explain the Stage 1 root cause for future readers; the
|
||
// regression we guard against is the LIVE code re-emerging.
|
||
const block = stripComments(sliceHandler(HOME_TSX, "handleGenerate"));
|
||
// The Stage 1 root cause: these identifiers compared user input against
|
||
// itself (sourcePlan === effectiveSlidePlan → zones === pendingZones,
|
||
// both derived from the same overrides.zone_sections). u7 deleted the
|
||
// entire block.
|
||
expect(block).not.toMatch(/\bdefaultByZone\b/);
|
||
expect(block).not.toMatch(/\bsameAsDefault\b/);
|
||
expect(block).not.toMatch(/\bzoneSectionsDiff\b/);
|
||
});
|
||
|
||
it("co-PUT payload contract: marker=true + zone_sections land in a single PUT body", async () => {
|
||
fetchMock.mockResolvedValue(mockResponse({}));
|
||
// Shape produced by handleSectionDrop after the u6 marker flip.
|
||
void saveUserOverrides("03_demo", {
|
||
zone_sections: { left: ["03-2"], right: ["03-1"] },
|
||
manual_section_assignment: true,
|
||
});
|
||
vi.advanceTimersByTime(300);
|
||
await drainMicrotasks();
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
const body = lastPutBody() as Record<string, unknown>;
|
||
// Both axes in the same PUT body — exact equality, not arrayContaining,
|
||
// because any extra axis would mean a foreign mutation leaked through.
|
||
expect(Object.keys(body).sort()).toEqual(
|
||
["manual_section_assignment", "zone_sections"].sort(),
|
||
);
|
||
expect(body.manual_section_assignment).toBe(true);
|
||
expect(body.zone_sections).toEqual({ left: ["03-2"], right: ["03-1"] });
|
||
});
|
||
|
||
it("co-PUT payload contract: marker=false carries explicitly through saveUserOverrides", async () => {
|
||
// u12 will add the apply/cancel explicit `false` PUT; the typed client
|
||
// must already propagate the literal `false` through the debounce
|
||
// bucket. A truthiness-based coalesce in the bucket merge would drop
|
||
// the value and re-open the stale-disk window. This locks the wire
|
||
// contract independently of the u12 caller-site write.
|
||
fetchMock.mockResolvedValue(mockResponse({}));
|
||
void saveUserOverrides("03_demo", { manual_section_assignment: false });
|
||
vi.advanceTimersByTime(300);
|
||
await drainMicrotasks();
|
||
const body = lastPutBody() as Record<string, unknown>;
|
||
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
|
||
expect(body.manual_section_assignment).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─── IMP-55 #93 u12 — stale-disk marker reset on apply / cancel ───────────
|
||
// u5 resets the in-memory marker on layout apply, and u3's seed via
|
||
// `createInitialUserSelection` resets it on cancel. But the disk persists
|
||
// independently — a prior drag wrote `true` via u6's co-PUT, so after a
|
||
// page reload the u3 restore branch would re-seed `true` and the u7 gate
|
||
// would forward auto-carried section assignments → PARTIAL_COVERAGE
|
||
// regression. u12 closes that window by writing `manual_section_assignment:
|
||
// false` to disk via saveUserOverrides on both apply and cancel paths.
|
||
describe("IMP-55 #93 u12 — stale-disk marker reset on layout apply/cancel", () => {
|
||
it("handleApplyPendingLayout source contains a marker=false saveUserOverrides PUT", () => {
|
||
const block = sliceHandler(HOME_TSX, "handleApplyPendingLayout");
|
||
// Stripped-comment source so the u5 docblock prose doesn't satisfy the
|
||
// assertion — must be a real call expression.
|
||
const code = stripComments(block);
|
||
// Uploaded-file gate (mirrors the u6 / other handler pattern — the
|
||
// demo-mode initial render path must not PUT to an empty key).
|
||
expect(code).toMatch(
|
||
/if\s*\(\s*p\.uploadedFile\s*\)[\s\S]*?saveUserOverrides\([\s\S]*?manual_section_assignment:\s*false[\s\S]*?\)/,
|
||
);
|
||
expect(code).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
|
||
});
|
||
|
||
it("handleCancelPendingLayout source contains a marker=false saveUserOverrides PUT", () => {
|
||
const block = sliceHandler(HOME_TSX, "handleCancelPendingLayout");
|
||
const code = stripComments(block);
|
||
// Cancel handler converts from arrow-body to function-body for the
|
||
// disk PUT; the in-memory reset still comes from createInitialUserSelection.
|
||
expect(code).toMatch(
|
||
/if\s*\(\s*p\.uploadedFile\s*\)[\s\S]*?saveUserOverrides\([\s\S]*?manual_section_assignment:\s*false[\s\S]*?\)/,
|
||
);
|
||
expect(code).toMatch(/createInitialUserSelection\(p\.slidePlan\)/);
|
||
});
|
||
|
||
it("apply path PUT payload: marker=false carries alone (no auto-carry leakage)", async () => {
|
||
// The apply handler issues a dedicated PUT for the marker reset that is
|
||
// independent of the (conditional) zone_geometries PUT and of the
|
||
// in-memory zone_sections rewrite. The wire contract for this PUT must
|
||
// contain only the marker — if zone_sections leaked into the same body
|
||
// it would re-arm the u9 backend fallback gate against u12's intent.
|
||
fetchMock.mockResolvedValue(mockResponse({}));
|
||
void saveUserOverrides("03_demo", { manual_section_assignment: false });
|
||
vi.advanceTimersByTime(300);
|
||
await drainMicrotasks();
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
const body = lastPutBody() as Record<string, unknown>;
|
||
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
|
||
expect(body.manual_section_assignment).toBe(false);
|
||
});
|
||
|
||
it("apply path PUT is unconditional (does NOT gate on hadPriorGeoms)", () => {
|
||
// The u4 zone_geometries PUT inside handleApplyPendingLayout is
|
||
// conditional (`p.uploadedFile && hadPriorGeoms`). The u12 marker PUT
|
||
// must NOT inherit that gate — a stale disk `true` can exist without
|
||
// any prior zone_geometries, so the reset must always fire.
|
||
const code = stripComments(sliceHandler(HOME_TSX, "handleApplyPendingLayout"));
|
||
// Locate the marker PUT and verify its enclosing `if` clause is just
|
||
// `p.uploadedFile`, not the compound `... && hadPriorGeoms` guard.
|
||
const markerCallMatch = code.match(
|
||
/if\s*\(([^)]*)\)\s*\{[^}]*saveUserOverrides\([^)]*manual_section_assignment:\s*false[^)]*\)/,
|
||
);
|
||
expect(markerCallMatch).not.toBeNull();
|
||
if (markerCallMatch) {
|
||
expect(markerCallMatch[1].trim()).toBe("p.uploadedFile");
|
||
}
|
||
});
|
||
});
|