feat(#79): IMP-51 image_overrides axis (u1~u11 backend stamp+CLI+CSS inject + frontend drag/resize+persistence + tests)

This commit is contained in:
2026-05-22 21:54:38 +09:00
parent bd8bcf748b
commit 6f1c7367e0
18 changed files with 2311 additions and 32 deletions

View File

@@ -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);
});
});