import { describe, expect, it } from "bun:test"; import { buildStoryboardAssetCatalog } from "@/lib/canvas/canvas-storyboard-assets"; import { reconcileStoryboardTargetConnections, storyboardComposerContent, storyboardRowReferenceNodeIds } from "@/lib/canvas/canvas-storyboard-materializer"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type StoryboardRow } from "@/types/canvas"; const node = (id: string, type: CanvasNodeType, metadata: CanvasNodeData["metadata"] = {}): CanvasNodeData => ({ id, type, title: id, position: { x: 0, y: 0 }, width: 320, height: 180, metadata, }); const row: StoryboardRow = { id: "row-1", shotNumber: 1, durationSeconds: 6, plotDescription: "雨夜追逐", dialogue: "快走", characters: [{ characterName: "林岚", characterAssetId: "character-asset" }], narrativeIntent: "", viewerPOV: "", performanceBlocking: "", shotSize: "", emotion: "", lightingAndAtmosphere: "", audioEffects: "", camera: "", motion: "", timeBeats: "", imageGenerationPrompt: "", videoMotionPrompt: "快速跟拍", mustHave: [], optionalDetails: [], continuityOut: "", negativePrompt: "", assetBindings: [{ nodeId: "prop", role: "prop", priority: 80 }], imageNodeId: "first-frame", status: "idle", }; describe("storyboard asset catalog", () => { it("sends reusable image, video, audio and character assets but excludes generated shots", () => { const assets = buildStoryboardAssetCatalog([ node("image", CanvasNodeType.Image, { content: "data:image/png;base64,x", assetCategory: "environment" }), node("video", CanvasNodeType.Video, { storageKey: "resource:video" }), node("audio", CanvasNodeType.Audio, { content: "data:audio/wav;base64,x" }), node("character", CanvasNodeType.Image, { workflowKind: "character", characterAssetId: "character-asset", characterVersionId: "v1" }), node("shot", CanvasNodeType.Image, { content: "data:image/png;base64,x", workflowKind: "shot" }), node("text", CanvasNodeType.Text, { content: "story" }), ]); expect(assets.map((asset) => [asset.id, asset.type])).toEqual([ ["image", "image"], ["video", "video"], ["audio", "audio"], ["character", "character"], ]); }); }); describe("storyboard target materializer", () => { const script = node("script", CanvasNodeType.Script, { storyboard: { rows: [row], visibleColumns: [], referenceNodeIds: ["project-style"] } }); const nodes = [script, node("project-style", CanvasNodeType.Image, { content: "style" }), node("prop", CanvasNodeType.Image, { content: "prop" }), node("character", CanvasNodeType.Image, { workflowKind: "character", characterAssetId: "character-asset" }), node("manual", CanvasNodeType.Video, { content: "video" }), node("direct-manual", CanvasNodeType.Audio, { content: "audio" }), node("first-frame", CanvasNodeType.Image, { content: "frame", workflowKind: "shot" }), node("target", CanvasNodeType.Video)]; const connections: CanvasConnection[] = [{ id: "manual-row-input", fromNodeId: "manual", toNodeId: "script", toHandleId: "row:row-1" }]; it("combines stable bindings and produces position mention tokens", () => { const references = storyboardRowReferenceNodeIds(script, row, nodes, connections, true); expect(references).toEqual(["project-style", "prop", "character", "manual", "first-frame"]); expect(storyboardComposerContent("快速跟拍", references, nodes)).toBe("参考资产:@图片1 @图片2 @角色3 @视频1 @图片4\n快速跟拍"); const withDirectManualInput = storyboardRowReferenceNodeIds(script, row, nodes, [...connections, { id: "direct", fromNodeId: "direct-manual", toNodeId: "target" }], false, "target"); expect(withDirectManualInput).toContain("direct-manual"); }); it("reconciles managed edges without deleting manual connections", () => { const manualTargetEdge: CanvasConnection = { id: "manual-target", fromNodeId: "manual", toNodeId: "target" }; const created = reconcileStoryboardTargetConnections([manualTargetEdge], script, row, "target", ["prop", "character"]); expect(created.filter((edge) => edge.relation === "storyboard-output")).toHaveLength(1); expect(created.filter((edge) => edge.relation === "storyboard-asset-reference").map((edge) => edge.fromNodeId).sort()).toEqual(["character", "prop"]); const reconciled = reconcileStoryboardTargetConnections(created, script, row, "target", ["character"]); expect(reconciled.some((edge) => edge.id === "manual-target")).toBe(true); expect(reconciled.some((edge) => edge.relation === "storyboard-asset-reference" && edge.fromNodeId === "prop")).toBe(false); }); });