/** * 旧 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))); } });