// IMP-52 u6 — vitest coverage for restore-on-reopen helpers used by // `Home.tsx` to layer persisted `user_overrides.json` payloads onto the // in-memory `UserSelection` and `slidePlan`. // // Scope (Stage 2 unit u6 contract): // 1) deriveUserOverridesKey(filename) — MDX-stem key derivation that // matches backend u2 fallback's `Path(args.mdx_path).stem`. Strips // `.mdx` case-insensitively; preserves everything else. // 2) applyPersistedNonFrameOverrides(selection, persisted) — layers // layout / zone_geometries / zone_sections onto an existing selection. // Frames are NOT layered here (unit_id key requires slidePlan). // Foreign / unrecognized payloads degrade silently (no throw, no // partial mutation). // 3) remapPersistedFramesToZoneFrames(slidePlan, framesByUnitId) — // remaps frames (unit_id → template_id) to zone_frames (region.id → // template_id). Stale unit_ids (no matching zone) drop silently; // zones without internal_regions[0] or without section_ids are // skipped without throwing. // // All helpers are pure; tests run in vitest's default node environment // without RTL / jsdom. Home.tsx wiring sites (handleFileUpload pre-Generate // seed + handleGenerate post-loadRun frame remap) are 1-line call sites that // these helpers cover end-to-end. import { describe, it, expect } from "vitest"; import type { LayoutPresetId, SlidePlan, UserSelection, Zone, } from "../src/types/designAgent"; import { applyPersistedNonFrameOverrides, createInitialUserSelection, deriveUserOverridesKey, remapPersistedFramesToZoneFrames, saveImageOverride, } from "../src/utils/slidePlanUtils"; // ─── Fixtures ─────────────────────────────────────────────────────────────── function makeSelection(overrides?: Partial): UserSelection { return { selectedSectionId: null, selectedZoneId: null, selectedRegionId: null, overrides: { layout_preset: undefined, zone_frames: {}, zone_sections: {}, zone_sizes: {}, zone_geometries: {}, // IMP-51 (#79) u11 — keep the fixture in sync with the 5th persisted // axis declared on `UserSelection.overrides`. Empty by default so the // existing IMP-52 cases remain unchanged in shape. image_overrides: {}, ...overrides, }, }; } function makeZone( partial: { id: string; zone_id: string; section_ids: string[]; region_id?: string }, ): Zone { return { id: partial.id, zone_id: partial.zone_id, section_ids: partial.section_ids, position: { x: 0, y: 0, width: 1, height: 1 }, internal_regions: [ { id: partial.region_id ?? `${partial.id}-r0`, region_id: "region-single", role: "primary", content_type: "text_block", ratio_estimate: 1, content_unit_ids: [], frame_match_strategy: { kind: "frame_match", frame_id: null, display_strategy: "inline_full", }, frame_candidates: [], }, ], }; } function makeSlidePlan(zones: Zone[], layout: LayoutPresetId = "single"): SlidePlan { return { id: "plan-1", title: "test plan", layout_preset: layout, zones, }; } // ─── deriveUserOverridesKey ───────────────────────────────────────────────── describe("deriveUserOverridesKey (IMP-52 u6)", () => { it("strips trailing .mdx", () => { expect(deriveUserOverridesKey("03__DX_BIM_value_chain.mdx")).toBe( "03__DX_BIM_value_chain", ); }); it("strips .MDX case-insensitively", () => { expect(deriveUserOverridesKey("04_demo.MDX")).toBe("04_demo"); expect(deriveUserOverridesKey("05_intro.Mdx")).toBe("05_intro"); }); it("returns the filename unchanged when no .mdx suffix", () => { expect(deriveUserOverridesKey("03__DX_BIM_value_chain")).toBe( "03__DX_BIM_value_chain", ); expect(deriveUserOverridesKey("notes.txt")).toBe("notes.txt"); }); it("only strips the final .mdx, preserves dots inside the stem", () => { expect(deriveUserOverridesKey("05.2_layer.mdx")).toBe("05.2_layer"); }); it("returns empty string for empty input", () => { expect(deriveUserOverridesKey("")).toBe(""); }); it("matches backend Path(args.mdx_path).stem for the canonical demo MDXs", () => { // These are the three canonical samples loaded by /api/sample-mdx; the // key on both ends must agree so a write from frontend (PUT) is found // by backend (u2 fallback on next pipeline run). expect(deriveUserOverridesKey("03_demo.mdx")).toBe("03_demo"); expect(deriveUserOverridesKey("04_demo.mdx")).toBe("04_demo"); expect(deriveUserOverridesKey("05_demo.mdx")).toBe("05_demo"); }); }); // ─── applyPersistedNonFrameOverrides ──────────────────────────────────────── describe("applyPersistedNonFrameOverrides (IMP-52 u6)", () => { it("layers layout / zone_geometries / zone_sections", () => { const sel = makeSelection(); const persisted = { layout: "horizontal-2", zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.4 } }, zone_sections: { top: ["03-1"], bottom: ["03-2"] }, } as const; const next = applyPersistedNonFrameOverrides(sel, persisted); expect(next.overrides.layout_preset).toBe("horizontal-2"); expect(next.overrides.zone_geometries).toEqual({ top: { x: 0, y: 0, w: 1, h: 0.4 }, }); expect(next.overrides.zone_sections).toEqual({ top: ["03-1"], bottom: ["03-2"], }); }); it("does NOT layer frames (frames need post-loadRun remap)", () => { const sel = makeSelection({ zone_frames: { "r-existing": "tpl-existing" } }); const persisted = { frames: { "03-1+03-2": "tpl-persisted" }, }; const next = applyPersistedNonFrameOverrides(sel, persisted); // zone_frames is untouched here; the post-loadRun remap step owns it. expect(next.overrides.zone_frames).toEqual({ "r-existing": "tpl-existing" }); }); it("rejects layout values outside the 8 known preset ids", () => { const sel = makeSelection({ layout_preset: "single" }); const next = applyPersistedNonFrameOverrides(sel, { layout: "rogue-layout" as unknown as string, }); // Stays at the original — preset whitelist guards against hand-edited // files or future schema drift. expect(next.overrides.layout_preset).toBe("single"); }); it("ignores zone_geometries when the payload axis is an array", () => { const sel = makeSelection({ zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } } }); const next = applyPersistedNonFrameOverrides(sel, { zone_geometries: [] as unknown as Record, }); expect(next.overrides.zone_geometries).toEqual({ top: { x: 0, y: 0, w: 1, h: 0.5 }, }); }); it("returns the selection unchanged when persisted is null / undefined / non-object", () => { const sel = makeSelection({ layout_preset: "single" }); expect(applyPersistedNonFrameOverrides(sel, null)).toEqual(sel); expect(applyPersistedNonFrameOverrides(sel, undefined)).toEqual(sel); }); it("returns the selection unchanged when persisted is empty {}", () => { const sel = makeSelection({ layout_preset: "single" }); const next = applyPersistedNonFrameOverrides(sel, {}); expect(next.overrides.layout_preset).toBe("single"); expect(next.overrides.zone_geometries).toEqual({}); expect(next.overrides.zone_sections).toEqual({}); }); it("returns a NEW selection object (no mutation of input)", () => { const sel = makeSelection(); const next = applyPersistedNonFrameOverrides(sel, { layout: "vertical-2" }); expect(next).not.toBe(sel); expect(next.overrides).not.toBe(sel.overrides); // Input still pristine. expect(sel.overrides.layout_preset).toBeUndefined(); }); }); // ─── remapPersistedFramesToZoneFrames ─────────────────────────────────────── describe("remapPersistedFramesToZoneFrames (IMP-52 u6)", () => { it("maps unit_id (section_ids joined by +) to region.id", () => { const plan = makeSlidePlan([ makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }), makeZone({ id: "z-bot", zone_id: "bottom", section_ids: ["03-2", "03-3"], region_id: "r-bot" }), ]); const remapped = remapPersistedFramesToZoneFrames(plan, { "03-1": "tpl-a", "03-2+03-3": "tpl-b", }); expect(remapped).toEqual({ "r-top": "tpl-a", "r-bot": "tpl-b", }); }); it("silently drops persisted entries whose unit_id matches no zone", () => { const plan = makeSlidePlan([ makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }), ]); const remapped = remapPersistedFramesToZoneFrames(plan, { "03-1": "tpl-a", "stale-section-id": "tpl-stale", // user changed zone_sections between sessions }); expect(remapped).toEqual({ "r-top": "tpl-a" }); }); it("returns {} when slidePlan is null / undefined", () => { expect(remapPersistedFramesToZoneFrames(null, { "03-1": "tpl-a" })).toEqual({}); expect(remapPersistedFramesToZoneFrames(undefined, { "03-1": "tpl-a" })).toEqual({}); }); it("returns {} when framesByUnitId is null / undefined / {}", () => { const plan = makeSlidePlan([ makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }), ]); expect(remapPersistedFramesToZoneFrames(plan, null)).toEqual({}); expect(remapPersistedFramesToZoneFrames(plan, undefined)).toEqual({}); expect(remapPersistedFramesToZoneFrames(plan, {})).toEqual({}); }); it("skips zones with empty section_ids (no unit_id to derive)", () => { const plan = makeSlidePlan([ makeZone({ id: "z-empty", zone_id: "empty", section_ids: [], region_id: "r-empty" }), makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }), ]); const remapped = remapPersistedFramesToZoneFrames(plan, { "": "tpl-should-not-match-empty-join", "03-1": "tpl-a", }); expect(remapped).toEqual({ "r-top": "tpl-a" }); }); it("skips zones without internal_regions[0]", () => { const plan: SlidePlan = { id: "plan-x", title: "no regions", layout_preset: "single", zones: [ { id: "z-bare", zone_id: "bare", section_ids: ["03-1"], position: { x: 0, y: 0, width: 1, height: 1 }, internal_regions: [], }, ], }; expect(remapPersistedFramesToZoneFrames(plan, { "03-1": "tpl-a" })).toEqual({}); }); it("ignores persisted entries with empty / non-string template_id", () => { const plan = makeSlidePlan([ makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }), ]); const remapped = remapPersistedFramesToZoneFrames(plan, { "03-1": "" as unknown as string, }); expect(remapped).toEqual({}); }); it("preserves the user-selected template even when slidePlan layout would imply a different default", () => { // Backend u2 fallback should already have applied the user's frame // override via CLI args, but if the plan's default frame_match_strategy // disagrees, the post-loadRun remap still surfaces the user's choice // for the SlideCanvas override-vs-default preview indicator. const plan = makeSlidePlan([ makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }), ]); const remapped = remapPersistedFramesToZoneFrames(plan, { "03-1": "user-chosen-tpl", }); expect(remapped["r-top"]).toBe("user-chosen-tpl"); }); }); // ─── IMP-51 (#79) u11 — image_overrides axis ──────────────────────────────── // New 5th persisted axis. The on-disk schema (KNOWN_AXES, // src/user_overrides_io.py u1), the typed client // (services/userOverridesApi.ts u3 ImageOverridesOverride), the Vite // allowlist (vite.config.ts u2), and the backend CLI flag (--override-image // in src/phase_z2_pipeline.py u5) all expect `image_id` → percent-of-slide // geometry. u11 owns the in-memory mirror on `UserSelection.overrides` // (declared in types/designAgent.ts) plus the three pure helpers that // Home.tsx (u10) wires: // • applyPersistedNonFrameOverrides — restore-on-reopen layer. // • createInitialUserSelection — fresh-slide initializer. // • saveImageOverride — single-image record helper invoked by the // SlideCanvas u8 drag/resize handler. describe("image_overrides axis — applyPersistedNonFrameOverrides (IMP-51 u11)", () => { it("layers a flat image_overrides dict onto the selection", () => { const sel = makeSelection(); const persisted = { image_overrides: { "img-abc1234567": { x: 10, y: 15, w: 30.5, h: 25 }, "img-deadbeef00": { x: 50, y: 50, w: 40, h: 40 }, }, }; const next = applyPersistedNonFrameOverrides(sel, persisted); expect(next.overrides.image_overrides).toEqual({ "img-abc1234567": { x: 10, y: 15, w: 30.5, h: 25 }, "img-deadbeef00": { x: 50, y: 50, w: 40, h: 40 }, }); // Untouched axes stay at their fixture defaults so the round-trip is // safe to interleave with the other four axes. expect(next.overrides.zone_geometries).toEqual({}); expect(next.overrides.zone_sections).toEqual({}); expect(next.overrides.layout_preset).toBeUndefined(); }); it("ignores image_overrides when the payload axis is an array", () => { const sel = makeSelection({ image_overrides: { "img-existing00": { x: 1, y: 2, w: 30, h: 40 } }, }); const next = applyPersistedNonFrameOverrides(sel, { image_overrides: [] as unknown as Record< string, { x: number; y: number; w: number; h: number } >, }); // Same guard the zone_geometries branch uses — array payloads from a // hand-edited file are rejected and the prior in-memory value stays. expect(next.overrides.image_overrides).toEqual({ "img-existing00": { x: 1, y: 2, w: 30, h: 40 }, }); }); it("ignores image_overrides when the payload axis is null", () => { const sel = makeSelection({ image_overrides: { "img-existing00": { x: 0, y: 0, w: 100, h: 100 } }, }); const next = applyPersistedNonFrameOverrides(sel, { image_overrides: null, }); expect(next.overrides.image_overrides).toEqual({ "img-existing00": { x: 0, y: 0, w: 100, h: 100 }, }); }); it("layers image_overrides alongside the four IMP-52 axes in one call", () => { const sel = makeSelection(); const next = applyPersistedNonFrameOverrides(sel, { layout: "horizontal-2", zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.4 } }, zone_sections: { top: ["03-1"] }, image_overrides: { "img-abc1234567": { x: 25, y: 25, w: 50, h: 50 } }, }); expect(next.overrides.layout_preset).toBe("horizontal-2"); expect(next.overrides.zone_geometries).toEqual({ top: { x: 0, y: 0, w: 1, h: 0.4 }, }); expect(next.overrides.zone_sections).toEqual({ top: ["03-1"] }); expect(next.overrides.image_overrides).toEqual({ "img-abc1234567": { x: 25, y: 25, w: 50, h: 50 }, }); }); it("seeds an empty image_overrides on a fresh selection (createInitialUserSelection)", () => { const sel = createInitialUserSelection(); expect(sel.overrides.image_overrides).toEqual({}); // Mirrors the shape Home.tsx receives before any user interaction — // SlideCanvas u8 expects the axis to exist (not undefined) so its // `Object.entries(measured + persisted)` merge never crashes. }); }); describe("image_overrides axis — saveImageOverride (IMP-51 u11)", () => { const ID_A = "img-abc1234567"; const ID_B = "img-deadbeef00"; it("adds a new image_id entry on an empty axis", () => { const sel = makeSelection(); const next = saveImageOverride(sel, ID_A, { x: 10, y: 15, w: 30.5, h: 25 }); expect(next.overrides.image_overrides).toEqual({ [ID_A]: { x: 10, y: 15, w: 30.5, h: 25 }, }); }); it("replaces an existing entry under the same image_id (most recent drag wins)", () => { const sel = makeSelection({ image_overrides: { [ID_A]: { x: 0, y: 0, w: 20, h: 20 } }, }); const next = saveImageOverride(sel, ID_A, { x: 50, y: 50, w: 30, h: 30 }); expect(next.overrides.image_overrides).toEqual({ [ID_A]: { x: 50, y: 50, w: 30, h: 30 }, }); }); it("preserves sibling image_id entries when adding a new one", () => { const sel = makeSelection({ image_overrides: { [ID_A]: { x: 10, y: 10, w: 20, h: 20 } }, }); const next = saveImageOverride(sel, ID_B, { x: 60, y: 60, w: 30, h: 30 }); expect(next.overrides.image_overrides).toEqual({ [ID_A]: { x: 10, y: 10, w: 20, h: 20 }, [ID_B]: { x: 60, y: 60, w: 30, h: 30 }, }); }); it("does NOT touch the other four override axes", () => { const sel = makeSelection({ zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } }, zone_sections: { top: ["03-1"] }, zone_frames: { "r-top": "tpl-a" }, layout_preset: "horizontal-2", }); const next = saveImageOverride(sel, ID_A, { x: 10, y: 10, w: 20, h: 20 }); expect(next.overrides.zone_geometries).toEqual({ top: { x: 0, y: 0, w: 1, h: 0.5 }, }); expect(next.overrides.zone_sections).toEqual({ top: ["03-1"] }); expect(next.overrides.zone_frames).toEqual({ "r-top": "tpl-a" }); expect(next.overrides.layout_preset).toBe("horizontal-2"); }); it("returns a NEW selection object (no input mutation)", () => { const sel = makeSelection({ image_overrides: { [ID_A]: { x: 0, y: 0, w: 10, h: 10 } }, }); const before = { ...sel.overrides.image_overrides }; const next = saveImageOverride(sel, ID_B, { x: 30, y: 30, w: 20, h: 20 }); expect(next).not.toBe(sel); expect(next.overrides).not.toBe(sel.overrides); expect(next.overrides.image_overrides).not.toBe(sel.overrides.image_overrides); // Input still pristine. expect(sel.overrides.image_overrides).toEqual(before); }); });