将底部抓手与区域选择收成胶囊开关,精简框选悬浮栏,并在右键提供按相对布局加大边距的自适应整理。 Co-authored-by: Cursor <cursoragent@cursor.com>
92 lines
5.2 KiB
TypeScript
92 lines
5.2 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
|
|
import { canvasLayoutLane, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodesByMediaType, spreadCanvasNodes } from "@/lib/canvas/canvas-layout";
|
|
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
|
|
|
function node(id: string, type: CanvasNodeType, width = 320, height = 180, locked = false): CanvasNodeData {
|
|
return { id, type, title: id, position: { x: 100, y: 100 }, width, height, metadata: locked ? { locked: true } : undefined };
|
|
}
|
|
|
|
describe("canvas media-aware layout", () => {
|
|
test("无连线时按文本、图片、视频、音频分成纵向泳道", () => {
|
|
const text = node("text", CanvasNodeType.Text, 300, 120);
|
|
const image = node("image", CanvasNodeType.Image, 420, 260);
|
|
const video = node("video", CanvasNodeType.Video, 500, 280);
|
|
const audio = node("audio", CanvasNodeType.Audio, 360, 100);
|
|
const positions = layoutCanvasNodesByMediaType([video, text, audio, image]);
|
|
|
|
expect(positions.get("text")!.y).toBeLessThan(positions.get("image")!.y);
|
|
expect(positions.get("image")!.y).toBeLessThan(positions.get("video")!.y);
|
|
expect(positions.get("video")!.y).toBeLessThan(positions.get("audio")!.y);
|
|
expect(positions.get("image")!.y).toBeGreaterThanOrEqual(positions.get("text")!.y + text.height);
|
|
});
|
|
|
|
test("有连线时保持从左到右的拓扑层,同时分离媒体泳道", () => {
|
|
const text = node("text", CanvasNodeType.Text);
|
|
const image = node("image", CanvasNodeType.Image);
|
|
const video = node("video", CanvasNodeType.Video);
|
|
const connections: CanvasConnection[] = [
|
|
{ id: "a", fromNodeId: text.id, toNodeId: image.id },
|
|
{ id: "b", fromNodeId: image.id, toNodeId: video.id },
|
|
];
|
|
const positions = layoutCanvasFlow([video, image, text], connections);
|
|
|
|
expect(positions.get("text")!.x).toBeLessThan(positions.get("image")!.x);
|
|
expect(positions.get("image")!.x).toBeLessThan(positions.get("video")!.x);
|
|
expect(positions.get("text")!.y).toBeLessThan(positions.get("image")!.y);
|
|
expect(positions.get("image")!.y).toBeLessThan(positions.get("video")!.y);
|
|
});
|
|
|
|
test("同一拓扑层和媒体泳道中的不同尺寸节点不会重叠", () => {
|
|
const first = node("first", CanvasNodeType.Image, 420, 300);
|
|
const second = node("second", CanvasNodeType.Image, 240, 120);
|
|
const positions = layoutCanvasFlow([first, second], []);
|
|
|
|
expect(positions.get("second")!.y).toBeGreaterThanOrEqual(positions.get("first")!.y + first.height);
|
|
});
|
|
|
|
test("自动整理只返回可移动节点,并忽略选区外连接", () => {
|
|
const image = node("image", CanvasNodeType.Image);
|
|
const video = node("video", CanvasNodeType.Video);
|
|
const locked = node("locked", CanvasNodeType.Image, 320, 180, true);
|
|
const frame = node("frame", CanvasNodeType.Frame);
|
|
const positions = layoutCanvasAuto([image, video, locked, frame], [{ id: "external", fromNodeId: image.id, toNodeId: "outside" }]);
|
|
|
|
expect([...positions.keys()].sort()).toEqual(["image", "video"]);
|
|
expect(positions.get("image")!.y).toBeLessThan(positions.get("video")!.y);
|
|
});
|
|
|
|
test("节点类型映射为稳定的四类泳道", () => {
|
|
expect(canvasLayoutLane(node("script", CanvasNodeType.Script))).toBe("text");
|
|
expect(canvasLayoutLane(node("drawing", CanvasNodeType.Drawing))).toBe("image");
|
|
expect(canvasLayoutLane(node("video", CanvasNodeType.Video))).toBe("video");
|
|
expect(canvasLayoutLane(node("audio", CanvasNodeType.Audio))).toBe("audio");
|
|
});
|
|
});
|
|
|
|
describe("canvas relative-layout spread", () => {
|
|
test("keeps relative occupancy and increases the gap between neighbors", () => {
|
|
const left: CanvasNodeData = { ...node("left", CanvasNodeType.Image, 100, 80), position: { x: 0, y: 0 } };
|
|
const right: CanvasNodeData = { ...node("right", CanvasNodeType.Image, 100, 80), position: { x: 120, y: 10 } };
|
|
const positions = spreadCanvasNodes([left, right], { scale: 1.5, minGap: 40 });
|
|
|
|
expect(positions.get("left")!.x).toBe(0);
|
|
expect(positions.get("right")!.x).toBeGreaterThan(120);
|
|
expect(positions.get("right")!.x).toBeGreaterThan(positions.get("left")!.x);
|
|
expect(positions.get("right")!.x - (positions.get("left")!.x + left.width)).toBeGreaterThanOrEqual(40);
|
|
});
|
|
|
|
test("keeps a left node left of a right node after resolving overlap", () => {
|
|
const first: CanvasNodeData = { ...node("first", CanvasNodeType.Image, 120, 90), position: { x: 40, y: 20 } };
|
|
const second: CanvasNodeData = { ...node("second", CanvasNodeType.Image, 120, 90), position: { x: 80, y: 30 } };
|
|
const positions = spreadCanvasNodes([first, second], { scale: 1, minGap: 48 });
|
|
|
|
expect(positions.get("first")!.x).toBeLessThan(positions.get("second")!.x);
|
|
expect(positions.get("second")!.x - (positions.get("first")!.x + first.width)).toBeGreaterThanOrEqual(48);
|
|
});
|
|
|
|
test("returns no moves for a single node", () => {
|
|
expect(spreadCanvasNodes([node("only", CanvasNodeType.Text)]).size).toBe(0);
|
|
});
|
|
});
|