Files
Endoleap/tests/canvas-geometry.test.mjs

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);
}
});