Files
Endoleap/tests/canvas-frames.test.mjs
T

123 lines
7.2 KiB
JavaScript

/**
* 旧 frameId 分区成员模型的拖入、随动与恢复测试;不是 headless ownerCanvasId 树或 linked-board 深子 canvas 的全部规则。
* 成员关系由明确 drop 决定,几何重叠本身不代表已经加入分区。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { assignFrameMembership, frameDropFeedback } from "../src/lib/canvas-frames.ts";
// frame、位于内部但尚未登记的 member,以及外部 icon;可分别检查 drop 准入和已有成员随动。
function seeded() {
const frame = makeCanvasNode("frame", { x: 100, y: 100 });
const member = makeCanvasNode("text", { x: 160, y: 160 });
const outsider = makeCanvasNode("icon", { x: 900, y: 120 });
return { frame, member, outsider, document: { ...emptyCanvas(), nodes: [frame, member, outsider] } };
}
// 把给定快照包成空历史结构,保持所有测试通过相同 reducer 入口。
function state(document) { return { past: [], present: document, future: [] }; }
// 多种内容 drop 到重叠 frame 时预览应与最终成员选择一致;较小 frame 优先,移到外部则同时显示和执行 detach。
test("drop previews match actual assignment for every content type, exits and overlapping frames", () => {
const { frame } = seeded();
const smaller = makeCanvasNode("frame", { x: 120, y: 120 }); smaller.width = 300; smaller.height = 240;
for (const kind of ["text", "script", "image", "plain-text", "plain-image", "icon"]) {
const node = makeCanvasNode(kind, { x: 140, y: 140 });
const nodes = [frame, smaller, node];
let preview = frameDropFeedback(nodes, [node.id, frame.id]);
assert.equal(preview.length, 1); assert.equal(preview[0].to, smaller.id);
assert.equal(preview[0].to, assignFrameMembership(nodes, [node.id])[2].data.frameId);
node.data.frameId = smaller.id; node.position = { x: 2000, y: 2000 };
preview = frameDropFeedback(nodes, [node.id]); assert.equal(preview[0].from, smaller.id); assert.equal(preview[0].to, undefined);
assert.equal(assignFrameMembership(nodes, [node.id])[2].data.frameId, undefined);
}
});
// 只给 dropped IDs 分配成员,拖 frame 或其他节点不能顺手收集原本重叠的内容。
test("only dropped content joins a frame; pre-existing overlapping content stays outside", () => {
const { frame, member, outsider, document } = seeded();
assert.equal(document.nodes[1].data.frameId, undefined);
const nodes = assignFrameMembership(document.nodes, [member.id]);
assert.equal(nodes[1].data.frameId, frame.id);
assert.equal(nodes[2].data.frameId, undefined);
const untouched = assignFrameMembership(document.nodes, [frame.id]);
assert.equal(untouched[1].data.frameId, undefined);
outsider.position = { x: 200, y: 200 };
assert.equal(assignFrameMembership([frame, member, outsider], [member.id])[2].data.frameId, undefined);
});
// 已有成员随 frame 同量移动,旁边 outsider 不改位置和归属;undo 恢复成员坐标与 frameId。
test("frames carry existing members without collecting newly overlapped content and undo restores positions", () => {
const { frame, member, outsider, document } = seeded();
member.data.frameId = frame.id;
let history = canvasReducer(state(document), { type: "checkpoint" });
history = canvasReducer(history, { type: "nodes", changes: [{ type: "position", id: frame.id, position: { x: 700, y: 100 } }] });
assert.deepEqual(history.present.nodes[1].position, { x: 760, y: 160 });
assert.equal(history.present.nodes[2].data.frameId, undefined);
assert.deepEqual(history.present.nodes[2].position, outsider.position);
history = canvasReducer(history, { type: "undo" });
assert.deepEqual(history.present.nodes[1].position, member.position);
assert.equal(history.present.nodes[1].data.frameId, frame.id);
});
// 已有成员拖出清除 frameId,落进另一 frame 则换归属;以同一位置比较两种节点集合。
test("dragging out detaches content and dragging between frames reassigns membership", () => {
const { frame, member } = seeded();
member.data.frameId = frame.id;
member.position = { x: 900, y: 900 };
assert.equal(assignFrameMembership([frame, member], [member.id])[1].data.frameId, undefined);
const second = makeCanvasNode("frame", { x: 800, y: 800 });
assert.equal(assignFrameMembership([frame, second, member], [member.id])[2].data.frameId, second.id);
});
// 同时上报父 frame 和成员的最终位置时,不能再把父位移叠加到成员,避免双重移动。
test("multi-selection does not move frame members twice", () => {
const { frame, member, document } = seeded();
member.data.frameId = frame.id;
const result = canvasReducer(state(document), { type: "nodes", changes: [
{ type: "position", id: frame.id, position: { x: 200, y: 200 } },
{ type: "position", id: member.id, position: { x: 260, y: 260 } },
] });
assert.deepEqual(result.present.nodes[1].position, { x: 260, y: 260 });
});
// 旧模型删除 frame 保留内容并清成员关联,undo 可恢复原 frameId;这不是 headless 删除 owning 子树的产品规则。
test("deleting a frame keeps its content and undo restores membership", () => {
const { frame, member, document } = seeded();
member.data.frameId = frame.id;
const removed = canvasReducer(state(document), { type: "nodes", changes: [{ type: "remove", id: frame.id }] });
assert.equal(removed.present.nodes.length, 2);
assert.equal(removed.present.nodes[0].data.frameId, undefined);
assert.deepEqual(removed.present.nodes[0].position, member.position);
assert.equal(canvasReducer(removed, { type: "undo" }).present.nodes[1].data.frameId, frame.id);
});
// hidden/fixed/scaled 标签、名称与 frameId 往返保存,frame 渲染层序为 -1,设置历史可撤销。
test("all label modes, frame names and memberships survive save/restore and settings undo", () => {
const { frame, member, document } = seeded();
member.data.frameId = frame.id;
for (const labelMode of ["hidden", "fixed", "scaled"]) {
const result = canvasReducer(state(document), { type: "frame", id: frame.id, title: "灵感分区", labelMode });
const restored = decodeCanvas(JSON.stringify(result.present));
assert.equal(restored.nodes[0].type, "frame");
assert.equal(restored.nodes[0].data.title, "灵感分区");
assert.equal(restored.nodes[0].data.labelMode, labelMode);
assert.equal(restored.nodes[1].data.frameId, frame.id);
assert.equal(restored.nodes[0].zIndex, -1);
assert.equal(canvasReducer(result, { type: "undo" }).present.nodes[0].data.title, frame.data.title);
}
});
// 缺失 frame、frame 作为别的内容成员、非法标签和 frame 旋转均拒绝;限制属于本旧格式。
test("invalid frame references, label modes, frame nesting and rotation are rejected", () => {
for (const mutate of [
({ member }) => { member.data.frameId = "missing"; },
({ frame, member }) => { frame.data.frameId = member.id; },
({ frame }) => { frame.data.labelMode = "unexpected"; },
({ frame }) => { frame.data.rotation = 20; },
]) {
const input = seeded(); mutate(input);
assert.throws(() => decodeCanvas(JSON.stringify(input.document)));
}
});