From a4585fdc2a5c8fb26ea7d2c8d0fbcef3770fa4d8 Mon Sep 17 00:00:00 2001 From: dlh <2773843782@qq.com> Date: Thu, 20 Aug 2026 22:27:23 +0800 Subject: [PATCH] =?UTF-8?q?refactor(canvas):=20=E8=8A=82=E7=82=B9=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E8=A1=A8=20-=20=E6=94=B6=E6=95=9B=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E3=80=81=E7=94=9F=E6=88=90=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E4=B8=8E=E8=BE=93=E5=85=A5=E8=AE=A1=E6=95=B0=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把三条按类型分支的语义判据收进注册表: - resourceKind 原 canvas-resource-references.ts,作为 @ 引用素材时的类型 - generationMode 原 canvas-connection-policy.ts,作为生成节点时的模式 - inputKind 原 canvas-connection-policy.ts,被容量校验计数时的类别 resourceKind 与 inputKind 刻意分成两个字段:它们语义不同——空图片节点不是素材 (不进 @ 引用),但照样计入 imageCount。合成一个会静默改掉连线容量校验。 两处必须保住的语义,均已由测试钉住: 1. 角色卡(workflowKind === character)是跨类型覆盖,对任何节点类型生效, 属于查注册表之前的前置判定,因此留在调用点而不进 per-type 定义。 2. 排除必须早于覆盖。原实现先 return 掉生成配置与背板,之后才判角色卡; 若把角色卡判定提前,带角色元数据的配置/背板节点会被多算成 characterCount。 新增 test/canvas-node-registry.test.ts(19 用例 / 77 断言)并加入 package.json 的 test 列表——该列表是显式枚举,不加进去文件不会跑。补测的原因是 canvas-resource-references.test.ts 只覆盖 collectUpstreamVideoNodes, 本次改动的 resourceKind 此前零覆盖,而这类判据出错不报错、只会让 @ 引用少收素材。 顺带内联 nodeGenerationMode(迁移后退化为单行转发)。 Co-Authored-By: Claude Fable 5 --- web/package.json | 2 +- .../lib/canvas/canvas-connection-policy.ts | 26 ++- .../lib/canvas/canvas-resource-references.ts | 10 +- .../definitions/builtin-nodes.tsx | 24 +++ web/src/lib/canvas/node-registry/index.ts | 4 +- .../canvas/node-registry/node-definition.ts | 21 ++- .../lib/canvas/node-registry/node-registry.ts | 18 ++ web/test/canvas-node-registry.test.ts | 167 ++++++++++++++++++ 8 files changed, 245 insertions(+), 27 deletions(-) create mode 100644 web/test/canvas-node-registry.test.ts diff --git a/web/package.json b/web/package.json index e5d4d1e6f..38255273b 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/lib/canvas/canvas-connection-policy.ts b/web/src/lib/canvas/canvas-connection-policy.ts index 570a8f310..072331aeb 100644 --- a/web/src/lib/canvas/canvas-connection-policy.ts +++ b/web/src/lib/canvas/canvas-connection-policy.ts @@ -1,6 +1,7 @@ import { maxModelInputCapacity, type ModelInputSummary } from "@/lib/model-selection"; +import { getNodeGenerationMode, getNodeInputKind } from "@/lib/canvas/node-registry"; import type { AiConfig } from "@/stores/use-config-store"; -import { CanvasNodeType, type CanvasConnection, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas"; +import { type CanvasConnection, type CanvasNodeData } from "@/types/canvas"; type ConnectionCandidate = Pick; type CanvasConnectionPolicyOptions = { @@ -11,7 +12,7 @@ type CanvasConnectionPolicyOptions = { export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[], connections: CanvasConnection[], candidate: ConnectionCandidate, options: CanvasConnectionPolicyOptions = {}) { const target = nodes.find((node) => node.id === candidate.toNodeId); if (!target) return "找不到连线目标节点"; - const mode = nodeGenerationMode(target); + const mode = getNodeGenerationMode(target); if (!mode) return ""; const input = connectionInputSummary(target.id, nodes, connections, candidate); const visualInputCount = input.imageCount + input.characterCount; @@ -35,25 +36,18 @@ export function connectionInputSummary(targetNodeId: string, nodes: CanvasNodeDa const input: ModelInputSummary = { textCount: 0, imageCount: 0, videoCount: 0, audioCount: 0, characterCount: 0 }; sourceIds.forEach((sourceId) => { const source = nodes.find((node) => node.id === sourceId); - if (!source || source.type === CanvasNodeType.Config || source.type === CanvasNodeType.Frame) return; + if (!source) return; + // 生成配置与背板不是参考素材,不参与容量计数——这一步必须早于角色卡判定, + // 否则一个带角色元数据的配置/背板节点会被多算成角色。 + const inputKind = getNodeInputKind(source.type); + if (!inputKind) return; + // 角色卡是跨类型覆盖:落在可计数类型上时改记为角色。 if (source.metadata?.workflowKind === "character") input.characterCount += 1; - else if (source.type === CanvasNodeType.Image || source.type === CanvasNodeType.Drawing) input.imageCount += 1; - else if (source.type === CanvasNodeType.Video) input.videoCount += 1; - else if (source.type === CanvasNodeType.Audio) input.audioCount += 1; - else input.textCount += 1; + else input[`${inputKind}Count`] += 1; }); return input; } -function nodeGenerationMode(node: CanvasNodeData): CanvasGenerationMode | null { - if (node.type === CanvasNodeType.Config) return node.metadata?.generationMode || "image"; - if (node.type === CanvasNodeType.Image) return "image"; - if (node.type === CanvasNodeType.Video) return "video"; - if (node.type === CanvasNodeType.Audio) return "audio"; - if (node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Script) return "text"; - return null; -} - function capacityError(config: AiConfig, capability: "image" | "video", kind: "image" | "video" | "audio", count: number, label: string) { const maximum = maxModelInputCapacity(config, capability, kind); if (maximum === null || count <= maximum) return ""; diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts index 69f7ad913..7f300833c 100644 --- a/web/src/lib/canvas/canvas-resource-references.ts +++ b/web/src/lib/canvas/canvas-resource-references.ts @@ -1,4 +1,5 @@ import { imageReferenceLabel } from "@/lib/image-reference-prompt"; +import { getNodeResourceKind } from "@/lib/canvas/node-registry"; import { seedanceReferenceLabel } from "@/lib/seedance-video"; import type { Skill } from "@/services/api/skills"; import type { Asset, AssetCategory } from "@/stores/use-asset-store"; @@ -146,14 +147,9 @@ function isResourceNode(node: CanvasNodeData) { } function resourceKind(node: CanvasNodeData): CanvasResourceKind | null { + // 角色卡是跨类型覆盖:任何节点带上角色元数据都按角色处理,故先于按类型判定。 if (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId) return "character"; - if (node.type === CanvasNodeType.Image && node.metadata?.content) return "image"; - if (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId) return "image"; - if (node.type === CanvasNodeType.Video && node.metadata?.content) return "video"; - if (node.type === CanvasNodeType.Audio && node.metadata?.content) return "audio"; - if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return "text"; - if (node.type === CanvasNodeType.Skill && (node.metadata?.skillSnapshot || node.metadata?.content)) return "text"; - return null; + return getNodeResourceKind(node); } function skillResourceText(node: CanvasNodeData) { diff --git a/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx b/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx index 8af8ab33f..d4470f5e0 100644 --- a/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx +++ b/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx @@ -26,18 +26,27 @@ const BUILTIN_NODE_TRAITS = { minSize: MEDIA_NODE_MIN_SIZE, keepAspectRatio: (node: CanvasNodeData) => !node.metadata?.freeResize, showInCreateMenu: true, + resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "image" : null), + generationMode: () => "image", + inputKind: "image", }, [CanvasNodeType.Text]: { label: "文本", icon: , minSize: DEFAULT_MIN_SIZE, showInCreateMenu: true, + resourceKind: (node: CanvasNodeData) => (node.metadata?.content || node.metadata?.prompt ? "text" : null), + generationMode: () => "text", + inputKind: "text", }, [CanvasNodeType.Drawing]: { label: "绘图", icon: , minSize: DEFAULT_MIN_SIZE, showInCreateMenu: true, + // 绘图产出的是图像,所以作为素材与输入都按图片计。 + resourceKind: (node: CanvasNodeData) => (node.metadata?.drawingId ? "image" : null), + inputKind: "image", }, [CanvasNodeType.Script]: { label: "分镜脚本", @@ -45,6 +54,8 @@ const BUILTIN_NODE_TRAITS = { // 分镜脚本的表格布局需要更宽的下限;高度仍由内容动态撑开(见 canvas-node.tsx)。 minSize: { width: 800, height: DEFAULT_MIN_SIZE.height }, showInCreateMenu: true, + generationMode: () => "text", + inputKind: "text", }, [CanvasNodeType.Skill]: { label: "技能", @@ -52,6 +63,9 @@ const BUILTIN_NODE_TRAITS = { minSize: DEFAULT_MIN_SIZE, // 技能节点由技能库插入,不占创建菜单格位。 showInCreateMenu: false, + // 技能注入的是提示词文本,故按文本素材计。 + resourceKind: (node: CanvasNodeData) => (node.metadata?.skillSnapshot || node.metadata?.content ? "text" : null), + inputKind: "text", }, [CanvasNodeType.Config]: { label: "生成配置", @@ -59,6 +73,9 @@ const BUILTIN_NODE_TRAITS = { minSize: DEFAULT_MIN_SIZE, // 生成配置由连线时自动创建。 showInCreateMenu: false, + // 生成模式由用户在配置节点上选择,缺省按图片。 + generationMode: (node: CanvasNodeData) => node.metadata?.generationMode || "image", + // 不设 inputKind:配置节点本身不是参考素材,不参与容量计数。 }, [CanvasNodeType.Video]: { label: "视频", @@ -66,12 +83,18 @@ const BUILTIN_NODE_TRAITS = { minSize: MEDIA_NODE_MIN_SIZE, keepAspectRatio: () => true, showInCreateMenu: true, + resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "video" : null), + generationMode: () => "video", + inputKind: "video", }, [CanvasNodeType.Audio]: { label: "音频", icon: , minSize: DEFAULT_MIN_SIZE, showInCreateMenu: true, + resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "audio" : null), + generationMode: () => "audio", + inputKind: "audio", }, [CanvasNodeType.Frame]: { label: "背板", @@ -80,6 +103,7 @@ const BUILTIN_NODE_TRAITS = { icon: , minSize: DEFAULT_MIN_SIZE, showInCreateMenu: true, + // 背板只是视觉容器,既不是素材也不参与计数。 }, } satisfies Record>; diff --git a/web/src/lib/canvas/node-registry/index.ts b/web/src/lib/canvas/node-registry/index.ts index b76db78f9..8c8d0dc79 100644 --- a/web/src/lib/canvas/node-registry/index.ts +++ b/web/src/lib/canvas/node-registry/index.ts @@ -1,3 +1,3 @@ -export type { CanvasNodeDefinition } from "./node-definition"; -export { getNodeDefinition, getNodeIcon, getNodeLabel, getNodeListLabel, getNodeMinSize, getNodeOwnerId, listCreatableNodeDefinitions, listNodeDefinitions, registerNodeDefinitions, shouldKeepAspectRatio, unregisterNodeDefinitions } from "./node-registry"; +export type { CanvasNodeDefinition, CanvasNodeInputKind } from "./node-definition"; +export { getNodeDefinition, getNodeGenerationMode, getNodeIcon, getNodeInputKind, getNodeLabel, getNodeListLabel, getNodeMinSize, getNodeOwnerId, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, registerNodeDefinitions, shouldKeepAspectRatio, unregisterNodeDefinitions } from "./node-registry"; import "./definitions"; diff --git a/web/src/lib/canvas/node-registry/node-definition.ts b/web/src/lib/canvas/node-registry/node-definition.ts index 25a7cd021..5301d6d37 100644 --- a/web/src/lib/canvas/node-registry/node-definition.ts +++ b/web/src/lib/canvas/node-registry/node-definition.ts @@ -1,6 +1,10 @@ import type { ReactNode } from "react"; -import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeType } from "@/types/canvas"; +import type { CanvasResourceKind } from "@/lib/canvas/canvas-resource-references"; +import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasNodeType } from "@/types/canvas"; + +/** 作为上游输入被容量校验计数时归入的类别 */ +export type CanvasNodeInputKind = "image" | "video" | "audio" | "text"; /** * 节点定义——注册表的基本单元。 @@ -26,4 +30,19 @@ export type CanvasNodeDefinition = { keepAspectRatio?: (node: CanvasNodeData) => boolean; /** 是否出现在添加节点菜单(技能、生成配置由其他入口创建) */ showInCreateMenu: boolean; + /** + * 作为 @ 引用素材时归入的类型;不设或返回 null 表示该节点不是可引用素材。 + * 判定依赖内容——空节点不构成素材。 + * + * 注意:角色卡(workflowKind === "character")是**跨类型覆盖**,不在这里表达, + * 由 canvas-resource-references 在查注册表之前先行判定。 + */ + resourceKind?: (node: CanvasNodeData) => CanvasResourceKind | null; + /** 作为生成节点时的生成模式;不设表示该类型不产生生成行为 */ + generationMode?: (node: CanvasNodeData) => CanvasGenerationMode | null; + /** + * 作为上游输入被参考素材容量校验计数时的类别; + * 不设表示不参与计数(生成配置、背板)。与 resourceKind 不同,计数不看内容。 + */ + inputKind?: CanvasNodeInputKind; }; diff --git a/web/src/lib/canvas/node-registry/node-registry.ts b/web/src/lib/canvas/node-registry/node-registry.ts index 675ddf831..c43515506 100644 --- a/web/src/lib/canvas/node-registry/node-registry.ts +++ b/web/src/lib/canvas/node-registry/node-registry.ts @@ -72,3 +72,21 @@ export function getNodeMinSize(type: CanvasNodeType) { export function shouldKeepAspectRatio(node: CanvasNodeData) { return definitions.get(node.type)?.keepAspectRatio?.(node) ?? false; } + +/** + * 该节点作为 @ 引用素材时的类型,非素材返回 null。 + * 只按类型判定——角色卡那类跨类型覆盖由调用方在此之前处理。 + */ +export function getNodeResourceKind(node: CanvasNodeData) { + return definitions.get(node.type)?.resourceKind?.(node) ?? null; +} + +/** 该节点作为生成节点时的生成模式,不产生生成行为返回 null */ +export function getNodeGenerationMode(node: CanvasNodeData) { + return definitions.get(node.type)?.generationMode?.(node) ?? null; +} + +/** 该节点作为上游输入被计数时的类别,不参与计数返回 undefined */ +export function getNodeInputKind(type: CanvasNodeType) { + return definitions.get(type)?.inputKind; +} diff --git a/web/test/canvas-node-registry.test.ts b/web/test/canvas-node-registry.test.ts new file mode 100644 index 000000000..508ba2a14 --- /dev/null +++ b/web/test/canvas-node-registry.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test"; + +import { getNodeGenerationMode, getNodeInputKind, getNodeListLabel, getNodeMinSize, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, shouldKeepAspectRatio } from "../src/lib/canvas/node-registry"; +import { connectionInputSummary } from "../src/lib/canvas/canvas-connection-policy"; +import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata } from "../src/types/canvas"; + +function node(type: CanvasNodeType, metadata?: CanvasNodeMetadata, id = type): CanvasNodeData { + return { id, type, title: id, position: { x: 0, y: 0 }, width: 100, height: 100, metadata }; +} + +const ALL_TYPES = Object.values(CanvasNodeType); + +describe("节点注册表——覆盖完整性", () => { + test("每种节点类型都有定义", () => { + expect(listNodeDefinitions().length).toBe(ALL_TYPES.length); + for (const type of ALL_TYPES) expect(getNodeMinSize(type)).toBeDefined(); + }); + + test("仅技能与生成配置不进创建菜单", () => { + const hidden = ALL_TYPES.filter((type) => !listCreatableNodeDefinitions().some((def) => def.type === type)); + expect(hidden.sort()).toEqual([CanvasNodeType.Config, CanvasNodeType.Skill].sort()); + }); +}); + +describe("节点注册表——几何", () => { + test("最小尺寸按类型区分", () => { + expect(getNodeMinSize(CanvasNodeType.Image)).toEqual({ width: 420, height: 236 }); + expect(getNodeMinSize(CanvasNodeType.Video)).toEqual({ width: 420, height: 236 }); + expect(getNodeMinSize(CanvasNodeType.Script).width).toBe(800); + expect(getNodeMinSize(CanvasNodeType.Text)).toEqual({ width: 220, height: 160 }); + }); + + test("仅图片(未开自由比例)与视频锁定宽高比", () => { + expect(shouldKeepAspectRatio(node(CanvasNodeType.Image))).toBe(true); + expect(shouldKeepAspectRatio(node(CanvasNodeType.Image, { freeResize: true }))).toBe(false); + expect(shouldKeepAspectRatio(node(CanvasNodeType.Video))).toBe(true); + expect(shouldKeepAspectRatio(node(CanvasNodeType.Video, { freeResize: true }))).toBe(true); + for (const type of [CanvasNodeType.Text, CanvasNodeType.Drawing, CanvasNodeType.Script, CanvasNodeType.Skill, CanvasNodeType.Config, CanvasNodeType.Audio, CanvasNodeType.Frame]) { + expect(shouldKeepAspectRatio(node(type))).toBe(false); + } + }); +}); + +describe("节点注册表——素材类型(resourceKind)", () => { + test("有内容才算素材,空节点不算", () => { + expect(getNodeResourceKind(node(CanvasNodeType.Image, { content: "x" }))).toBe("image"); + expect(getNodeResourceKind(node(CanvasNodeType.Image))).toBeNull(); + expect(getNodeResourceKind(node(CanvasNodeType.Video, { content: "x" }))).toBe("video"); + expect(getNodeResourceKind(node(CanvasNodeType.Video))).toBeNull(); + expect(getNodeResourceKind(node(CanvasNodeType.Audio, { content: "x" }))).toBe("audio"); + expect(getNodeResourceKind(node(CanvasNodeType.Audio))).toBeNull(); + }); + + test("绘图按图片计,且认的是 drawingId 而非 content", () => { + expect(getNodeResourceKind(node(CanvasNodeType.Drawing, { drawingId: "d1" }))).toBe("image"); + expect(getNodeResourceKind(node(CanvasNodeType.Drawing, { content: "x" }))).toBeNull(); + }); + + test("文本认 content 或 prompt", () => { + expect(getNodeResourceKind(node(CanvasNodeType.Text, { content: "x" }))).toBe("text"); + expect(getNodeResourceKind(node(CanvasNodeType.Text, { prompt: "p" }))).toBe("text"); + expect(getNodeResourceKind(node(CanvasNodeType.Text))).toBeNull(); + }); + + test("技能按文本计,认 skillSnapshot 或 content", () => { + expect(getNodeResourceKind(node(CanvasNodeType.Skill, { content: "x" }))).toBe("text"); + expect(getNodeResourceKind(node(CanvasNodeType.Skill))).toBeNull(); + }); + + test("分镜脚本、生成配置、背板都不是素材", () => { + for (const type of [CanvasNodeType.Script, CanvasNodeType.Config, CanvasNodeType.Frame]) { + expect(getNodeResourceKind(node(type, { content: "x" }))).toBeNull(); + } + }); +}); + +describe("节点注册表——生成模式(generationMode)", () => { + test("按类型映射", () => { + expect(getNodeGenerationMode(node(CanvasNodeType.Image))).toBe("image"); + expect(getNodeGenerationMode(node(CanvasNodeType.Video))).toBe("video"); + expect(getNodeGenerationMode(node(CanvasNodeType.Audio))).toBe("audio"); + expect(getNodeGenerationMode(node(CanvasNodeType.Text))).toBe("text"); + expect(getNodeGenerationMode(node(CanvasNodeType.Script))).toBe("text"); + }); + + test("生成配置由 metadata 决定,缺省按图片", () => { + expect(getNodeGenerationMode(node(CanvasNodeType.Config, { generationMode: "video" }))).toBe("video"); + expect(getNodeGenerationMode(node(CanvasNodeType.Config))).toBe("image"); + }); + + test("绘图、技能、背板不产生生成行为", () => { + for (const type of [CanvasNodeType.Drawing, CanvasNodeType.Skill, CanvasNodeType.Frame]) { + expect(getNodeGenerationMode(node(type))).toBeNull(); + } + }); +}); + +describe("节点注册表——输入计数类别(inputKind)", () => { + test("生成配置与背板不参与计数,其余各归其类", () => { + expect(getNodeInputKind(CanvasNodeType.Config)).toBeUndefined(); + expect(getNodeInputKind(CanvasNodeType.Frame)).toBeUndefined(); + expect(getNodeInputKind(CanvasNodeType.Image)).toBe("image"); + expect(getNodeInputKind(CanvasNodeType.Drawing)).toBe("image"); + expect(getNodeInputKind(CanvasNodeType.Video)).toBe("video"); + expect(getNodeInputKind(CanvasNodeType.Audio)).toBe("audio"); + for (const type of [CanvasNodeType.Text, CanvasNodeType.Script, CanvasNodeType.Skill]) { + expect(getNodeInputKind(type)).toBe("text"); + } + }); +}); + +describe("connectionInputSummary——计数与跨类型覆盖", () => { + const conn = (fromNodeId: string, toNodeId: string): CanvasConnection => ({ id: `c-${fromNodeId}`, fromNodeId, toNodeId }); + + test("与内容无关:空图片节点仍计入 imageCount", () => { + const nodes = [node(CanvasNodeType.Image, undefined, "img"), node(CanvasNodeType.Text, undefined, "target")]; + expect(connectionInputSummary("target", nodes, [conn("img", "target")]).imageCount).toBe(1); + }); + + test("绘图计入 imageCount,脚本与技能计入 textCount", () => { + const nodes = [ + node(CanvasNodeType.Drawing, undefined, "draw"), + node(CanvasNodeType.Script, undefined, "script"), + node(CanvasNodeType.Skill, undefined, "skill"), + node(CanvasNodeType.Text, undefined, "target"), + ]; + const summary = connectionInputSummary("target", nodes, [conn("draw", "target"), conn("script", "target"), conn("skill", "target")]); + expect(summary.imageCount).toBe(1); + expect(summary.textCount).toBe(2); + }); + + test("生成配置与背板作为上游一律不计数", () => { + const nodes = [ + node(CanvasNodeType.Config, undefined, "config"), + node(CanvasNodeType.Frame, undefined, "frame"), + node(CanvasNodeType.Text, undefined, "target"), + ]; + const summary = connectionInputSummary("target", nodes, [conn("config", "target"), conn("frame", "target")]); + expect(summary).toEqual({ textCount: 0, imageCount: 0, videoCount: 0, audioCount: 0, characterCount: 0 }); + }); + + test("角色卡覆盖类型归类:图片节点带角色元数据记为角色而非图片", () => { + const nodes = [node(CanvasNodeType.Image, { workflowKind: "character" }, "char"), node(CanvasNodeType.Text, undefined, "target")]; + const summary = connectionInputSummary("target", nodes, [conn("char", "target")]); + expect(summary.characterCount).toBe(1); + expect(summary.imageCount).toBe(0); + }); + + test("不可计数类型即使带角色元数据也不记为角色(排除早于覆盖)", () => { + const nodes = [node(CanvasNodeType.Frame, { workflowKind: "character" }, "frame"), node(CanvasNodeType.Text, undefined, "target")]; + expect(connectionInputSummary("target", nodes, [conn("frame", "target")]).characterCount).toBe(0); + }); +}); + +describe("节点注册表——列表标签", () => { + test("派生自 label,背板显式钉住不带「节点」后缀", () => { + expect(getNodeListLabel(CanvasNodeType.Image)).toBe("图片节点"); + expect(getNodeListLabel(CanvasNodeType.Text)).toBe("文本节点"); + expect(getNodeListLabel(CanvasNodeType.Drawing)).toBe("绘图节点"); + expect(getNodeListLabel(CanvasNodeType.Script)).toBe("分镜脚本节点"); + expect(getNodeListLabel(CanvasNodeType.Skill)).toBe("技能节点"); + expect(getNodeListLabel(CanvasNodeType.Config)).toBe("生成配置节点"); + expect(getNodeListLabel(CanvasNodeType.Video)).toBe("视频节点"); + expect(getNodeListLabel(CanvasNodeType.Audio)).toBe("音频节点"); + expect(getNodeListLabel(CanvasNodeType.Frame)).toBe("背板"); + }); +});