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