/** * 旧快照画布的分支创建、文字样式、图标变换与恢复校验。这里只验证 legacy reducer/schema,不代表 headless 增量事务实现。 */ import { test } from "node:test"; import assert from "node:assert/strict"; import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts"; import { CanvasElement, CardElement, IconElement } from "../src/lib/canvas-elements.ts"; import { resizeFromPointer } from "../src/lib/canvas-geometry.ts"; // 每次返回一个新文本节点,避免样式/分支变更在不同测试之间共享可变实例。 function seeded() { return { ...emptyCanvas(), nodes: [makeCanvasNode("text", { x: 100, y: 200 })] }; } // 从同一父节点分别创建 text/script/image,检查连线端口、图片源、快照往返,再用一次 replace 历史整体撤销。 test("branch type selection creates the chosen text, script or uploaded image and keeps the link in one undo step", () => { for (const kind of ["text", "script", "image"]) { const original = seeded(); const src = kind === "image" ? "data:image/png;base64,YQ==" : undefined; const result = branchCanvasNode(original, original.nodes[0].id, "bottom", kind, src); assert.equal(result.nodes[1].data.kind, kind); assert.equal(result.nodes[1].data.src, src); assert.equal(result.edges[0].sourceHandle, "bottom"); assert.equal(result.edges[0].targetHandle, "top"); const restored = decodeCanvas(JSON.stringify(result)); assert.equal(restored.nodes[1].data.kind, kind); const history = canvasReducer({ past: [], present: original, future: [] }, { type: "replace", document: result }); assert.deepEqual(canvasReducer(history, { type: "undo" }).present, original); } }); // 连线释放在任意画布坐标时,新 script 直接落在该 release 点,并只增加一条边。 test("a connection released on the canvas creates the chosen node at the release point", () => { const original = seeded(); const at = { x: -300, y: 700 }; const result = branchCanvasNode(original, original.nodes[0].id, "left", "script", undefined, at); assert.deepEqual(result.nodes[1].position, at); assert.equal(result.edges.length, 1); }); // 颜色/背景/字体/行高等样式与字号可保存和遍历历史;部分样式清除只改目标键,保留未涉及颜色。 test("all text styles and font size survive serialization, undo and redo", () => { const original = seeded(); const style = { color: "#123456", background: "#fedcba", bold: true, italic: true, underline: true, align: "right", fontFamily: "serif", lineHeight: 1.8 }; const edited = canvasReducer({ past: [], present: original, future: [] }, { type: "style", id: original.nodes[0].id, style, fontSize: 32 }); const restored = decodeCanvas(JSON.stringify(edited.present)); assert.deepEqual(restored.nodes[0].data.textStyle, style); assert.equal(restored.nodes[0].data.fontSize, 32); const undo = canvasReducer(edited, { type: "undo" }); assert.equal(undo.present.nodes[0].data.textStyle, undefined); assert.equal(canvasReducer(undo, { type: "redo" }).present.nodes[0].data.textStyle.bold, true); const cleared = canvasReducer(edited, { type: "style", id: original.nodes[0].id, style: { bold: false, background: "transparent" } }); assert.equal(cleared.present.nodes[0].data.textStyle.color, style.color); assert.equal(decodeCanvas(JSON.stringify(cleared.present)).nodes[0].data.textStyle.bold, false); }); // 逐个非法样式/类型和可执行样式片段均拒绝;非法 icon ID 也不能通过旧快照恢复。 test("unsafe CSS, unknown styles and invalid icon identifiers cannot enter a restored canvas", () => { for (const style of [{ color: "url(https://example.com)" }, { background: "red" }, { fontFamily: "custom" }, { lineHeight: Infinity }, { bold: "true" }, { align: "justify" }, { align: ["left"] }, { fontFamily: ["sans"] }, { position: "fixed" }, []]) { const doc = seeded(); doc.nodes[0].data.textStyle = style; assert.throws(() => decodeCanvas(JSON.stringify(doc)), /样式/); } const icon = makeCanvasNode("icon", { x: 0, y: 0 }); icon.data.icon = ""; assert.throws(() => decodeCanvas(JSON.stringify({ ...emptyCanvas(), nodes: [icon] })), /图标/); }); // IconElement 继承通用变换而非 CardElement,ID、颜色、旋转可恢复;等比缩放有最小尺寸且不能分支。 test("icon elements inherit transforms without card branching, preserve their identity and resize proportionally", () => { const element = new IconElement({ x: 10, y: 20 }); assert(element instanceof CanvasElement); assert.equal(element instanceof CardElement, false); const icon = element.toNode(); icon.data.icon = "Rocket"; icon.data.textStyle = { color: "#cc6633" }; icon.data.rotation = 45; const result = decodeCanvas(JSON.stringify({ ...emptyCanvas(), nodes: [icon] })).nodes[0]; assert.equal(result.type, "icon"); assert.equal(result.data.icon, "Rocket"); assert.equal(result.data.textStyle.color, "#cc6633"); assert.equal(result.data.rotation, 45); icon.data.rotation = 0; const larger = resizeFromPointer(icon, "bottom-right", { x: 80, y: 80 }); assert.equal(larger.width, 160); assert.equal(larger.height, 160); const smaller = resizeFromPointer(icon, "bottom-right", { x: -1000, y: -1000 }); assert.equal(smaller.width, 24); assert.equal(smaller.height, 24); assert.throws(() => branchCanvasNode({ ...emptyCanvas(), nodes: [icon] }, icon.id, "right")); });