/** * 旧画布快照 reducer 的基本历史、节点/边、序列化与分支规则测试。past/present/future 是旧版完整快照结构。 * 这里的 checkpoint/replace 不能当作 headless 操作账本、条件增量请求或项目恢复协议的验证。 */ import { test } from "node:test"; import assert from "node:assert/strict"; import { branchCanvasNode, canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts"; // 构造两张不同卡片及一条边,返回空 past/future 的独立历史,用于比较编辑前后内容和 viewport。 function seeded() { const document = emptyCanvas(); document.nodes = [makeCanvasNode("text", { x: 20, y: 30 }), makeCanvasNode("script", { x: 400, y: 30 })]; document.edges = [{ id: "edge", source: document.nodes[0].id, target: document.nodes[1].id }]; return { past: [], present: document, future: [] }; } // checkpoint 后拖动形成一个历史起点;undo/redo 改内容位置但保留当前 viewport,视图导航不跟着旧快照回退。 test("node dragging updates its position; undo and redo retain the current viewport", () => { let state = seeded(); const id = state.present.nodes[0].id; // 显式 checkpoint 模拟手势开始;位置通知本身不构成完整手势生命周期测试。 state = canvasReducer(state, { type: "checkpoint" }); state = canvasReducer(state, { type: "nodes", changes: [{ id, type: "position", position: { x: 220, y: -30 }, dragging: true }] }); assert.deepEqual(state.present.nodes[0].position, { x: 220, y: -30 }); state = canvasReducer(state, { type: "viewport", viewport: { x: -500, y: 200, zoom: 0.5 } }); state = canvasReducer(state, { type: "undo" }); assert.deepEqual(state.present.nodes[0].position, { x: 20, y: 30 }); assert.deepEqual(state.present.viewport, { x: -500, y: 200, zoom: 0.5 }); state = canvasReducer(state, { type: "redo" }); assert.deepEqual(state.present.nodes[0].position, { x: 220, y: -30 }); }); // 删节点连同附边一起删除,随后重复边删除通知不应再追加历史;一次 undo 恢复节点与边。 test("deleting a node removes attached edges and undo restores both", () => { const initial = seeded(); const removed = canvasReducer(initial, { type: "nodes", changes: [{ id: initial.present.nodes[0].id, type: "remove" }] }); assert.equal(removed.present.nodes.length, 1); assert.equal(removed.present.edges.length, 0); const afterEdgeNotification = canvasReducer(removed, { type: "edges", changes: [{ id: "edge", type: "remove" }] }); assert.equal(afterEdgeNotification.past.length, 1); // 重复删除通知与节点删除属于同一结果,一次撤销应从原历史快照恢复二者。 const restored = canvasReducer(afterEdgeNotification, { type: "undo" }); assert.equal(restored.present.nodes.length, 2); assert.equal(restored.present.edges.length, 1); }); // 同一 checkpoint 内多次文字修改只留一个 past,undo 回到开始编辑前文字。 test("a text editing session is undoable as one operation", () => { let state = seeded(); const id = state.present.nodes[0].id; state = canvasReducer(state, { type: "checkpoint" }); state = canvasReducer(state, { type: "edit", id, text: "a" }); state = canvasReducer(state, { type: "edit", id, text: "a new idea" }); assert.equal(state.past.length, 1); assert.equal(canvasReducer(state, { type: "undo" }).present.nodes[0].data.text, ""); }); // JSON 往返保留文本、图片、连线与 viewport,selected 作为临时 UI 字段不恢复。 test("export/import retains nodes, edges, text, images and the viewport", () => { const document = seeded().present; const image = makeCanvasNode("image", { x: 80, y: 90 }, "data:image/png;base64,YQ=="); document.nodes.push(image); document.nodes[0].data.text = "hello"; document.nodes[0].selected = true; document.viewport = { x: 12, y: -28, zoom: 0.67 }; const restored = decodeCanvas(JSON.stringify(document)); assert.equal(restored.nodes[0].data.text, "hello"); assert.equal(restored.nodes[0].selected, undefined); assert.equal(restored.nodes[2].data.src, image.data.src); assert.deepEqual(restored.edges, document.edges); assert.deepEqual(restored.viewport, document.viewport); }); // 悬空端点、重复 ID、不安全图片 URL 和非法 zoom 分别构造非法输入,任一项都使整个 decode 失败。 test("import rejects dangling connections, duplicate nodes, unsafe images and invalid zoom", () => { for (const mutate of [ (doc) => { doc.edges[0].target = "missing"; }, (doc) => { doc.nodes.push(doc.nodes[0]); }, (doc) => { doc.nodes.push(makeCanvasNode("image", { x: 0, y: 0 }, "javascript:alert(1)")); }, (doc) => { doc.viewport.zoom = 0; }, ]) { // 每种破坏都使用新文档,避免上一种错误残留导致本轮因错误原因而通过。 const document = seeded().present; mutate(document); assert.throws(() => decodeCanvas(JSON.stringify(document))); } }); // 旧草稿 key 包含 workspace 与 board,不能因同名图谱跨作用域覆盖。 test("draft storage is isolated by workspace and graph", () => { assert.notEqual(canvasStorageKey("workspace-a", "board"), canvasStorageKey("workspace-b", "board")); assert.notEqual(canvasStorageKey("workspace", "board-a"), canvasStorageKey("workspace", "board-b")); }); // 50 次 replace 后 past 受限为 40;undo 产生 redo,新 add 必须清空 redo。 test("undo history is bounded and new edits clear the redo stack", () => { let state = seeded(); // replace 即使内容等价也在这条旧 reducer 路径记录历史;本例验证条目上限,不测字节容量。 for (let i = 0; i < 50; i++) state = canvasReducer(state, { type: "replace", document: { ...state.present, edges: [] } }); assert.equal(state.past.length, 40); state = canvasReducer(state, { type: "undo" }); assert.equal(state.future.length, 1); state = canvasReducer(state, { type: "nodes", changes: [{ type: "add", item: makeCanvasNode("text", { x: 0, y: 0 }) }] }); assert.equal(state.future.length, 0); }); // 四方向分支检查相对位置、选中切换、source/target ID 和相反 handle;快照可恢复,整组可撤销。 test("selected nodes can branch in all four directions with matching connection handles", () => { const opposite = { top: "bottom", right: "left", bottom: "top", left: "right" }; for (const direction of Object.keys(opposite)) { const document = emptyCanvas(); const parent = makeCanvasNode("text", { x: 100, y: 100 }); document.nodes = [parent]; const branched = branchCanvasNode(document, parent.id, direction); const child = branched.nodes[1]; if (direction === "top") assert(child.position.y < parent.position.y); if (direction === "bottom") assert(child.position.y > parent.position.y); if (direction === "left") assert(child.position.x < parent.position.x); if (direction === "right") assert(child.position.x > parent.position.x); assert.equal(child.selected, true); assert.equal(branched.nodes[0].selected, false); assert.equal(branched.edges[0].source, parent.id); assert.equal(branched.edges[0].target, child.id); assert.equal(branched.edges[0].sourceHandle, direction); assert.equal(branched.edges[0].targetHandle, opposite[direction]); assert.deepEqual(decodeCanvas(JSON.stringify(branched)).edges, branched.edges); const history = canvasReducer({ past: [], present: document, future: [] }, { type: "replace", document: branched }); assert.equal(canvasReducer(history, { type: "undo" }).present.nodes.length, 1); assert.equal(canvasReducer(history, { type: "undo" }).present.edges.length, 0); } }); // 同侧重复添加要避开已有节点,第二个新节点不能占据第一次相同位置。 test("repeated additions on the same side avoid existing nodes", () => { const document = emptyCanvas(); const parent = makeCanvasNode("text", { x: 0, y: 0 }); document.nodes = [parent]; const first = branchCanvasNode(document, parent.id, "right"); const second = branchCanvasNode(first, parent.id, "right"); assert.notDeepEqual(first.nodes[1].position, second.nodes[2].position); assert(second.nodes[2].position.y >= first.nodes[1].position.y + 200); });