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"; 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] } }; } function state(document) { return { past: [], present: document, future: [] }; } 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); } }); 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); }); 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); }); 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); }); 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 }); }); 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); }); 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); } }); 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))); } });