132 lines
7.8 KiB
JavaScript
132 lines
7.8 KiB
JavaScript
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);
|
|
}
|
|
});
|