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

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