import { test } from "node:test"; import assert from "node:assert/strict"; import { canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts"; 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: [] }; } test("node dragging updates its position; undo and redo retain the current viewport", () => { let state = seeded(); const id = state.present.nodes[0].id; 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 }); }); 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); }); 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, ""); }); 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); }); 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))); } }); 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")); }); test("undo history is bounded and new edits clear the redo stack", () => { let state = seeded(); 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); });