Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
u1: text_overrides axis in user_overrides_io u2: structure_overrides axis in user_overrides_io u3: vite allowlist for new endpoints u4: text_override_resolver u5: Step 12 text_overrides apply in phase_z2_pipeline u6: structure_override_resolver u7: text_path_stamper u8: SlideCanvas text-edit capture u9: SlideCanvas structure-edit overlay u10: userOverridesApi service extension u11: designAgent types extension u12: slidePlanUtils restore u13: user_overrides endpoint tests u14: user_overrides restore tests u15: pipeline fallback tests u16: edit-mode state + gating tests u17: slide_base print mode CSS u18: /api/connect endpoint (vite) u19: /api/export endpoint (vite) Recovery scope: 29 files (12 modified + 17 new). u20 already pushed in 9439575; this commit lands u1-u19 that were authored but not committed before #90 was externally closed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
707 lines
28 KiB
TypeScript
707 lines
28 KiB
TypeScript
// 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,
|
|
saveTextOverride,
|
|
saveStructureOverride,
|
|
} from "../src/utils/slidePlanUtils";
|
|
|
|
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
|
|
|
function makeSelection(overrides?: Partial<UserSelection["overrides"]>): 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: {},
|
|
// IMP-55 (#93) u3 — bool intent marker is REQUIRED on
|
|
// `UserSelection.overrides` (not optional). Default to `false` so every
|
|
// pre-existing fixture matches the `createInitialUserSelection` seed
|
|
// and stays compile-clean after u3 widened the type.
|
|
manual_section_assignment: false,
|
|
// IMP-56 (#90) u15 — keep the fixture in sync with the two Step-22
|
|
// persist axes declared on `UserSelection.overrides`. Empty by
|
|
// default so pre-existing cases retain their shape.
|
|
text_overrides: {},
|
|
structure_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<string, { x: number; y: number; w: number; h: number }>,
|
|
});
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ─── IMP-55 (#93) u3 — manual_section_assignment bool axis ──────────────────
|
|
// Restore-on-reopen / seed coverage for the bool intent marker. Production
|
|
// branch lives at `slidePlanUtils.ts` — `applyPersistedNonFrameOverrides`
|
|
// guards with `typeof persisted.manual_section_assignment === "boolean"`,
|
|
// and `createInitialUserSelection` seeds the axis to `false`. The marker
|
|
// gates whether `handleGenerate` (u7) forwards `overrides.zoneSections`
|
|
// to the backend; the pipeline (u9) consumes persisted `zone_sections`
|
|
// only when the marker is exactly `true`, so any non-boolean payload MUST
|
|
// end up `false` in memory (fail-closed).
|
|
|
|
describe("manual_section_assignment axis — applyPersistedNonFrameOverrides (IMP-55 #93 u3)", () => {
|
|
it("restores literal true verbatim", () => {
|
|
const sel = makeSelection();
|
|
const next = applyPersistedNonFrameOverrides(sel, {
|
|
manual_section_assignment: true,
|
|
});
|
|
expect(next.overrides.manual_section_assignment).toBe(true);
|
|
});
|
|
|
|
it("restores literal false verbatim (u12 apply/cancel write must survive reopen)", () => {
|
|
// Seed `true` so the assertion proves `false` overwrites; a truthiness
|
|
// check instead of `typeof === \"boolean\"` would silently keep `true`
|
|
// and resurrect stale auto-carry assignments as user intent.
|
|
const sel = makeSelection({ manual_section_assignment: true });
|
|
const next = applyPersistedNonFrameOverrides(sel, {
|
|
manual_section_assignment: false,
|
|
});
|
|
expect(next.overrides.manual_section_assignment).toBe(false);
|
|
});
|
|
|
|
it("leaves the in-memory marker unchanged when the persisted axis is absent", () => {
|
|
const sel = makeSelection({ manual_section_assignment: true });
|
|
const next = applyPersistedNonFrameOverrides(sel, { layout: "horizontal-2" });
|
|
expect(next.overrides.manual_section_assignment).toBe(true);
|
|
expect(next.overrides.layout_preset).toBe("horizontal-2");
|
|
});
|
|
|
|
it.each([
|
|
["null clear sentinel", null],
|
|
['string "true"', "true"],
|
|
['string "false"', "false"],
|
|
["number 1", 1],
|
|
["number 0", 0],
|
|
["object {}", {}],
|
|
["array []", []],
|
|
])("ignores non-boolean payload (%s) — keeps prior in-memory value", (_label, payload) => {
|
|
const sel = makeSelection({ manual_section_assignment: true });
|
|
const next = applyPersistedNonFrameOverrides(sel, {
|
|
manual_section_assignment: payload as unknown as boolean,
|
|
});
|
|
expect(next.overrides.manual_section_assignment).toBe(true);
|
|
});
|
|
|
|
it("seeds an empty selection with manual_section_assignment=false (createInitialUserSelection)", () => {
|
|
const sel = createInitialUserSelection();
|
|
expect(sel.overrides.manual_section_assignment).toBe(false);
|
|
});
|
|
|
|
it("returns a NEW selection object (no input mutation) when restoring the marker", () => {
|
|
const sel = makeSelection({ manual_section_assignment: false });
|
|
const next = applyPersistedNonFrameOverrides(sel, {
|
|
manual_section_assignment: true,
|
|
});
|
|
expect(next).not.toBe(sel);
|
|
expect(next.overrides).not.toBe(sel.overrides);
|
|
// Input still pristine — proves the helper does not flip the fixture.
|
|
expect(sel.overrides.manual_section_assignment).toBe(false);
|
|
});
|
|
|
|
it("layers the bool axis alongside other persisted axes in a single call", () => {
|
|
const sel = makeSelection();
|
|
const next = applyPersistedNonFrameOverrides(sel, {
|
|
layout: "vertical-2",
|
|
zone_sections: { top: ["03-1"], bottom: ["03-2"] },
|
|
manual_section_assignment: true,
|
|
});
|
|
expect(next.overrides.layout_preset).toBe("vertical-2");
|
|
expect(next.overrides.zone_sections).toEqual({
|
|
top: ["03-1"],
|
|
bottom: ["03-2"],
|
|
});
|
|
expect(next.overrides.manual_section_assignment).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── IMP-56 (#90) u15 — text_overrides + structure_overrides axes ───────────
|
|
// Pure helpers wired by Home.tsx into the SlideCanvas u13 focusout capture
|
|
// (text) and u14 structure overlay emit (structure). Tests cover:
|
|
// • saveTextOverride / saveStructureOverride immutability + merge semantics
|
|
// • createInitialUserSelection seeding the two new axes empty
|
|
// • applyPersistedNonFrameOverrides layering via the u10 extract helpers
|
|
|
|
describe("text_overrides axis — saveTextOverride (IMP-56 u15)", () => {
|
|
it("records a fresh (zoneId, textPath, value) tuple", () => {
|
|
const sel = makeSelection();
|
|
const next = saveTextOverride(sel, "top", "row_1_left_body.0", "분석 결과");
|
|
expect(next.overrides.text_overrides).toEqual({
|
|
top: { "row_1_left_body.0": "분석 결과" },
|
|
});
|
|
});
|
|
|
|
it("merges within the same zone without erasing prior text_paths", () => {
|
|
const sel = makeSelection({
|
|
text_overrides: { top: { "row_1_left_body.0": "기존" } },
|
|
});
|
|
const next = saveTextOverride(sel, "top", "row_1_left_body.1", "신규");
|
|
expect(next.overrides.text_overrides.top).toEqual({
|
|
"row_1_left_body.0": "기존",
|
|
"row_1_left_body.1": "신규",
|
|
});
|
|
});
|
|
|
|
it("overwrites the same textPath value within a zone", () => {
|
|
const sel = makeSelection({
|
|
text_overrides: { top: { "headline.0": "v1" } },
|
|
});
|
|
const next = saveTextOverride(sel, "top", "headline.0", "v2");
|
|
expect(next.overrides.text_overrides.top).toEqual({ "headline.0": "v2" });
|
|
});
|
|
|
|
it("does not mutate the input selection (immutable contract)", () => {
|
|
const sel = makeSelection({
|
|
text_overrides: { top: { "headline.0": "before" } },
|
|
});
|
|
saveTextOverride(sel, "top", "headline.0", "after");
|
|
expect(sel.overrides.text_overrides).toEqual({
|
|
top: { "headline.0": "before" },
|
|
});
|
|
});
|
|
|
|
it("seeds an empty text_overrides on a fresh selection", () => {
|
|
const sel = createInitialUserSelection();
|
|
expect(sel.overrides.text_overrides).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("structure_overrides axis — saveStructureOverride (IMP-56 u15)", () => {
|
|
it("records a fresh (zoneId → {slot_order, hidden_slots}) tuple", () => {
|
|
const sel = makeSelection();
|
|
const next = saveStructureOverride(sel, "top", {
|
|
slot_order: ["b", "a"],
|
|
hidden_slots: ["c"],
|
|
});
|
|
expect(next.overrides.structure_overrides).toEqual({
|
|
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
|
|
});
|
|
});
|
|
|
|
it("replaces an existing zone entry verbatim (no merge within zone)", () => {
|
|
const sel = makeSelection({
|
|
structure_overrides: { top: { slot_order: ["a", "b"], hidden_slots: [] } },
|
|
});
|
|
const next = saveStructureOverride(sel, "top", {
|
|
slot_order: ["b", "a"],
|
|
hidden_slots: ["a"],
|
|
});
|
|
expect(next.overrides.structure_overrides.top).toEqual({
|
|
slot_order: ["b", "a"],
|
|
hidden_slots: ["a"],
|
|
});
|
|
});
|
|
|
|
it("keeps unrelated zones intact when updating one zone", () => {
|
|
const sel = makeSelection({
|
|
structure_overrides: {
|
|
top: { slot_order: ["x"], hidden_slots: [] },
|
|
bottom_l: { slot_order: ["y"], hidden_slots: ["z"] },
|
|
},
|
|
});
|
|
const next = saveStructureOverride(sel, "top", {
|
|
slot_order: ["x", "x2"],
|
|
hidden_slots: [],
|
|
});
|
|
expect(next.overrides.structure_overrides.bottom_l).toEqual({
|
|
slot_order: ["y"],
|
|
hidden_slots: ["z"],
|
|
});
|
|
});
|
|
|
|
it("does not mutate the input perZone object after save", () => {
|
|
const sel = makeSelection();
|
|
const perZone = { slot_order: ["a"], hidden_slots: ["b"] };
|
|
const next = saveStructureOverride(sel, "top", perZone);
|
|
perZone.slot_order.push("MUTATED");
|
|
expect(next.overrides.structure_overrides.top.slot_order).toEqual(["a"]);
|
|
});
|
|
|
|
it("seeds an empty structure_overrides on a fresh selection", () => {
|
|
const sel = createInitialUserSelection();
|
|
expect(sel.overrides.structure_overrides).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("Step-22 axes — applyPersistedNonFrameOverrides restore (IMP-56 u15)", () => {
|
|
it("layers persisted text_overrides through the u10 extract helper", () => {
|
|
const sel = makeSelection();
|
|
const next = applyPersistedNonFrameOverrides(sel, {
|
|
text_overrides: {
|
|
top: { "row_1_left_body.0": "복원" },
|
|
},
|
|
});
|
|
expect(next.overrides.text_overrides).toEqual({
|
|
top: { "row_1_left_body.0": "복원" },
|
|
});
|
|
});
|
|
|
|
it("layers persisted structure_overrides through the u10 extract helper", () => {
|
|
const sel = makeSelection();
|
|
const next = applyPersistedNonFrameOverrides(sel, {
|
|
structure_overrides: {
|
|
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
|
|
},
|
|
});
|
|
expect(next.overrides.structure_overrides).toEqual({
|
|
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
|
|
});
|
|
});
|
|
|
|
it("drops non-object payloads silently (no throw, axis stays empty)", () => {
|
|
const sel = makeSelection();
|
|
const next = applyPersistedNonFrameOverrides(sel, {
|
|
text_overrides: "garbage" as unknown as Record<string, Record<string, string>>,
|
|
structure_overrides: ["bad"] as unknown as Record<
|
|
string,
|
|
{ slot_order?: string[]; hidden_slots?: string[] }
|
|
>,
|
|
});
|
|
expect(next.overrides.text_overrides).toEqual({});
|
|
expect(next.overrides.structure_overrides).toEqual({});
|
|
});
|
|
});
|