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