Files
Endoleap/tests/canvas.test.mjs
T

140 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧画布快照 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;
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();
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);
});