feat(#79): IMP-51 image_overrides axis (u1~u11 backend stamp+CLI+CSS inject + frontend drag/resize+persistence + tests)
This commit is contained in:
@@ -315,6 +315,7 @@ describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => {
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
"image_overrides",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -338,16 +339,19 @@ describe("mergeUserOverrides (IMP-52 u4)", () => {
|
||||
});
|
||||
|
||||
it("preserves foreign top-level keys in existing", () => {
|
||||
// Forward-compat: future axes (zone_sizes, image_overrides, etc.) on
|
||||
// disk must survive PUT writes that only touch the 4 in-scope axes.
|
||||
// Forward-compat: future axes (zone_sizes, schema_version, etc.) on
|
||||
// disk must survive PUT writes that only touch the 5 in-scope axes.
|
||||
// `image_overrides` is no longer a foreign key after IMP-51 #79 u2 —
|
||||
// it joined KNOWN_USER_OVERRIDES_AXES — so we probe with axes that
|
||||
// are still NOT in the allowlist.
|
||||
const existing = {
|
||||
layout: "old",
|
||||
zone_sizes: { top: 0.42 },
|
||||
image_overrides: { img1: { x: 0.1 } },
|
||||
schema_version: 2,
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { layout: "new" });
|
||||
expect(merged.zone_sizes).toEqual({ top: 0.42 });
|
||||
expect(merged.image_overrides).toEqual({ img1: { x: 0.1 } });
|
||||
expect(merged.schema_version).toBe(2);
|
||||
});
|
||||
|
||||
it("clears axis when partial value is null (explicit clear)", () => {
|
||||
@@ -359,7 +363,7 @@ describe("mergeUserOverrides (IMP-52 u4)", () => {
|
||||
|
||||
it("drops non-axis keys in partial (allowlist)", () => {
|
||||
// PUT payload may carry junk fields (typo, malicious key); allowlist
|
||||
// ensures only the 4 axes can be written to disk.
|
||||
// ensures only the 5 axes can be written to disk.
|
||||
const merged = mergeUserOverrides(
|
||||
{},
|
||||
{ layout: "x", random_key: "evil", __proto__: "x" } as Record<
|
||||
@@ -371,7 +375,7 @@ describe("mergeUserOverrides (IMP-52 u4)", () => {
|
||||
expect("random_key" in merged).toBe(false);
|
||||
});
|
||||
|
||||
it("merges all 4 axes when present in partial", () => {
|
||||
it("merges all 5 axes when present in partial", () => {
|
||||
const merged = mergeUserOverrides(
|
||||
{},
|
||||
{
|
||||
@@ -379,16 +383,47 @@ describe("mergeUserOverrides (IMP-52 u4)", () => {
|
||||
frames: { "03-1+03-2": "frame_07" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1", "03-2"] },
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
},
|
||||
);
|
||||
expect(Object.keys(merged).sort()).toEqual([
|
||||
"frames",
|
||||
"image_overrides",
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves image_overrides when absent from partial (5th axis IMP-51 #79 u2)", () => {
|
||||
// Sibling axis of layout/frames/zone_geometries/zone_sections: a PUT
|
||||
// that touches only layout must NOT erase the image_overrides map
|
||||
// already on disk. Mirrors the partial-merge invariant for the 4
|
||||
// pre-existing axes.
|
||||
const existing = {
|
||||
layout: "old",
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { layout: "new" });
|
||||
expect(merged.image_overrides).toEqual({
|
||||
"img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 },
|
||||
});
|
||||
expect(merged.layout).toBe("new");
|
||||
});
|
||||
|
||||
it("clears image_overrides when partial value is null (explicit clear)", () => {
|
||||
// Same null-sentinel contract as the 4 sibling axes — `null` removes
|
||||
// the axis from disk so the next render reverts to baseline (no
|
||||
// user image position/size override).
|
||||
const existing = {
|
||||
layout: "x",
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { image_overrides: null });
|
||||
expect("image_overrides" in merged).toBe(false);
|
||||
expect(merged.layout).toBe("x");
|
||||
});
|
||||
|
||||
it("does not mutate the existing input", () => {
|
||||
const existing = { layout: "old", frames: { a: "b" } };
|
||||
const snapshot = JSON.parse(JSON.stringify(existing));
|
||||
@@ -572,13 +607,15 @@ describe("handlePutUserOverrides (IMP-52 u4)", () => {
|
||||
});
|
||||
|
||||
it("preserves foreign top-level keys on disk (forward-compat)", () => {
|
||||
// `image_overrides` is no longer a foreign key after IMP-51 #79 u2;
|
||||
// probe with axes that are still NOT in KNOWN_USER_OVERRIDES_AXES.
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "future.json"),
|
||||
JSON.stringify({
|
||||
layout: "old",
|
||||
zone_sizes: { top: 0.42 },
|
||||
image_overrides: { img1: { x: 0.1 } },
|
||||
schema_version: 2,
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
@@ -592,10 +629,48 @@ describe("handlePutUserOverrides (IMP-52 u4)", () => {
|
||||
fs.readFileSync(path.join(overridesDir, "future.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk.zone_sizes).toEqual({ top: 0.42 });
|
||||
expect(onDisk.image_overrides).toEqual({ img1: { x: 0.1 } });
|
||||
expect(onDisk.schema_version).toBe(2);
|
||||
expect(onDisk.layout).toBe("new");
|
||||
});
|
||||
|
||||
it("persists image_overrides partial-merge and preserves sibling axes (IMP-51 #79 u2)", () => {
|
||||
// 5th axis end-to-end PUT round-trip: writing only image_overrides
|
||||
// must NOT touch the 4 sibling axes already on disk. Mirrors the
|
||||
// existing partial-merge test for layout above.
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(
|
||||
JSON.stringify({
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops non-axis payload keys (allowlist enforced at write)", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
|
||||
@@ -31,8 +31,10 @@ import type {
|
||||
} from "../src/types/designAgent";
|
||||
import {
|
||||
applyPersistedNonFrameOverrides,
|
||||
createInitialUserSelection,
|
||||
deriveUserOverridesKey,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
saveImageOverride,
|
||||
} from "../src/utils/slidePlanUtils";
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
@@ -48,6 +50,10 @@ function makeSelection(overrides?: Partial<UserSelection["overrides"]>): UserSel
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -300,3 +306,157 @@ describe("remapPersistedFramesToZoneFrames (IMP-52 u6)", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -480,6 +480,82 @@ describe("UserOverridesPartial type (IMP-52 u5)", () => {
|
||||
const b: UserOverridesPartial = { layout: null };
|
||||
const c: UserOverridesPartial = { frames: { unit: "tmpl" } };
|
||||
const d: UserOverridesPartial = {};
|
||||
expect([a, b, c, d]).toHaveLength(4);
|
||||
const e: UserOverridesPartial = {
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
};
|
||||
const f: UserOverridesPartial = { image_overrides: null };
|
||||
expect([a, b, c, d, e, f]).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// IMP-51 #79 u3 — image_overrides axis (5th axis) parity coverage
|
||||
//
|
||||
// Same debounce / coalescing / clear / per-key isolation guarantees as the
|
||||
// 4 sibling axes (layout / frames / zone_geometries / zone_sections), but
|
||||
// asserted explicitly so a regression in the type or the runtime allowlist
|
||||
// fails here instead of in a downstream u8~u11 handler.
|
||||
// ============================================================================
|
||||
|
||||
describe("saveUserOverrides (IMP-51 #79 u3) — image_overrides axis", () => {
|
||||
it("PUT body carries only image_overrides when that is the sole mutated axis", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
});
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["image_overrides"]);
|
||||
expect(body.image_overrides).toEqual({
|
||||
"img-1": { x: 10, y: 20, w: 30, h: 25 },
|
||||
});
|
||||
expect("layout" in body).toBe(false);
|
||||
expect("frames" in body).toBe(false);
|
||||
expect("zone_geometries" in body).toBe(false);
|
||||
expect("zone_sections" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("per-axis later-wins: same image_id mutated twice keeps the LAST value", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 0, y: 0, w: 50, h: 50 } },
|
||||
});
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({
|
||||
image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards null sentinel verbatim (clear all image_overrides on disk)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { image_overrides: null });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ image_overrides: null });
|
||||
});
|
||||
|
||||
it("coalesces with sibling axes in a single PUT", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({
|
||||
layout: "two_zone_split",
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user