126 lines
6.3 KiB
JavaScript
126 lines
6.3 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { branchCanvasNode, 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);
|
|
});
|
|
|
|
|
|
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);
|
|
});
|