import { test } from "node:test"; import assert from "node:assert/strict"; import { CanvasElement, CardElement, TextCardElement, ImageCardElement, ScriptCardElement, PlainTextElement, elementCapabilities } from "../src/lib/canvas-elements.ts"; import { nodeConnector, connectorBezier, nodeCenter, nodeSize, resizeFromPointer, rotateVector, rotationFromPointer } from "../src/lib/canvas-geometry.ts"; import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas } from "../src/lib/canvas.ts"; function close(actual, expected) { assert(Math.abs(actual - expected) < 0.00001, `${actual} != ${expected}`); } function transformed(node, patch) { return { ...node, ...patch, data: { ...node.data, rotation: patch.rotation ?? node.data.rotation, fontSize: patch.fontSize ?? node.data.fontSize } }; } test("card types inherit common element transforms and add branching; plain text inherits only common transforms", () => { for (const Type of [TextCardElement, ImageCardElement, ScriptCardElement]) { const element = new Type({ x: 0, y: 0 }); assert(element instanceof CanvasElement); assert(element instanceof CardElement); assert.equal(Type.capabilities.resizable, true); assert.equal(Type.capabilities.rotatable, true); assert.equal(Type.capabilities.branchable, true); } const plain = new PlainTextElement({ x: 0, y: 0 }); assert(plain instanceof CanvasElement); assert.equal(plain instanceof CardElement, false); assert.equal(elementCapabilities("plain-text").branchable, false); assert.equal(elementCapabilities("plain-text").connectable, false); assert.equal(plain.toNode().type, "plainText"); assert.throws(() => branchCanvasNode({ ...emptyCanvas(), nodes: [plain.toNode()] }, "missing", "top")); const node = plain.toNode(); assert.throws(() => branchCanvasNode({ ...emptyCanvas(), nodes: [node] }, node.id, "right"), /纯文本/); }); test("all eight handles resize cards while retaining the opposite local anchor, including rotated cards", () => { const directions = ["top", "right", "bottom", "left", "top-left", "top-right", "bottom-left", "bottom-right"]; for (const rotation of [0, 37, 90, 180, 270]) { for (const direction of directions) { const node = new TextCardElement({ x: 100, y: 200 }).toNode(); node.data.rotation = rotation; const hx = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0; const hy = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0; const delta = rotateVector({ x: hx * 60, y: hy * 40 }, rotation); const patch = resizeFromPointer(node, direction, delta); const updated = transformed(node, patch); close(patch.width, 260 + Math.abs(hx) * 60); close(patch.height, 200 + Math.abs(hy) * 40); const before = nodeCenter(node), after = nodeCenter(updated); const oldOffset = rotateVector({ x: -hx * 260 / 2, y: -hy * 200 / 2 }, rotation); const newOffset = rotateVector({ x: -hx * patch.width / 2, y: -hy * patch.height / 2 }, rotation); close(before.x + oldOffset.x, after.x + newOffset.x); close(before.y + oldOffset.y, after.y + newOffset.y); } } }); test("plain text width and height resize independently without changing the font", () => { const node = new PlainTextElement({ x: 0, y: 0 }).toNode(); const patch = resizeFromPointer(node, "bottom-right", { x: 240, y: 72 }); close(patch.width, 480); close(patch.height, 144); assert.equal(patch.fontSize, undefined); const widthOnly = resizeFromPointer(node, "bottom-right", { x: 120, y: 0 }); close(widthOnly.width, 360); close(widthOnly.height, 72); const heightOnly = resizeFromPointer(node, "bottom-right", { x: 0, y: 60 }); close(heightOnly.width, 240); close(heightOnly.height, 132); const min = resizeFromPointer(node, "bottom-right", { x: -10000, y: -10000 }); assert(min.width >= 60); assert(min.height >= 24); assert.equal(min.fontSize, undefined); }); test("rotation follows the pointer, supports 15-degree snapping and normalizes the angle", () => { const node = new TextCardElement({ x: 0, y: 0 }).toNode(); const center = nodeCenter(node); const start = { x: center.x + 100, y: center.y }; close(rotationFromPointer(node, start, { x: center.x, y: center.y + 100 }), 90); const angle = 22 * Math.PI / 180; close(rotationFromPointer(node, start, { x: center.x + 100 * Math.cos(angle), y: center.y + 100 * Math.sin(angle) }, true), 15); close(rotationFromPointer(node, start, { x: center.x, y: center.y - 100 }), 270); }); test("geometry and plain-text type survive save/restore and transforms undo as one gesture", () => { const node = new PlainTextElement({ x: 20, y: 30 }).toNode(); let state = { past: [], present: { ...emptyCanvas(), nodes: [node] }, future: [] }; state = canvasReducer(state, { type: "checkpoint" }); state = canvasReducer(state, { type: "transform", id: node.id, transform: { ...resizeFromPointer(node, "right", { x: 120, y: 0 }), rotation: 45 } }); const restored = decodeCanvas(JSON.stringify(state.present)).nodes[0]; assert.equal(restored.type, "plainText"); close(restored.width, 360); close(restored.height, 72); close(restored.data.rotation, 45); close(restored.data.fontSize, 24); const undo = canvasReducer(state, { type: "undo" }); assert.deepEqual(nodeSize(undo.present.nodes[0]), { width: 240, height: 72 }); close(undo.present.nodes[0].data.rotation, 0); }); test("old text-card drafts remain cards and invalid transform values are rejected", () => { const old = { id: "old", type: "material", position: { x: 0, y: 0 }, data: { kind: "text", title: "Old", text: "Keep" } }; const document = { ...emptyCanvas(), nodes: [old] }; const restored = decodeCanvas(JSON.stringify(document)).nodes[0]; assert.equal(restored.type, "material"); assert.equal(restored.data.rotation, 0); for (const patch of [{ width: -1, height: 100 }, { data: { ...old.data, rotation: 999 } }, { data: { ...old.data, fontSize: 1000 } }]) { assert.throws(() => decodeCanvas(JSON.stringify({ ...document, nodes: [{ ...old, ...patch }] }))); } }); test("connection endpoints stay on the exact border centers after resize and rotation", () => { const node = new TextCardElement({ x: 20, y: 30 }).toNode(); const expected = { 0: { top: [150, 30], right: [280, 130], bottom: [150, 230], left: [20, 130] }, 90: { top: [250, 130], right: [150, 260], bottom: [50, 130], left: [150, 0] }, 180: { top: [150, 230], right: [20, 130], bottom: [150, 30], left: [280, 130] }, }; for (const [rotation, sides] of Object.entries(expected)) { node.data.rotation = Number(rotation); for (const [side, point] of Object.entries(sides)) { const anchor = nodeConnector(node, side); close(anchor.point.x, point[0]); close(anchor.point.y, point[1]); close(Math.hypot(anchor.normal.x, anchor.normal.y), 1); } } node.data.rotation = 0; const resized = transformed(node, resizeFromPointer(node, "bottom-right", { x: 100, y: 50 })); assert.deepEqual(nodeConnector(resized, "right").point, { x: 380, y: 155 }); assert.deepEqual(nodeConnector(resized, "bottom").point, { x: 200, y: 280 }); }); test("Bezier paths include both exact card anchors, independent of selected handle sizes", () => { const source = new TextCardElement({ x: 20, y: 30 }).toNode(); const target = new TextCardElement({ x: 500, y: 180 }).toNode(); for (const angle of [0, 37, 90, 180, 270]) { source.data.rotation = angle; target.data.rotation = (angle + 45) % 360; const from = nodeConnector(source, "right"), to = nodeConnector(target, "left"); const path = connectorBezier(from, to); assert(path.startsWith(`M ${from.point.x},${from.point.y} C `)); assert(path.endsWith(` ${to.point.x},${to.point.y}`)); source.selected = true; target.selected = true; assert.equal(connectorBezier(nodeConnector(source, "right"), nodeConnector(target, "left")), path); } });