feat(#90): IMP-56 u1-u19 catch-up before final close (post-u20 push fix)
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
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>
This commit is contained in:
282
Front/client/tests/imp90_connect_endpoint.test.ts
Normal file
282
Front/client/tests/imp90_connect_endpoint.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
// IMP-56 (#90) u18 — vitest coverage for the vite POST /api/connect
|
||||
// middleware and its supporting mirrorDirRecursive helper.
|
||||
//
|
||||
// Scope:
|
||||
// 1) mirrorDirRecursive (pure helper):
|
||||
// - absent src → returns 0 (no-throw, no dst creation).
|
||||
// - file-only src → flat copy + count.
|
||||
// - nested src → recursive copy + count.
|
||||
// - overwrites pre-existing dst files (cel mirror semantics).
|
||||
// 2) handleConnectMirror (POST):
|
||||
// - method != POST → false (chain continues; next middleware may handle).
|
||||
// - invalid JSON / non-object body → 400.
|
||||
// - missing run_id or slug → 400.
|
||||
// - invalid run_id or slug (key gate / path traversal) → 400.
|
||||
// - final.html missing → 404.
|
||||
// - success without run-assets dir → 200, assets_copied: 0, html copy ok.
|
||||
// - success with run-assets dir → 200, assets_copied = file count, dst dir
|
||||
// populated.
|
||||
// - dstSlidesDir auto-created when celRoot/public/slides missing.
|
||||
//
|
||||
// Tests exercise the pure handler with mock req/res — no real vite server.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
handleConnectMirror,
|
||||
mirrorDirRecursive,
|
||||
} from "../../vite.config";
|
||||
|
||||
function makeMockRes() {
|
||||
const state = {
|
||||
statusCode: 0,
|
||||
headers: {} as Record<string, string>,
|
||||
body: "",
|
||||
ended: false,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
res: {
|
||||
writeHead(status: number, headers?: Record<string, string>) {
|
||||
state.statusCode = status;
|
||||
if (headers) state.headers = headers;
|
||||
},
|
||||
end(body?: string) {
|
||||
state.body = body ?? "";
|
||||
state.ended = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockReq(opts: {
|
||||
method?: string;
|
||||
}): EventEmitter & { method?: string; send: (body: string) => void } {
|
||||
const ee = new EventEmitter() as EventEmitter & {
|
||||
method?: string;
|
||||
send: (body: string) => void;
|
||||
};
|
||||
ee.method = opts.method;
|
||||
ee.send = (body: string) => {
|
||||
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
|
||||
ee.emit("end");
|
||||
};
|
||||
return ee;
|
||||
}
|
||||
|
||||
function seedRun(daRoot: string, runId: string, htmlBody: string): string {
|
||||
const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2");
|
||||
fs.mkdirSync(runDir, { recursive: true });
|
||||
const html = path.join(runDir, "final.html");
|
||||
fs.writeFileSync(html, htmlBody, "utf-8");
|
||||
return runDir;
|
||||
}
|
||||
|
||||
describe("mirrorDirRecursive (IMP-56 #90 u18)", () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-mirror-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns 0 and does not throw when src absent", () => {
|
||||
const dst = path.join(tmp, "dst");
|
||||
const n = mirrorDirRecursive(path.join(tmp, "missing"), dst);
|
||||
expect(n).toBe(0);
|
||||
expect(fs.existsSync(dst)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 0 when src exists but is a file (not a directory)", () => {
|
||||
const srcFile = path.join(tmp, "src.txt");
|
||||
fs.writeFileSync(srcFile, "x", "utf-8");
|
||||
const dst = path.join(tmp, "dst");
|
||||
const n = mirrorDirRecursive(srcFile, dst);
|
||||
expect(n).toBe(0);
|
||||
expect(fs.existsSync(dst)).toBe(false);
|
||||
});
|
||||
|
||||
it("flat-copies file entries and returns the file count", () => {
|
||||
const src = path.join(tmp, "src");
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, "a.css"), "/*a*/", "utf-8");
|
||||
fs.writeFileSync(path.join(src, "b.png"), "PNG", "utf-8");
|
||||
const dst = path.join(tmp, "dst");
|
||||
const n = mirrorDirRecursive(src, dst);
|
||||
expect(n).toBe(2);
|
||||
expect(fs.readFileSync(path.join(dst, "a.css"), "utf-8")).toBe("/*a*/");
|
||||
expect(fs.readFileSync(path.join(dst, "b.png"), "utf-8")).toBe("PNG");
|
||||
});
|
||||
|
||||
it("recurses into nested directories and counts only files", () => {
|
||||
const src = path.join(tmp, "src");
|
||||
fs.mkdirSync(path.join(src, "nested", "deep"), { recursive: true });
|
||||
fs.writeFileSync(path.join(src, "root.txt"), "r", "utf-8");
|
||||
fs.writeFileSync(path.join(src, "nested", "n.txt"), "n", "utf-8");
|
||||
fs.writeFileSync(path.join(src, "nested", "deep", "d.txt"), "d", "utf-8");
|
||||
const dst = path.join(tmp, "dst");
|
||||
const n = mirrorDirRecursive(src, dst);
|
||||
expect(n).toBe(3);
|
||||
expect(fs.readFileSync(path.join(dst, "nested", "deep", "d.txt"), "utf-8"))
|
||||
.toBe("d");
|
||||
});
|
||||
|
||||
it("overwrites pre-existing files in dst (cel mirror semantics)", () => {
|
||||
const src = path.join(tmp, "src");
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, "a.css"), "NEW", "utf-8");
|
||||
const dst = path.join(tmp, "dst");
|
||||
fs.mkdirSync(dst);
|
||||
fs.writeFileSync(path.join(dst, "a.css"), "OLD", "utf-8");
|
||||
mirrorDirRecursive(src, dst);
|
||||
expect(fs.readFileSync(path.join(dst, "a.css"), "utf-8")).toBe("NEW");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleConnectMirror (IMP-56 #90 u18)", () => {
|
||||
let daRoot: string;
|
||||
let celRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-da-"));
|
||||
celRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-cel-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(daRoot, { recursive: true, force: true });
|
||||
fs.rmSync(celRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns false (next chained) when method != POST", () => {
|
||||
const req = makeMockReq({ method: "GET" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleConnectMirror(req, res, daRoot, celRoot);
|
||||
expect(handled).toBe(false);
|
||||
expect(state.ended).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 on invalid JSON body", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleConnectMirror(req, res, daRoot, celRoot);
|
||||
expect(handled).toBe(true);
|
||||
req.send("{not-json}");
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid JSON");
|
||||
});
|
||||
|
||||
it("returns 400 when body is not a JSON object (array root)", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify(["not", "an", "object"]));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("body must be a JSON object");
|
||||
});
|
||||
|
||||
it("returns 400 when run_id or slug is missing", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "abc" })); // slug missing
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("missing run_id or slug");
|
||||
});
|
||||
|
||||
it("returns 400 when run_id contains path traversal", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "../escape", slug: "03" }));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid run_id or slug");
|
||||
});
|
||||
|
||||
it("returns 400 when slug contains a forward slash", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "valid_id", slug: "03/etc" }));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid run_id or slug");
|
||||
});
|
||||
|
||||
it("returns 404 when final.html does not exist for run_id", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "ghost_run", slug: "03" }));
|
||||
expect(state.statusCode).toBe(404);
|
||||
expect(JSON.parse(state.body).error).toBe("final.html not found");
|
||||
});
|
||||
|
||||
it("copies final.html to cel/public/slides/<slug>.html on success", () => {
|
||||
seedRun(daRoot, "mdx03_run", "<html>03</html>");
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx03_run", slug: "03" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const dstHtml = path.join(celRoot, "public", "slides", "03.html");
|
||||
expect(fs.existsSync(dstHtml)).toBe(true);
|
||||
expect(fs.readFileSync(dstHtml, "utf-8")).toBe("<html>03</html>");
|
||||
const body = JSON.parse(state.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.run_id).toBe("mdx03_run");
|
||||
expect(body.slug).toBe("03");
|
||||
expect(body.assets_copied).toBe(0);
|
||||
expect(body.html_target).toBe(dstHtml);
|
||||
});
|
||||
|
||||
it("auto-creates cel/public/slides when missing", () => {
|
||||
seedRun(daRoot, "mdx04_run", "<html>04</html>");
|
||||
expect(fs.existsSync(path.join(celRoot, "public", "slides"))).toBe(false);
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx04_run", slug: "04" }));
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(fs.existsSync(path.join(celRoot, "public", "slides", "04.html"))).toBe(true);
|
||||
});
|
||||
|
||||
it("mirrors assets/ recursively when present in the run dir", () => {
|
||||
const runDir = seedRun(daRoot, "mdx05_run", "<html>05</html>");
|
||||
fs.mkdirSync(path.join(runDir, "assets", "css"), { recursive: true });
|
||||
fs.writeFileSync(path.join(runDir, "assets", "main.css"), "*{}", "utf-8");
|
||||
fs.writeFileSync(path.join(runDir, "assets", "css", "extra.css"), "p{}", "utf-8");
|
||||
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx05_run", slug: "05" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(JSON.parse(state.body).assets_copied).toBe(2);
|
||||
expect(fs.readFileSync(path.join(celRoot, "public", "slides", "assets", "main.css"), "utf-8"))
|
||||
.toBe("*{}");
|
||||
expect(fs.readFileSync(path.join(celRoot, "public", "slides", "assets", "css", "extra.css"), "utf-8"))
|
||||
.toBe("p{}");
|
||||
});
|
||||
|
||||
it("overwrites pre-existing cel slide html (re-Connect semantics)", () => {
|
||||
seedRun(daRoot, "mdx03_run", "NEW");
|
||||
const dstSlidesDir = path.join(celRoot, "public", "slides");
|
||||
fs.mkdirSync(dstSlidesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dstSlidesDir, "03.html"), "OLD", "utf-8");
|
||||
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx03_run", slug: "03" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(fs.readFileSync(path.join(dstSlidesDir, "03.html"), "utf-8")).toBe("NEW");
|
||||
});
|
||||
});
|
||||
219
Front/client/tests/imp90_edit_mode_gating.test.tsx
Normal file
219
Front/client/tests/imp90_edit_mode_gating.test.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
// IMP-90 (#90) u12 — vitest coverage for `computeEditModeGates`, the pure
|
||||
// helper that drives SlideCanvas's mutually-exclusive gesture gating.
|
||||
// u11 introduced the `EditMode` enum + toolbar; u12 splits the prior
|
||||
// `isEditMode` shim (which fired ALL gates whenever any edit mode was
|
||||
// active) into 5 per-gate booleans:
|
||||
// textEditing — designMode + contentEditable (text mode only).
|
||||
// imageSelection — in-iframe user-content image click listener
|
||||
// (image-zone mode only).
|
||||
// iframePointerAuto — iframe pointer-events:auto so in-iframe gestures
|
||||
// (text caret OR image click) can reach the doc.
|
||||
// text mode + image-zone mode; structure stays
|
||||
// pe:none because u14 will overlay React controls.
|
||||
// zoneGestures — zone resize 8-handle ring + drag perimeter strips
|
||||
// + canDrag in handleZoneMouseDown
|
||||
// (image-zone mode only).
|
||||
// imageOverlay — React-side image edit overlay (image-zone only).
|
||||
//
|
||||
// Mutually-exclusive contract (from the issue body's "discriminated edit
|
||||
// mode"): no editMode value enables both `textEditing` and either
|
||||
// `imageSelection` or `zoneGestures` simultaneously. structure mode is
|
||||
// the no-op placeholder — u14 will plant the structure overlay there.
|
||||
// pendingLayout fully suppresses every gate (mirrors the existing
|
||||
// useEffect that forces editMode='off' on pendingLayout entry).
|
||||
//
|
||||
// Scope guard: this test exercises the pure helper only — no React
|
||||
// rendering, no DOM. testing-library/react is NOT in devDependencies
|
||||
// (verified in Front/package.json); helper-level coverage is the
|
||||
// established u11 pattern.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeEditModeGates,
|
||||
type EditMode,
|
||||
type EditModeGates,
|
||||
} from "../src/components/SlideCanvas";
|
||||
|
||||
const ALL_MODES: EditMode[] = ["off", "text", "structure", "image-zone"];
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — pendingLayout suppression", () => {
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"pendingLayout=true forces every gate false (editMode=%s)",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, true);
|
||||
expect(g).toEqual<EditModeGates>({
|
||||
textEditing: false,
|
||||
imageSelection: false,
|
||||
iframePointerAuto: false,
|
||||
zoneGestures: false,
|
||||
imageOverlay: false,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — off baseline", () => {
|
||||
it("editMode=off pendingLayout=false: every gate false", () => {
|
||||
expect(computeEditModeGates("off", false)).toEqual<EditModeGates>({
|
||||
textEditing: false,
|
||||
imageSelection: false,
|
||||
iframePointerAuto: false,
|
||||
zoneGestures: false,
|
||||
imageOverlay: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — text mode", () => {
|
||||
const g = computeEditModeGates("text", false);
|
||||
|
||||
it("textEditing = true (designMode + contentEditable activate)", () => {
|
||||
expect(g.textEditing).toBe(true);
|
||||
});
|
||||
it("iframePointerAuto = true (caret needs to reach the doc)", () => {
|
||||
expect(g.iframePointerAuto).toBe(true);
|
||||
});
|
||||
it("imageSelection = false (no in-iframe image click listener)", () => {
|
||||
expect(g.imageSelection).toBe(false);
|
||||
});
|
||||
it("zoneGestures = false (no zone resize / drag affordances)", () => {
|
||||
expect(g.zoneGestures).toBe(false);
|
||||
});
|
||||
it("imageOverlay = false (no React-side image overlay)", () => {
|
||||
expect(g.imageOverlay).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — structure mode", () => {
|
||||
const g = computeEditModeGates("structure", false);
|
||||
|
||||
// structure mode is the u14 placeholder — no gestures here yet. All five
|
||||
// gates stay false so the iframe and React overlays remain quiescent
|
||||
// until u14 plants the structure overlay on the React layer.
|
||||
it("every gate false (u14 will plant the structure overlay later)", () => {
|
||||
expect(g).toEqual<EditModeGates>({
|
||||
textEditing: false,
|
||||
imageSelection: false,
|
||||
iframePointerAuto: false,
|
||||
zoneGestures: false,
|
||||
imageOverlay: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — image-zone mode", () => {
|
||||
const g = computeEditModeGates("image-zone", false);
|
||||
|
||||
it("textEditing = false (contentEditable would steal image clicks)", () => {
|
||||
expect(g.textEditing).toBe(false);
|
||||
});
|
||||
it("imageSelection = true (in-iframe img click → selectedImageId)", () => {
|
||||
expect(g.imageSelection).toBe(true);
|
||||
});
|
||||
it("iframePointerAuto = true (so image clicks reach the doc)", () => {
|
||||
expect(g.iframePointerAuto).toBe(true);
|
||||
});
|
||||
it("zoneGestures = true (zone resize + drag affordances visible)", () => {
|
||||
expect(g.zoneGestures).toBe(true);
|
||||
});
|
||||
it("imageOverlay = true (React-side overlay renders the drag handles)", () => {
|
||||
expect(g.imageOverlay).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — mutually exclusive contract", () => {
|
||||
it("text mode never co-activates image-zone gates (imageSelection / zoneGestures / imageOverlay)", () => {
|
||||
const g = computeEditModeGates("text", false);
|
||||
expect(g.textEditing).toBe(true);
|
||||
expect(g.imageSelection).toBe(false);
|
||||
expect(g.zoneGestures).toBe(false);
|
||||
expect(g.imageOverlay).toBe(false);
|
||||
});
|
||||
|
||||
it("image-zone mode never co-activates text gates (textEditing)", () => {
|
||||
const g = computeEditModeGates("image-zone", false);
|
||||
expect(g.imageSelection).toBe(true);
|
||||
expect(g.textEditing).toBe(false);
|
||||
});
|
||||
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"for every editMode (%s), textEditing AND zoneGestures are NEVER both true",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
expect(g.textEditing && g.zoneGestures).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"for every editMode (%s), textEditing AND imageOverlay are NEVER both true",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
expect(g.textEditing && g.imageOverlay).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"for every editMode (%s), textEditing AND imageSelection are NEVER both true",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
expect(g.textEditing && g.imageSelection).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — iframePointerAuto coupling", () => {
|
||||
// pe:auto is the iframe-side prerequisite for ANY in-iframe gesture
|
||||
// (text caret OR image click). The helper must NOT advertise an
|
||||
// in-iframe gate as active while pe is none, or those gestures would
|
||||
// be silently swallowed by the wrapper.
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"textEditing → iframePointerAuto (editMode=%s)",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
if (g.textEditing) expect(g.iframePointerAuto).toBe(true);
|
||||
}
|
||||
);
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"imageSelection → iframePointerAuto (editMode=%s)",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
if (g.imageSelection) expect(g.iframePointerAuto).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — referential transparency", () => {
|
||||
it("multiple calls with the same inputs return equal output", () => {
|
||||
const a = computeEditModeGates("image-zone", false);
|
||||
const b = computeEditModeGates("image-zone", false);
|
||||
const c = computeEditModeGates("image-zone", false);
|
||||
expect(a).toEqual(b);
|
||||
expect(b).toEqual(c);
|
||||
});
|
||||
|
||||
it("does not mutate captured state across calls (independent invocations)", () => {
|
||||
const a = computeEditModeGates("text", false);
|
||||
const _b = computeEditModeGates("image-zone", false);
|
||||
// a must still reflect text mode after b's call.
|
||||
expect(a.textEditing).toBe(true);
|
||||
expect(a.imageSelection).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — gate truthtable snapshot", () => {
|
||||
// Snapshot for human-readable inspection — the per-mode flag layout
|
||||
// is the contract u13 (text capture) and u14 (structure overlay)
|
||||
// will build against. Any change requires updating both this test
|
||||
// AND the consuming gates in SlideCanvas.tsx.
|
||||
it("non-pendingLayout truthtable matches the u12 contract", () => {
|
||||
const rows = (["off", "text", "structure", "image-zone"] as EditMode[]).map(
|
||||
(m) => ({ mode: m, ...computeEditModeGates(m, false) })
|
||||
);
|
||||
expect(rows).toEqual([
|
||||
{ mode: "off", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false },
|
||||
{ mode: "text", textEditing: true, imageSelection: false, iframePointerAuto: true, zoneGestures: false, imageOverlay: false },
|
||||
{ mode: "structure", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false },
|
||||
{ mode: "image-zone", textEditing: false, imageSelection: true, iframePointerAuto: true, zoneGestures: true, imageOverlay: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
133
Front/client/tests/imp90_edit_mode_state.test.tsx
Normal file
133
Front/client/tests/imp90_edit_mode_state.test.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
// IMP-90 (#90) u11 — vitest coverage for the discriminated EditMode enum
|
||||
// and its pure transition helper `nextEditMode`. Replaces the prior single
|
||||
// `isEditMode` boolean state. u11 introduces ONLY the state surface + the
|
||||
// toolbar UI; gesture gating per mode is u12 (mutually exclusive) and must
|
||||
// not regress this contract.
|
||||
//
|
||||
// Scope (Stage 2 unit u11 contract):
|
||||
// 1) EDIT_MODES is the canonical ['text','structure','image-zone'] list
|
||||
// in toolbar render order. 'off' is intentionally excluded from the
|
||||
// iterable because it is the implicit baseline (no button); the
|
||||
// toolbar only renders the three active modes per the u11 design.
|
||||
// 2) nextEditMode is a pure (current, requested) -> EditMode mapping
|
||||
// with three rules:
|
||||
// - requested === 'off' -> 'off' (explicit exit)
|
||||
// - requested === current -> 'off' (toggle exit)
|
||||
// - requested !== current && != 'off'-> requested (mode switch)
|
||||
// 3) The helper is referentially transparent — no side effects, no
|
||||
// React, no useState, no DOM. SlideCanvas wires it as the useState
|
||||
// updater callback (`setEditMode((prev) => nextEditMode(prev, m))`),
|
||||
// so covering the helper here covers every toolbar click outcome
|
||||
// directly without DOM rendering. (@testing-library/react is NOT in
|
||||
// devDependencies; this mirrors the imp47b_human_review_toast pattern.)
|
||||
// 4) The exported EditMode type union must contain exactly the four
|
||||
// members 'off' | 'text' | 'structure' | 'image-zone'. The runtime
|
||||
// EDIT_MODES list intentionally excludes 'off' (see (1) above).
|
||||
//
|
||||
// Forward-compat note: u12 will discriminate per-mode gating but MUST NOT
|
||||
// alter the (current, requested) -> next contract verified here. Any
|
||||
// change to the toggle/switch/exit semantics is a scope-violation against
|
||||
// the u11 binding contract.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
EDIT_MODES,
|
||||
nextEditMode,
|
||||
type EditMode,
|
||||
} from "../src/components/SlideCanvas";
|
||||
|
||||
describe("EDIT_MODES (IMP-90 u11 — toolbar render order)", () => {
|
||||
it("contains exactly the three active modes in toolbar order", () => {
|
||||
expect(EDIT_MODES).toEqual(["text", "structure", "image-zone"]);
|
||||
});
|
||||
|
||||
it("excludes 'off' — baseline is implicit, no toolbar button", () => {
|
||||
expect(EDIT_MODES).not.toContain("off" as EditMode);
|
||||
});
|
||||
|
||||
it("has length 3", () => {
|
||||
expect(EDIT_MODES.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextEditMode (IMP-90 u11 — pure transition helper)", () => {
|
||||
describe("explicit 'off' request always exits", () => {
|
||||
it.each<EditMode>(["off", "text", "structure", "image-zone"])(
|
||||
"current=%s, requested=off -> off",
|
||||
(current) => {
|
||||
expect(nextEditMode(current, "off")).toBe("off");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("clicking the active mode toggles back to 'off'", () => {
|
||||
it.each<EditMode>(["text", "structure", "image-zone"])(
|
||||
"current=%s, requested=%s -> off",
|
||||
(mode) => {
|
||||
expect(nextEditMode(mode, mode)).toBe("off");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("clicking a different mode switches", () => {
|
||||
const cases: Array<[EditMode, EditMode]> = [
|
||||
["off", "text"],
|
||||
["off", "structure"],
|
||||
["off", "image-zone"],
|
||||
["text", "structure"],
|
||||
["text", "image-zone"],
|
||||
["structure", "text"],
|
||||
["structure", "image-zone"],
|
||||
["image-zone", "text"],
|
||||
["image-zone", "structure"],
|
||||
];
|
||||
it.each(cases)("current=%s, requested=%s -> requested", (current, requested) => {
|
||||
expect(nextEditMode(current, requested)).toBe(requested);
|
||||
});
|
||||
});
|
||||
|
||||
it("is referentially transparent — multiple calls with same inputs return same output", () => {
|
||||
const a = nextEditMode("text", "structure");
|
||||
const b = nextEditMode("text", "structure");
|
||||
const c = nextEditMode("text", "structure");
|
||||
expect(a).toBe("structure");
|
||||
expect(b).toBe("structure");
|
||||
expect(c).toBe("structure");
|
||||
});
|
||||
|
||||
it("never returns a value outside the EditMode union", () => {
|
||||
const all: EditMode[] = ["off", "text", "structure", "image-zone"];
|
||||
for (const current of all) {
|
||||
for (const requested of all) {
|
||||
const result = nextEditMode(current, requested);
|
||||
expect(all).toContain(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves toggle semantics under repeated identical clicks", () => {
|
||||
// off -> text -> off -> text -> off (toggle behavior)
|
||||
let m: EditMode = "off";
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("text");
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("off");
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("text");
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("off");
|
||||
});
|
||||
|
||||
it("preserves switch semantics across distinct mode clicks", () => {
|
||||
// off -> text -> structure -> image-zone -> off (via toggle)
|
||||
let m: EditMode = "off";
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("text");
|
||||
m = nextEditMode(m, "structure");
|
||||
expect(m).toBe("structure");
|
||||
m = nextEditMode(m, "image-zone");
|
||||
expect(m).toBe("image-zone");
|
||||
m = nextEditMode(m, "image-zone");
|
||||
expect(m).toBe("off");
|
||||
});
|
||||
});
|
||||
255
Front/client/tests/imp90_export_endpoint.test.ts
Normal file
255
Front/client/tests/imp90_export_endpoint.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
// IMP-56 (#90) u19 — vitest coverage for the vite POST /api/export
|
||||
// middleware and its supporting inlineAssetsAsDataUrls helper.
|
||||
//
|
||||
// Scope:
|
||||
// 1) inlineAssetsAsDataUrls (pure helper):
|
||||
// - no url(assets/...) refs → passthrough.
|
||||
// - single PNG ref → inlined as base64 data: URL with image/png mime.
|
||||
// - multiple refs → all inlined.
|
||||
// - SVG ref → image/svg+xml mime.
|
||||
// - missing asset file → left as-is (no throw, no rewrite).
|
||||
// - data:/http:/ URLs (non-asset) → untouched.
|
||||
// 2) handleExportStandalone (POST):
|
||||
// - method != POST → false (chain continues; next middleware may handle).
|
||||
// - invalid JSON / non-object body → 400.
|
||||
// - missing run_id → 400.
|
||||
// - invalid run_id (key gate / path traversal) → 400.
|
||||
// - final.html missing → 404.
|
||||
// - success → 200 with Content-Disposition: attachment; filename=...,
|
||||
// Content-Type: text/html; charset=utf-8, body = inlined HTML.
|
||||
//
|
||||
// Tests exercise the pure handler with mock req/res — no real vite server.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
handleExportStandalone,
|
||||
inlineAssetsAsDataUrls,
|
||||
} from "../../vite.config";
|
||||
|
||||
function makeMockRes() {
|
||||
const state = {
|
||||
statusCode: 0,
|
||||
headers: {} as Record<string, string>,
|
||||
body: "",
|
||||
ended: false,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
res: {
|
||||
writeHead(status: number, headers?: Record<string, string>) {
|
||||
state.statusCode = status;
|
||||
if (headers) state.headers = headers;
|
||||
},
|
||||
end(body?: string) {
|
||||
state.body = body ?? "";
|
||||
state.ended = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockReq(opts: {
|
||||
method?: string;
|
||||
}): EventEmitter & { method?: string; send: (body: string) => void } {
|
||||
const ee = new EventEmitter() as EventEmitter & {
|
||||
method?: string;
|
||||
send: (body: string) => void;
|
||||
};
|
||||
ee.method = opts.method;
|
||||
ee.send = (body: string) => {
|
||||
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
|
||||
ee.emit("end");
|
||||
};
|
||||
return ee;
|
||||
}
|
||||
|
||||
function seedRun(
|
||||
daRoot: string,
|
||||
runId: string,
|
||||
htmlBody: string,
|
||||
assets?: Record<string, Buffer | string>,
|
||||
): string {
|
||||
const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2");
|
||||
fs.mkdirSync(runDir, { recursive: true });
|
||||
const html = path.join(runDir, "final.html");
|
||||
fs.writeFileSync(html, htmlBody, "utf-8");
|
||||
if (assets) {
|
||||
for (const [rel, buf] of Object.entries(assets)) {
|
||||
const dst = path.join(runDir, "assets", rel);
|
||||
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
||||
fs.writeFileSync(dst, buf);
|
||||
}
|
||||
}
|
||||
return runDir;
|
||||
}
|
||||
|
||||
describe("inlineAssetsAsDataUrls (IMP-56 #90 u19)", () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-inline-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns html unchanged when no url(assets/...) refs are present", () => {
|
||||
const html = "<html><style>body{color:red;}</style><body>hi</body></html>";
|
||||
expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html);
|
||||
});
|
||||
|
||||
it("inlines a single PNG asset as a base64 data: URL with image/png mime", () => {
|
||||
fs.mkdirSync(path.join(tmp, "frame_x"), { recursive: true });
|
||||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
fs.writeFileSync(path.join(tmp, "frame_x", "a.png"), pngBytes);
|
||||
const html = "background: url(assets/frame_x/a.png);";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
expect(out).toContain(`url("data:image/png;base64,${pngBytes.toString("base64")}")`);
|
||||
expect(out).not.toContain("url(assets/frame_x/a.png)");
|
||||
});
|
||||
|
||||
it("inlines multiple refs across the same HTML body", () => {
|
||||
fs.mkdirSync(path.join(tmp, "f"), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, "f", "one.png"), Buffer.from("ONE"));
|
||||
fs.writeFileSync(path.join(tmp, "f", "two.png"), Buffer.from("TWO"));
|
||||
const html = "a{background:url(assets/f/one.png)} b{background:url(assets/f/two.png)}";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
expect(out).toContain(`data:image/png;base64,${Buffer.from("ONE").toString("base64")}`);
|
||||
expect(out).toContain(`data:image/png;base64,${Buffer.from("TWO").toString("base64")}`);
|
||||
});
|
||||
|
||||
it("uses image/svg+xml mime for .svg refs", () => {
|
||||
fs.mkdirSync(path.join(tmp, "f"), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, "f", "icon.svg"), "<svg/>", "utf-8");
|
||||
const html = "url(assets/f/icon.svg)";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
expect(out).toContain("data:image/svg+xml;base64,");
|
||||
});
|
||||
|
||||
it("leaves the ref untouched when the asset file is missing", () => {
|
||||
const html = "url(assets/missing/file.png)";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
expect(out).toBe(html);
|
||||
});
|
||||
|
||||
it("does not touch data: or http(s): url() values (only matches assets/...)", () => {
|
||||
const html =
|
||||
"x{background:url(data:image/png;base64,AAA)} " +
|
||||
"y{background:url(https://cdn.x/a.png)}";
|
||||
expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html);
|
||||
});
|
||||
|
||||
it("handles quoted url(...) refs (single and double quotes)", () => {
|
||||
fs.mkdirSync(path.join(tmp, "q"), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, "q", "k.png"), Buffer.from("K"));
|
||||
const html =
|
||||
"a{background:url('assets/q/k.png')} b{background:url(\"assets/q/k.png\")}";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
const data = `data:image/png;base64,${Buffer.from("K").toString("base64")}`;
|
||||
expect(out.split(data).length - 1).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleExportStandalone (IMP-56 #90 u19)", () => {
|
||||
let daRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-da-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(daRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns false (next chained) when method != POST", () => {
|
||||
const req = makeMockReq({ method: "GET" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleExportStandalone(req, res, daRoot);
|
||||
expect(handled).toBe(false);
|
||||
expect(state.ended).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 on invalid JSON body", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleExportStandalone(req, res, daRoot);
|
||||
expect(handled).toBe(true);
|
||||
req.send("{nope");
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid JSON");
|
||||
});
|
||||
|
||||
it("returns 400 when body is not a JSON object (array root)", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify(["x"]));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("body must be a JSON object");
|
||||
});
|
||||
|
||||
it("returns 400 when run_id is missing", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({}));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("missing run_id");
|
||||
});
|
||||
|
||||
it("returns 400 when run_id contains path traversal", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({ run_id: "../escape" }));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid run_id");
|
||||
});
|
||||
|
||||
it("returns 404 when final.html does not exist for run_id", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({ run_id: "ghost_run" }));
|
||||
expect(state.statusCode).toBe(404);
|
||||
expect(JSON.parse(state.body).error).toBe("final.html not found");
|
||||
});
|
||||
|
||||
it("returns 200 with text/html body + Content-Disposition on success", () => {
|
||||
seedRun(daRoot, "mdx03_run", "<html><body>03</body></html>");
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx03_run" }));
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.headers["Content-Type"]).toBe("text/html; charset=utf-8");
|
||||
expect(state.headers["Content-Disposition"]).toBe(
|
||||
'attachment; filename="mdx03_run.html"',
|
||||
);
|
||||
expect(state.body).toBe("<html><body>03</body></html>");
|
||||
});
|
||||
|
||||
it("inlines assets in final.html when run dir has assets/", () => {
|
||||
const pngBytes = Buffer.from("PNGDATA");
|
||||
seedRun(
|
||||
daRoot,
|
||||
"mdx05_run",
|
||||
"<html><body><div style=\"background: url(assets/f/x.png)\"></div></body></html>",
|
||||
{ "f/x.png": pngBytes },
|
||||
);
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx05_run" }));
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.body).toContain(
|
||||
`data:image/png;base64,${pngBytes.toString("base64")}`,
|
||||
);
|
||||
expect(state.body).not.toContain("url(assets/f/x.png)");
|
||||
});
|
||||
});
|
||||
150
Front/client/tests/imp90_structure_overlay.test.tsx
Normal file
150
Front/client/tests/imp90_structure_overlay.test.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
// IMP-90 (#90) u14 — vitest coverage for the pure helpers exported by
|
||||
// `StructureEditOverlay`. The React component itself is not rendered
|
||||
// (jsdom / @testing-library NOT in Front devDependencies — verified in
|
||||
// `Front/package.json`); we test the deterministic pieces that drive its
|
||||
// JSX: `resolveEffectiveSlotOrder` (effective-order resolution under
|
||||
// override) and `moveItem` (immutable reorder primitive).
|
||||
//
|
||||
// Upstream / downstream contracts (verified by prior units):
|
||||
// - u2 KNOWN_AXES += structure_overrides (Python backend).
|
||||
// - u3 vite allowlist += structure_overrides.
|
||||
// - u6 structure_override_resolver — inner shape locked to
|
||||
// {slot_order, hidden_slots}; frame swap REJECTED to existing
|
||||
// frames axis.
|
||||
// - u10 typed-client `StructureOverridePerZone` + extract helper.
|
||||
// - u15 (next) will debounce + PUT the emitted capture.
|
||||
//
|
||||
// u14 scope: pure helpers only. React render path is verified by Codex
|
||||
// auditor via static read of the JSX (no runtime test possible without
|
||||
// jsdom). Tests below are intentionally side-effect-free.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveEffectiveSlotOrder,
|
||||
moveItem,
|
||||
} from "../src/components/StructureEditOverlay";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// resolveEffectiveSlotOrder
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveEffectiveSlotOrder — no override", () => {
|
||||
it("returns a fresh copy of the discovered keys when slotOrder is undefined", () => {
|
||||
const discovered = ["a", "b", "c"];
|
||||
const out = resolveEffectiveSlotOrder(discovered, undefined);
|
||||
expect(out).toEqual(["a", "b", "c"]);
|
||||
expect(out).not.toBe(discovered);
|
||||
});
|
||||
it("returns a fresh copy when slotOrder is null", () => {
|
||||
const out = resolveEffectiveSlotOrder(["a", "b"], null);
|
||||
expect(out).toEqual(["a", "b"]);
|
||||
});
|
||||
it("returns a fresh copy when slotOrder is empty []", () => {
|
||||
const out = resolveEffectiveSlotOrder(["a", "b"], []);
|
||||
expect(out).toEqual(["a", "b"]);
|
||||
});
|
||||
it("handles empty discovered list (no slots in zone)", () => {
|
||||
expect(resolveEffectiveSlotOrder([], undefined)).toEqual([]);
|
||||
expect(resolveEffectiveSlotOrder([], ["x"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEffectiveSlotOrder — full override", () => {
|
||||
it("reorders all discovered keys per slotOrder", () => {
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b", "c"], ["c", "a", "b"]),
|
||||
).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
it("is idempotent when slotOrder matches discovered order", () => {
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b", "c"], ["a", "b", "c"]),
|
||||
).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEffectiveSlotOrder — partial / drift override", () => {
|
||||
it("appends missing discovered keys in backend order at the tail", () => {
|
||||
// user reordered b -> first, but c was added later by backend.
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b", "c"], ["b", "a"]),
|
||||
).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
it("drops override entries that no longer exist in discovered keys", () => {
|
||||
// user had slot 'x' before; backend dropped it.
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b"], ["x", "a", "b"]),
|
||||
).toEqual(["a", "b"]);
|
||||
});
|
||||
it("dedupes duplicate entries within slotOrder", () => {
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b", "c"], ["a", "a", "b"]),
|
||||
).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
it("dedupe + drop + append all together (stress)", () => {
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(
|
||||
["a", "b", "c", "d"],
|
||||
["d", "x", "d", "a", "ghost"],
|
||||
),
|
||||
).toEqual(["d", "a", "b", "c"]);
|
||||
});
|
||||
it("ignores non-string entries in slotOrder", () => {
|
||||
const bogus = ["a", null as unknown as string, undefined as unknown as string, "b"];
|
||||
expect(resolveEffectiveSlotOrder(["a", "b"], bogus)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// moveItem
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("moveItem — happy paths", () => {
|
||||
it("moves index 0 down by 1 (swap with index 1)", () => {
|
||||
expect(moveItem(["a", "b", "c"], 0, 1)).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
it("moves index 2 up by 1 (swap with index 1)", () => {
|
||||
expect(moveItem(["a", "b", "c"], 2, -1)).toEqual(["a", "c", "b"]);
|
||||
});
|
||||
it("moves across larger delta (swap with target)", () => {
|
||||
expect(moveItem(["a", "b", "c", "d"], 0, 2)).toEqual(["c", "b", "a", "d"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveItem — bounds", () => {
|
||||
it("no-op (fresh copy) when moving first up", () => {
|
||||
const src = ["a", "b", "c"];
|
||||
const out = moveItem(src, 0, -1);
|
||||
expect(out).toEqual(["a", "b", "c"]);
|
||||
expect(out).not.toBe(src);
|
||||
});
|
||||
it("no-op when moving last down", () => {
|
||||
expect(moveItem(["a", "b", "c"], 2, 1)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
it("no-op when index negative", () => {
|
||||
expect(moveItem(["a", "b"], -1, 1)).toEqual(["a", "b"]);
|
||||
});
|
||||
it("no-op when index past end", () => {
|
||||
expect(moveItem(["a", "b"], 5, -1)).toEqual(["a", "b"]);
|
||||
});
|
||||
it("no-op when target falls out of range from large delta", () => {
|
||||
expect(moveItem(["a", "b", "c"], 1, 99)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
it("no-op on empty array (any index)", () => {
|
||||
expect(moveItem<string>([], 0, 1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveItem — immutability", () => {
|
||||
it("never mutates the input array", () => {
|
||||
const src = ["a", "b", "c"];
|
||||
moveItem(src, 0, 1);
|
||||
expect(src).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
it("returns a new reference even when no-op", () => {
|
||||
const src = ["a", "b"];
|
||||
expect(moveItem(src, 0, -1)).not.toBe(src);
|
||||
});
|
||||
it("preserves T-typed values (number array)", () => {
|
||||
expect(moveItem([1, 2, 3], 0, 1)).toEqual([2, 1, 3]);
|
||||
});
|
||||
});
|
||||
259
Front/client/tests/imp90_text_edit_capture.test.tsx
Normal file
259
Front/client/tests/imp90_text_edit_capture.test.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
// IMP-90 (#90) u13 — vitest coverage for `deriveTextEditCapture`, the pure
|
||||
// helper that resolves a contentEditable focusout target into the
|
||||
// (zone_id, text_path, value) capture tuple emitted by SlideCanvas.
|
||||
//
|
||||
// Upstream contract (verified by prior units):
|
||||
// - u8 `src/text_path_stamper.py` stamps `data-text-path="{slot_key}.{
|
||||
// line_index}"` on every rendered text-line opening tag at Step 13.
|
||||
// - u9 wires the stamper into `render_slide` so the final.html consumed
|
||||
// by SlideCanvas's iframe carries those attributes.
|
||||
// - Phase Z slide-base wraps every zone in `.zone[data-zone-position]`
|
||||
// (verified at SlideCanvas.tsx onLoad measure block).
|
||||
//
|
||||
// u13 scope: derive the capture tuple from any descendant of a stamped
|
||||
// line, OR the stamped line itself. Non-stamped targets (slide-base
|
||||
// title/footer, decorative spans outside the zone tree) return null so
|
||||
// the focusout handler silently skips them — never crashes.
|
||||
//
|
||||
// Forward-compat note: u15 will debounce + PUT the capture; u15 MUST NOT
|
||||
// alter the (target) -> {zoneId, textPath, value} | null contract verified
|
||||
// here. Any change to the resolution semantics is a scope-violation
|
||||
// against the u13 binding contract.
|
||||
//
|
||||
// jsdom is NOT in devDependencies (verified in Front/package.json); this
|
||||
// test mocks `TextEditCaptureTarget` with structurally-typed objects per
|
||||
// the established u11/u12 pure-helper pattern.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
deriveTextEditCapture,
|
||||
type TextEditCapture,
|
||||
type TextEditCaptureTarget,
|
||||
} from "../src/components/SlideCanvas";
|
||||
|
||||
// --- minimal closest-aware mock builders -----------------------------
|
||||
// Each node only needs to know which selectors it matches and its
|
||||
// parent chain — `closest` is implemented by walking parent pointers.
|
||||
|
||||
interface MockNodeSpec {
|
||||
matches: string[];
|
||||
attrs?: Record<string, string>;
|
||||
text?: string | null;
|
||||
parent?: MockNode | null;
|
||||
}
|
||||
interface MockNode extends TextEditCaptureTarget {
|
||||
matches(sel: string): boolean;
|
||||
parent: MockNode | null;
|
||||
}
|
||||
function makeNode(spec: MockNodeSpec): MockNode {
|
||||
const node: MockNode = {
|
||||
parent: spec.parent ?? null,
|
||||
matches(sel: string) {
|
||||
return spec.matches.includes(sel);
|
||||
},
|
||||
closest(sel: string): TextEditCaptureTarget | null {
|
||||
let cur: MockNode | null = node;
|
||||
while (cur) {
|
||||
if (cur.matches(sel)) return cur;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getAttribute(name: string): string | null {
|
||||
return spec.attrs?.[name] ?? null;
|
||||
},
|
||||
textContent: spec.text === undefined ? null : spec.text,
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
// Canonical zone + line scaffold used across happy-path tests.
|
||||
// `null` for any field is preserved verbatim so edge cases (missing attr /
|
||||
// null textContent) can exercise the helper's defensive branches.
|
||||
function makeZoneLineScaffold(opts: {
|
||||
zoneId?: string | null;
|
||||
textPath?: string | null;
|
||||
lineText?: string | null;
|
||||
}) {
|
||||
const zone = makeNode({
|
||||
matches: [".zone[data-zone-position]"],
|
||||
attrs: opts.zoneId === null ? {} : { "data-zone-position": opts.zoneId ?? "top" },
|
||||
});
|
||||
const line = makeNode({
|
||||
matches: ["[data-text-path]"],
|
||||
attrs:
|
||||
opts.textPath === null
|
||||
? {}
|
||||
: { "data-text-path": opts.textPath ?? "row_1_left_body.0" },
|
||||
text: opts.lineText === undefined ? "hello world" : opts.lineText,
|
||||
parent: zone,
|
||||
});
|
||||
return { zone, line };
|
||||
}
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — null inputs / non-stamped", () => {
|
||||
it("returns null when target is null", () => {
|
||||
expect(deriveTextEditCapture(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no ancestor has data-text-path (e.g., slide title)", () => {
|
||||
const title = makeNode({
|
||||
matches: [".slide-title"],
|
||||
text: "Phase Z 슬라이드",
|
||||
});
|
||||
expect(deriveTextEditCapture(title)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the stamped line has no enclosing zone", () => {
|
||||
// Decorative line stamped by the future u8 but rendered outside a
|
||||
// zone (e.g., footer pill). u13 silently skips — caller never sees
|
||||
// a half-resolved capture.
|
||||
const orphanLine = makeNode({
|
||||
matches: ["[data-text-path]"],
|
||||
attrs: { "data-text-path": "footer.0" },
|
||||
text: "결론",
|
||||
});
|
||||
expect(deriveTextEditCapture(orphanLine)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — happy path", () => {
|
||||
it("resolves (zoneId, textPath, value) when target IS the stamped line", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: "분석 결과",
|
||||
});
|
||||
expect(deriveTextEditCapture(line)).toEqual<TextEditCapture>({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
value: "분석 결과",
|
||||
});
|
||||
});
|
||||
|
||||
it("walks up to the stamped line when target is a nested descendant", () => {
|
||||
const { zone, line } = makeZoneLineScaffold({
|
||||
zoneId: "bottom_l",
|
||||
textPath: "left_body.2",
|
||||
lineText: "wrapped",
|
||||
});
|
||||
// emulate a SPAN inside the stamped line (e.g., bold inline span)
|
||||
const innerSpan = makeNode({
|
||||
matches: ["span.highlight"],
|
||||
text: "ignored — closest walks to the line",
|
||||
parent: line,
|
||||
});
|
||||
void zone;
|
||||
expect(deriveTextEditCapture(innerSpan)).toEqual<TextEditCapture>({
|
||||
zoneId: "bottom_l",
|
||||
textPath: "left_body.2",
|
||||
value: "wrapped",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the line's textContent without HTML normalization", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "primary",
|
||||
textPath: "headline.0",
|
||||
lineText: " spaced inner words ",
|
||||
});
|
||||
// u13 trims outer whitespace but does NOT collapse interior whitespace
|
||||
// — value mirrors what user typed, modulo blur-edge trim.
|
||||
expect(deriveTextEditCapture(line)?.value).toBe("spaced inner words");
|
||||
});
|
||||
|
||||
it("returns empty string when textContent is null (edge: empty line)", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: null,
|
||||
});
|
||||
expect(deriveTextEditCapture(line)?.value).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when textContent is whitespace-only", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: " \n \t ",
|
||||
});
|
||||
expect(deriveTextEditCapture(line)?.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — missing attribute defensiveness", () => {
|
||||
it("returns null when data-text-path attribute is absent on the matched line", () => {
|
||||
// Should not happen with the u8 stamper, but a downstream mutation
|
||||
// (e.g., user pasting a fresh element) could create a stamped-class
|
||||
// node without the actual attribute. u13 stays defensive.
|
||||
const zone = makeNode({
|
||||
matches: [".zone[data-zone-position]"],
|
||||
attrs: { "data-zone-position": "top" },
|
||||
});
|
||||
const lineNoPath = makeNode({
|
||||
matches: ["[data-text-path]"],
|
||||
attrs: {},
|
||||
text: "hello",
|
||||
parent: zone,
|
||||
});
|
||||
expect(deriveTextEditCapture(lineNoPath)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when data-zone-position attribute is absent on the matched zone", () => {
|
||||
const zoneNoId = makeNode({
|
||||
matches: [".zone[data-zone-position]"],
|
||||
attrs: {},
|
||||
});
|
||||
const line = makeNode({
|
||||
matches: ["[data-text-path]"],
|
||||
attrs: { "data-text-path": "row_1_left_body.0" },
|
||||
text: "hello",
|
||||
parent: zoneNoId,
|
||||
});
|
||||
expect(deriveTextEditCapture(line)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — referential transparency", () => {
|
||||
it("multiple calls with the same target return equal captures", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: "stable",
|
||||
});
|
||||
const a = deriveTextEditCapture(line);
|
||||
const b = deriveTextEditCapture(line);
|
||||
expect(a).toEqual(b);
|
||||
expect(a).not.toBe(b); // fresh objects each call (caller-friendly)
|
||||
});
|
||||
|
||||
it("does not mutate the target element (attrs / parent / textContent unchanged)", () => {
|
||||
const { line, zone } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: "immutable",
|
||||
});
|
||||
deriveTextEditCapture(line);
|
||||
expect(line.getAttribute("data-text-path")).toBe("row_1_left_body.0");
|
||||
expect(line.textContent).toBe("immutable");
|
||||
expect(zone.getAttribute("data-zone-position")).toBe("top");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — zone id pass-through", () => {
|
||||
// u13 does not validate the zone id shape — Phase Z slide-base owns the
|
||||
// canonical zone position vocabulary, and u15 / pipeline-side resolver
|
||||
// (u4) re-validate downstream. u13 just forwards whatever the stamped
|
||||
// DOM declared.
|
||||
const ZONE_IDS = ["top", "bottom_l", "bottom_r", "primary", "secondary"];
|
||||
it.each(ZONE_IDS)("preserves zone id '%s' verbatim", (zid) => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: zid,
|
||||
textPath: `${zid}.0`,
|
||||
lineText: "x",
|
||||
});
|
||||
const cap = deriveTextEditCapture(line);
|
||||
expect(cap?.zoneId).toBe(zid);
|
||||
expect(cap?.textPath).toBe(`${zid}.0`);
|
||||
});
|
||||
});
|
||||
@@ -305,23 +305,37 @@ describe("handleGetUserOverrides (IMP-52 u3)", () => {
|
||||
// IMP-52 u4 — PUT endpoint coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => {
|
||||
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4 + IMP-56 #90 u3 allowlist sync)", () => {
|
||||
it("matches the Python KNOWN_AXES tuple in src/user_overrides_io.py", () => {
|
||||
// The on-disk schema is shared with backend pipeline fallback (u2).
|
||||
// Any drift here means a PUT could write an axis that the Python
|
||||
// load() ignores, or vice-versa, silently losing user overrides.
|
||||
// Mirror is Python-minus-`slide_css` (known IMP-45 #74 gap — the
|
||||
// frontend never writes slide_css). IMP-55 #93 u1 adds the bool
|
||||
// `manual_section_assignment` axis as a first-class allowlist entry.
|
||||
// IMP-56 #90 u3 closes the prior `slide_css` gap (IMP-45 #74) and
|
||||
// pre-wires `text_overrides` (IMP-56 #90 u1) +
|
||||
// `structure_overrides` (IMP-56 #90 u2) — full 9-axis mirror of the
|
||||
// Python tuple, same order.
|
||||
expect(KNOWN_USER_OVERRIDES_AXES).toEqual([
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
"image_overrides",
|
||||
"slide_css",
|
||||
"manual_section_assignment",
|
||||
"text_overrides",
|
||||
"structure_overrides",
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes the 3 axes added by IMP-56 #90 u3 (allowlist sync)", () => {
|
||||
// Spot-check the diff in addition to the full-equality assertion so a
|
||||
// future edit that drops one of the new axes fails with a localized
|
||||
// error rather than a 9-vs-N tuple-diff that obscures intent.
|
||||
expect(KNOWN_USER_OVERRIDES_AXES).toContain("slide_css");
|
||||
expect(KNOWN_USER_OVERRIDES_AXES).toContain("text_overrides");
|
||||
expect(KNOWN_USER_OVERRIDES_AXES).toContain("structure_overrides");
|
||||
expect(KNOWN_USER_OVERRIDES_AXES.length).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeUserOverrides (IMP-55 #93 u1) — manual_section_assignment bool axis", () => {
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
deriveUserOverridesKey,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
saveImageOverride,
|
||||
saveTextOverride,
|
||||
saveStructureOverride,
|
||||
} from "../src/utils/slidePlanUtils";
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
@@ -59,6 +61,11 @@ function makeSelection(overrides?: Partial<UserSelection["overrides"]>): UserSel
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
@@ -550,3 +557,150 @@ describe("manual_section_assignment axis — applyPersistedNonFrameOverrides (IM
|
||||
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({});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user