import { describe, expect, test } from "bun:test"; import { buildNodeGenerationContext } from "../src/components/canvas/canvas-node-generation"; import { buildCanvasResourceReferences, getGenerationResourceNodes, normalizeCanvasNodeMentionTokens } from "../src/lib/canvas/canvas-resource-references"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas"; function node(id: string, type: CanvasNodeType, content: string): CanvasNodeData { return { id, type, title: id, position: { x: 0, y: 0 }, width: 100, height: 100, metadata: { content }, }; } function targetNode(): CanvasNodeData { return { id: "target", type: CanvasNodeType.Video, title: "target", position: { x: 0, y: 0 }, width: 100, height: 100, metadata: { composerContent: "让 @图片1 进入画面" }, }; } function connection(fromNodeId: string, toNodeId = "target"): CanvasConnection { return { id: `connection-${fromNodeId}`, fromNodeId, toNodeId }; } describe("canvas node generation position mentions", () => { test("角色卡三视图可解析图片别名,编辑器与生成统一显示角色引用", () => { const target = targetNode(); const character = node("character", CanvasNodeType.Text, ""); character.metadata = { workflowKind: "character", characterAssetId: "character-asset", characterVersionPolicy: "current" }; const nodes = [target, character]; const connections = [connection(character.id)]; const references = buildCanvasResourceReferences(nodes, connections, target.id); const prompt = "严格参考 @图片1,保持服装一致。"; expect(normalizeCanvasNodeMentionTokens(prompt, references)).toBe("严格参考 @角色1,保持服装一致。"); const context = buildNodeGenerationContext(target.id, nodes, connections, prompt, []); expect(context.prompt).toBe("严格参考 @图片1,保持服装一致。"); expect(context.characterReferences).toEqual([{ nodeId: character.id, assetId: "character-asset", requestedVersionId: undefined }]); expect(() => buildNodeGenerationContext(target.id, nodes, connections, "参考 @图片2", [])).toThrow("@图片2"); }); test("角色卡与普通图片混合时共享图片序号,旧独立编号拒绝猜测迁移", () => { const target = targetNode(); const character = node("character", CanvasNodeType.Text, ""); character.metadata = { workflowKind: "character", characterAssetId: "character-asset" }; const image = node("scene", CanvasNodeType.Image, "data:image/png;base64,AA=="); const nodes = [target, character, image]; const connections = [connection(character.id), connection(image.id)]; const prompt = "@角色1站在@图片2场景内。"; const references = buildCanvasResourceReferences(nodes, connections, target.id); expect(normalizeCanvasNodeMentionTokens(prompt, references)).toBe(prompt); const context = buildNodeGenerationContext(target.id, nodes, connections, prompt, []); expect(context.prompt).toBe("@图片1站在@图片2场景内。"); expect(context.referenceImages.map((item) => item.id)).toEqual([image.id]); expect(context.characterReferences.map((item) => item.nodeId)).toEqual([character.id]); expect(() => buildNodeGenerationContext(target.id, nodes, connections, "@角色1站在@图片1场景内", [])).toThrow("@图片1"); }); test("混合角色、绘图和图片按连接位置编译,逆序提及不会重排资源", () => { const character = node("character", CanvasNodeType.Text, ""); character.metadata = { workflowKind: "character", characterAssetId: "character-asset" }; const drawing = node("drawing", CanvasNodeType.Drawing, ""); drawing.metadata = { drawingId: "drawing-document" }; const image = node("scene", CanvasNodeType.Image, "data:image/png;base64,AA=="); const target = targetNode(); const nodes = [target, image, drawing, character]; const connections = [character, drawing, image].map((source) => connection(source.id)); const references = buildCanvasResourceReferences(nodes, connections, target.id).filter((reference) => reference.active); expect(Object.fromEntries(references.map((reference) => [reference.nodeId, reference.label]))).toEqual({ character: "角色1", drawing: "绘图2", scene: "图片3" }); const context = buildNodeGenerationContext(target.id, nodes, connections, "@图片3背景,@绘图2@角色1保持一致", [], true); expect(context.prompt).toBe("@图片3背景,@图片2@图片1保持一致"); expect(context.referenceImageSlots.map((slot) => slot.nodeId)).toEqual([character.id, drawing.id, image.id]); expect(context.referenceImages.map((item) => item.id)).toEqual([drawing.id, image.id]); expect(() => buildNodeGenerationContext(target.id, nodes, connections, "@角色10保持一致", [], true)).toThrow("@角色10"); }); test("父图无预览时仍可继承输入,显式子图输入优先且去重", () => { const a = node("a", CanvasNodeType.Image, ""); a.metadata = { storageKey: "resource:a" }; const b = node("b", CanvasNodeType.Image, "b"); const root = node("root", CanvasNodeType.Image, ""); root.metadata = { isBatchRoot: true }; const child = node("child", CanvasNodeType.Image, ""); child.metadata = { batchRootId: root.id }; const nodes = [a, b, root, child]; const connections = [{ id: "a-root", fromNodeId: a.id, toNodeId: root.id }, { id: "group", fromNodeId: root.id, toNodeId: child.id }]; expect(getGenerationResourceNodes(child.id, nodes, connections).map((item) => item.id)).toEqual(["a"]); expect(getGenerationResourceNodes(child.id, nodes, [...connections, { id: "b-child", fromNodeId: b.id, toNodeId: child.id }, { id: "b-child-duplicate", fromNodeId: b.id, toNodeId: child.id }]).map((item) => item.id)).toEqual(["b"]); root.metadata = { isBatchRoot: true, batchRootId: child.id }; child.metadata = { isBatchRoot: true, batchRootId: root.id }; expect(getGenerationResourceNodes(child.id, nodes, [])).toEqual([]); }); test("批次子图沿父批次继承原始参考图,不把结果分组边当作参考图", () => { const a = node("a", CanvasNodeType.Image, "data:image/png;base64,a"); const b = node("b", CanvasNodeType.Image, "data:image/png;base64,b"); const root = node("root", CanvasNodeType.Image, "data:image/png;base64,result"); root.metadata = { ...root.metadata, isBatchRoot: true, batchChildIds: ["child"] }; const child = node("child", CanvasNodeType.Image, ""); child.metadata = { batchRootId: root.id, composerContent: "结合 @图片1 和 @图片2" }; const nodes = [a, b, root, child]; const connections = [ { id: "a-root", fromNodeId: a.id, toNodeId: root.id }, { id: "b-root", fromNodeId: b.id, toNodeId: root.id }, { id: "group", fromNodeId: root.id, toNodeId: child.id }, ]; const context = buildNodeGenerationContext(child.id, nodes, connections, child.metadata.composerContent!, []); expect(context.referenceImages.map((image) => image.id)).toEqual([a.id, b.id]); expect(buildCanvasResourceReferences(nodes, connections, child.id).filter((ref) => ref.active).map((ref) => ref.nodeId)).toEqual([a.id, b.id]); }); test("Agent 保存的素材引用块在编辑器和再次提交时保持相同编号", () => { const target = targetNode(); const character = node("character", CanvasNodeType.Image, "data:image/png;base64,a"); const wig = node("wig", CanvasNodeType.Image, "data:image/png;base64,b"); const voice = node("voice", CanvasNodeType.Audio, "data:audio/mpeg;base64,c"); const note = node("note", CanvasNodeType.Markdown, "导演方案"); const prompt = "一镜到底\n\n【资产参考】\n人物:@图片1\n假发:@图片2\n声音:@音频1"; target.metadata = { composerContent: prompt, prompt, referenceNodeIds: [character.id, voice.id, wig.id] }; const nodes = [target, wig, voice, note, character]; const connections = [character, voice, wig, note].map((source) => connection(source.id)); const references = buildCanvasResourceReferences(nodes, connections, target.id).filter((reference) => reference.active); expect(Object.fromEntries(references.map((reference) => [reference.nodeId, reference.label]))).toEqual({ character: "图片1", wig: "图片2", voice: "音频1", note: "文本1", }); const context = buildNodeGenerationContext(target.id, nodes, connections, prompt, [], true); expect(context.referenceImages.map((reference) => reference.id)).toEqual([character.id, wig.id]); expect(context.referenceAudios.map((reference) => reference.id)).toEqual([voice.id]); expect(context.prompt).toBe(prompt); expect(context.textCount).toBe(0); }); test("已有图片节点显式引用自身时作为图生图参考图提交", () => { const source = node("image-self", CanvasNodeType.Image, "data:image/png;base64,a"); source.metadata.composerContent = "将 @图片1 图片变清晰"; const context = buildNodeGenerationContext(source.id, [source], [], source.metadata.composerContent, []); expect(context.referenceImages.map((image) => image.id)).toEqual([source.id]); expect(context.imageCount).toBe(1); expect(context.prompt).toBe("将 @图片1 图片变清晰"); }); test("已有图片节点未显式引用自身时不自动退化为图生图", () => { const source = node("image-self", CanvasNodeType.Image, "data:image/png;base64,a"); const context = buildNodeGenerationContext(source.id, [source], [], "生成一个新的构图", []); expect(context.referenceImages).toEqual([]); expect(context.imageCount).toBe(0); expect(context.prompt).toBe("生成一个新的构图"); }); test("无法解析的画布引用会阻止静默降级为文生图", () => { const target = targetNode(); expect(() => buildNodeGenerationContext(target.id, [target], [], "将 @图片1 图片变清晰", [])).toThrow("@图片1 没有对应的画布资源"); }); test("同一个 @图片1 在换线后自动指向新的第一张图片", () => { const target = targetNode(); const imageA = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a"); const imageB = node("image-b", CanvasNodeType.Image, "data:image/png;base64,b"); const before = buildNodeGenerationContext(target.id, [imageA, target], [connection(imageA.id)], "让 @图片1 进入画面", []); const after = buildNodeGenerationContext(target.id, [imageB, target], [connection(imageB.id)], "让 @图片1 进入画面", []); expect(before.referenceImages.map((image) => image.id)).toEqual(["image-a"]); expect(after.referenceImages.map((image) => image.id)).toEqual(["image-b"]); expect(before.prompt).toBe("让 @图片1 进入画面"); expect(after.prompt).toBe("让 @图片1 进入画面"); }); test("按类型位置选择资源,提示词出现顺序不会改变槽位含义", () => { const target = targetNode(); const imageA = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a"); const audioA = node("audio-a", CanvasNodeType.Audio, "data:audio/mpeg;base64,a"); const imageB = node("image-b", CanvasNodeType.Image, "data:image/png;base64,b"); const connections = [connection(imageA.id), connection(audioA.id), connection(imageB.id)]; const context = buildNodeGenerationContext(target.id, [imageA, audioA, imageB, target], connections, "让 @图片2 配合 @音频1", []); expect(context.referenceImages.map((image) => image.id)).toEqual(["image-b"]); expect(context.referenceAudios.map((audio) => audio.id)).toEqual(["audio-a"]); expect(context.prompt).toBe("让 @图片1 配合 @音频1"); }); test("音频节点即使提示词只 @ 了文本,也会带上已连接的参考音频", () => { const target: CanvasNodeData = { id: "audio-target", type: CanvasNodeType.Audio, title: "audio-target", position: { x: 0, y: 0 }, width: 220, height: 160, metadata: { composerContent: "参考 @文本1" }, }; const source = node("audio-source", CanvasNodeType.Audio, "data:audio/mpeg;base64,a"); const note = node("note", CanvasNodeType.Text, "旁白:你好"); const context = buildNodeGenerationContext(target.id, [source, note, target], [connection(source.id, target.id), connection(note.id, target.id)], "参考 @文本1", []); expect(context.referenceAudios.map((audio) => audio.id)).toEqual(["audio-source"]); expect(context.prompt).toContain("旁白:你好"); }); test("只有 storageKey 的已生成音频也可以作为音频节点参考输入", () => { const target: CanvasNodeData = { id: "audio-target", type: CanvasNodeType.Audio, title: "audio-target", position: { x: 0, y: 0 }, width: 220, height: 160, metadata: {}, }; const source = node("audio-source", CanvasNodeType.Audio, ""); source.metadata = { storageKey: "resource:audio-source", mimeType: "audio/wav", durationMs: 2400 }; const context = buildNodeGenerationContext(target.id, [source, target], [connection(source.id, target.id)], "你好", []); expect(context.referenceAudios).toHaveLength(1); expect(context.referenceAudios[0]).toMatchObject({ id: source.id, storageKey: "resource:audio-source", type: "audio/wav" }); }); test("旧节点 token 只做读取迁移,不再进入生成提示词", () => { const target = targetNode(); const image = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a"); const context = buildNodeGenerationContext(target.id, [image, target], [connection(image.id)], "让 @[node:image-a] 进入画面", []); expect(context.referenceImages.map((item) => item.id)).toEqual(["image-a"]); expect(context.prompt).toBe("让 @图片1 进入画面"); expect(context.prompt).not.toContain("@[node:"); }); test("取消引用后的提示词不再因为悬挂 @图片1 阻断生成", () => { const target = targetNode(); const image = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a"); const { applyCanvasConnectionPromptSync } = require("../src/lib/canvas/canvas-resource-references") as typeof import("../src/lib/canvas/canvas-resource-references"); const [nextTarget] = applyCanvasConnectionPromptSync([image, target], [connection(image.id)], [image, target], []).filter((item: typeof target) => item.id === target.id); const context = buildNodeGenerationContext(nextTarget.id, [image, nextTarget], [], nextTarget.metadata?.composerContent || "", []); expect(nextTarget.metadata?.composerContent).toBe("让 进入画面"); expect(context.referenceImages).toEqual([]); expect(context.prompt).toBe("让 进入画面"); }); }); describe("canvas node generation video text references", () => { test("视频 promptOnly 下显式 @文本 引用仍内联真实内容,图片保持结构化引用", () => { const target = targetNode(); const image = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a"); const note = node("note", CanvasNodeType.Text, "角色设定:禾禾\n光影氛围:暗调"); const context = buildNodeGenerationContext(target.id, [image, note, target], [connection(image.id), connection(note.id)], "参考图:@图片1\n任务要求:@文本1", [], true); expect(context.prompt).toBe("参考图:@图片1\n任务要求:【文本1】\n\n【文本1】\n角色设定:禾禾\n光影氛围:暗调"); expect(context.referenceImages.map((item) => item.id)).toEqual(["image-a"]); expect(context.textCount).toBe(1); expect(context.imageCount).toBe(1); }); test("视频 promptOnly 下未显式引用的连线文本不会自动拼进提示词", () => { const target = targetNode(); const image = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a"); const note = node("note", CanvasNodeType.Text, "角色设定:禾禾"); const context = buildNodeGenerationContext(target.id, [image, note, target], [connection(image.id), connection(note.id)], "生成一段暗调舞蹈视频", [], true); expect(context.prompt).toBe("生成一段暗调舞蹈视频"); expect(context.referenceImages.map((item) => item.id)).toEqual(["image-a"]); expect(context.textCount).toBe(0); }); test("视频 promptOnly 非显式引用路径仍丢弃上游文本,关闭后恢复自动拼接", () => { const target = targetNode(); const note = node("note", CanvasNodeType.Text, "角色设定:禾禾"); const nodes = [note, target]; const connections = [connection(note.id)]; expect(buildNodeGenerationContext(target.id, nodes, connections, "跳舞", [], true).prompt).toBe("跳舞"); expect(buildNodeGenerationContext(target.id, nodes, connections, "跳舞", [], false).prompt).toBe("跳舞\n\n角色设定:禾禾"); }); });