import { describe, expect, test } from "bun:test"; import { inspectLayerAlpha, validateLayerRasters, imageLayerCompositeSignature, copyImageLayerSources, supportsLayerDecomposition, supportsExperimentalLayerExtraction, parseExperimentalLayerTargets, experimentalLayerPrompt, experimentalLayerSignature, layerDecompositionConfig, imageLayerProviderMetadata, validateImageLayerRole, validateImageLayerOutput, hasUsableLayerTransparency, MAX_IMAGE_LAYERS, MAX_LAYER_PIXELS, } from "@/lib/canvas/canvas-image-layers"; import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy"; import { applyBatchPrimaryImage, removeCanvasNodes, isHiddenBatchChild } from "@/lib/canvas/canvas-project-domain"; import { failedImageBatchChildren, reconcileImageBatchRoot } from "@/lib/canvas/canvas-image-batch-retry"; import { buildImageLayerTaskResult } from "@/lib/canvas/canvas-generation-task-sync"; import { canvasNodeToAsset, findCanvasNodeAsset } from "@/lib/canvas/canvas-node-asset"; import type { Asset } from "@/stores/use-asset-store"; import { defaultConfig, createModelChannel } from "@/stores/use-config-store"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; const opaque = inspectLayerAlpha(2, 1, new Uint8ClampedArray([20, 30, 40, 255, 20, 30, 40, 255])); const transparent = inspectLayerAlpha(2, 1, new Uint8ClampedArray([200, 0, 0, 128, 0, 0, 0, 0])); function sample() { const children: CanvasNodeData[] = [0, 1].map((index) => ({ id: `layer-${index}`, type: CanvasNodeType.Image, title: `图层 ${index}`, position: { x: 600, y: 240 * index }, width: 320, height: 200, metadata: { storageKey: `resource:layer-${index}`, content: `/layer-${index}.png`, naturalWidth: 2, naturalHeight: 1, imageLayer: { groupId: "root", outputIndex: index }, batchRootId: "root" }, })); const root: CanvasNodeData = { id: "root", type: CanvasNodeType.Image, title: "合成图", position: { x: 0, y: 0 }, width: 320, height: 200, metadata: { content: "/composite.png", storageKey: "resource:composite", status: "success", taskId: "task", generationOutputCount: 2, isBatchRoot: true, batchChildIds: children.map((child) => child.id), imageLayerGroup: { width: 2, height: 1, compositeStatus: "ready", layers: children.map((child) => ({ nodeId: child.id, x: 0, y: 0, visible: true })) }, }, }; return { root, children, nodes: [root, ...children] }; } describe("独立透明图层验收", () => { test("原图底板输入仍要求不透明,去背景触发仍不能用单个透明像素冒充", () => { expect(() => validateImageLayerRole(opaque, 0)).not.toThrow(); expect(() => validateImageLayerRole(transparent, 0)).toThrow("背景底图"); const rgba = new Uint8ClampedArray(100 * 100 * 4).fill(255); rgba[3] = 254; expect(() => validateImageLayerRole(inspectLayerAlpha(100, 100, rgba), 0)).toThrow("半透明"); rgba[3] = 0; const puncture = inspectLayerAlpha(100, 100, rgba); expect(puncture.transparent).toBe(true); expect(hasUsableLayerTransparency(puncture)).toBe(false); expect(() => validateImageLayerRole(puncture, 1)).toThrow("零散"); expect(() => validateImageLayerRole(transparent, 1)).not.toThrow(); }); test("最终显示接受半透明背景与不透明前景,仍拒绝完全透明空图", () => { for (const info of [opaque, transparent, { ...opaque, opaque: false }]) { expect(() => validateImageLayerOutput(info)).not.toThrow(); } expect(() => validateImageLayerOutput({ ...transparent, nonempty: false })).toThrow("空图层"); expect(hasUsableLayerTransparency(opaque)).toBe(false); }); test("完整场景只移除勾选对象,细节允许重复;单层提示不混入其他层排除指令", () => { const targets = ["房间:保留窗户、书架、地毯", "猫:保留轮廓", "毛线球:保留纹理"]; const background = experimentalLayerPrompt({ prompt: "移除猫并补全遮挡;保留窗户、书架、地毯与毛线球", background: "opaque" }); expect(background).toContain("移除猫并补全遮挡"); expect(background).toContain("保留窗户、书架、地毯与毛线球"); expect(background).toContain("alpha=255"); expect(background).toContain("0–1000 相对坐标,不是像素"); expect(background).toContain("补全为原有底色或环境"); expect(background).not.toContain("不要清空底图"); const foreground = experimentalLayerPrompt({ prompt: targets[1], background: "transparent" }); expect(foreground).not.toContain("房间:保留"); expect(foreground).not.toContain("毛线球:保留"); expect(foreground).toContain("不做面板内部主体抠图"); expect(background).not.toContain("保留自然边缘与接地阴影"); const model = "gpt-image-2.5-flare"; const channel = createModelChannel({ id: "backup", models: [model], interfaceType: "openai-image" }); const config = { ...defaultConfig, model: `backup::${model}`, channels: [channel] }; expect(imageLayerProviderMetadata(config, 0)).toEqual({ providerOptions: { "openai-image": { background: "opaque" } } }); expect(imageLayerProviderMetadata(config, 1)).toEqual({ providerOptions: { "openai-image": { background: "transparent" } } }); }); test("实验提取明确协议、每层目标和透明背景;限制调用层数", () => { const model = "gpt-image-2.5-flare"; const channel = createModelChannel({ id: "backup", models: [model], interfaceType: "openai-image" }); expect(supportsExperimentalLayerExtraction({ ...defaultConfig, channels: [channel] }, `backup::${model}`)).toBe(true); expect(supportsLayerDecomposition({ ...defaultConfig, channels: [channel] }, `backup::${model}`)).toBe(false); const targets = parseExperimentalLayerTargets("背景\n汽车\n前景"); expect(targets).toEqual(["背景", "汽车", "前景"]); expect(experimentalLayerPrompt({ prompt: "补全被遮挡背景", background: "opaque" })).toContain("补全被遮挡背景"); expect(experimentalLayerPrompt({ prompt: "目标:汽车", background: "transparent" })).toContain("alpha=0"); expect(experimentalLayerPrompt({ prompt: "目标:汽车", background: "transparent" })).toContain("目标:汽车"); expect(() => parseExperimentalLayerTargets("汽车")).toThrow("2–8"); expect(() => parseExperimentalLayerTargets("汽车\n汽车")).toThrow("重复"); expect(() => parseExperimentalLayerTargets(Array.from({ length: 9 }, (_, i) => String(i)).join("\n"))).toThrow("2–8"); }); test("未完成实验组删除清理逐层节点,复制不继承付费提取计划", () => { const { root, children, nodes } = sample(); const staged = { ...root, metadata: { experimentalLayerPlan: { sourceNodeId: "source", requests: children.map((child) => ({ nodeId: child.id, target: child.id })) } } }; expect(removeCanvasNodes([staged, ...children], new Set([root.id])).nodes).toHaveLength(0); expect(isolateCopiedNodeMetadata(staged, new Map([[root.id, "copy"]])).experimentalLayerPlan).toBeUndefined(); expect(experimentalLayerSignature(staged, nodes)).not.toBe(experimentalLayerSignature(staged, [root, ...children.map((child) => ({ ...child, metadata: { ...child.metadata, storageKey: "edited" } }))])); }); test("读取真实 alpha,保留半透明边缘和重叠;唯一底图置底", () => { expect(opaque).toMatchObject({ transparent: false, nonempty: true }); expect(transparent).toMatchObject({ transparent: true, nonempty: true }); expect(validateLayerRasters([transparent, opaque, transparent])).toEqual([1, 0, 2]); expect(validateLayerRasters([transparent, transparent])).toEqual([0, 1]); }); test("多张不透明图保留返回顺序;仍拒绝单张拼版、空图层、不同尺寸及超限输出", () => { expect(() => validateLayerRasters([opaque])).toThrow("至少需要两张"); expect(validateLayerRasters([opaque, opaque])).toEqual([0, 1]); expect(validateLayerRasters([transparent, opaque, opaque])).toEqual([0, 1, 2]); expect(() => validateLayerRasters([opaque, { ...transparent, nonempty: false }])).toThrow("空图层"); expect(() => validateLayerRasters([opaque, { ...transparent, width: 3 }])).toThrow("尺寸不一致"); expect(() => validateLayerRasters(Array(MAX_IMAGE_LAYERS + 1).fill(transparent))).toThrow("数量"); expect(() => inspectLayerAlpha(MAX_LAYER_PIXELS + 1, 1, new Uint8ClampedArray())).toThrow("尺寸"); expect(() => inspectLayerAlpha(2, 1, new Uint8ClampedArray(4))).toThrow("尺寸"); }); test("模型名不能代替专用协议能力", () => { const model = "bytedance/seedream-v5.0-pro/layer-decomposition"; const channel = createModelChannel({ id: "test", models: [model], interfaceType: "image-tools-layer-decomposition" }); expect(supportsLayerDecomposition({ ...defaultConfig, channels: [channel] }, `test::${model}`)).toBe(true); expect(supportsLayerDecomposition({ ...defaultConfig, channels: [{ ...channel, interfaceType: undefined }] }, `test::${model}`)).toBe(false); }); test("来源工作流和多图数量不能把拆层改路由或提交多次任务", () => { const config = layerDecompositionConfig({ ...defaultConfig, taskWorkflowProvider: "runninghub", count: "4" }, "dedicated", { quality: "1k" }); expect(config).toMatchObject({ model: "dedicated", imageModel: "dedicated", count: "1", taskWorkflowProvider: "model", quality: "1k" }); }); }); describe("图层组状态与画布操作", () => { test("进行中或失败的拆层计划也收纳在组内,不把底图设为成功主图或批量收费重试", () => { const { root, children, nodes } = sample(); const pending = { ...root, metadata: { isBatchRoot: true, batchChildIds: children.map((child) => child.id), imageBatchExpanded: false, status: "error" as const, experimentalLayerPlan: { sourceNodeId: "source", requests: children.map((child) => ({ nodeId: child.id, target: child.title })) }, }, }; expect(isHiddenBatchChild(children[0], [pending, ...children])).toBe(true); expect(applyBatchPrimaryImage(pending, children[0])).toBe(pending); expect(reconcileImageBatchRoot(pending, nodes)).toBe(pending); expect( failedImageBatchChildren( pending, children.map((child) => ({ ...child, metadata: { ...child.metadata, status: "error" as const } })), ), ).toHaveLength(0); }); test("合成资源更新后不沿用同一原任务的旧素材绑定", () => { const { root } = sample(); const old = { ...canvasNodeToAsset(root, { canvasId: "canvas", source: "canvas-generation", taskId: "task" }), id: "old", createdAt: "", updatedAt: "" } as Asset; const updated = { ...root, metadata: { ...root.metadata, storageKey: "resource:new-composite", content: "/new.png" } }; expect(findCanvasNodeAsset([old], root, "canvas", "task")?.id).toBe("old"); expect(findCanvasNodeAsset([old], updated, "canvas", "task")).toBeUndefined(); }); test("画布移动、节点名称、展开与收起不影响合成;顺序/可见性/资源改变会更新", () => { const { root, children, nodes } = sample(); const group = root.metadata!.imageLayerGroup!; const signature = imageLayerCompositeSignature(group, nodes); expect( imageLayerCompositeSignature( group, nodes.map((node) => ({ ...node, title: "改名", position: { x: 5000, y: 5000 }, metadata: { ...node.metadata, imageBatchExpanded: true } })), ), ).toBe(signature); expect(imageLayerCompositeSignature({ ...group, layers: [...group.layers].reverse() }, nodes)).not.toBe(signature); expect(imageLayerCompositeSignature({ ...group, layers: group.layers.map((layer) => ({ ...layer, visible: false })) }, nodes)).not.toBe(signature); expect(imageLayerCompositeSignature(group, [root, { ...children[0], metadata: { ...children[0].metadata, storageKey: "new" } }, children[1]])).not.toBe(signature); expect(isHiddenBatchChild(children[0], [root])).toBe(true); expect(isHiddenBatchChild(children[0], [{ ...root, metadata: { ...root.metadata, imageBatchExpanded: true } }])).toBe(false); }); test("删除子层不替换合成图,删除组清理全部子层,MJ 主图逻辑不介入", () => { const { root, children, nodes } = sample(); expect(applyBatchPrimaryImage(root, children[0])).toBe(root); expect(reconcileImageBatchRoot(root, nodes)).toBe(root); const next = removeCanvasNodes(nodes, new Set([children[0].id])).nodes; expect(next[0].metadata?.content).toBe("/composite.png"); expect(next[0].metadata?.imageLayerGroup?.layers.map((layer) => layer.nodeId)).toEqual([children[1].id]); expect(next[0].metadata?.imageLayerGroup?.compositeStatus).toBe("updating"); expect(removeCanvasNodes(nodes, new Set([root.id])).nodes).toHaveLength(0); }); test("复制整组重映射内部身份,单独复制子层脱离原组和任务", () => { const { root, children, nodes } = sample(); expect(copyImageLayerSources(root, nodes)).toEqual(nodes); const mapping = new Map(nodes.map((node) => [node.id, `copy-${node.id}`])); const copy = isolateCopiedNodeMetadata(root, mapping); expect(copy.taskId).toBeUndefined(); expect(copy.batchChildIds).toEqual(["copy-layer-0", "copy-layer-1"]); expect(copy.imageLayerGroup?.layers[0].nodeId).toBe("copy-layer-0"); expect(isolateCopiedNodeMetadata(children[0], mapping).imageLayer?.groupId).toBe("copy-root"); const detached = isolateCopiedNodeMetadata(children[0], new Map([[children[0].id, "single"]])); expect(detached.imageLayer).toBeUndefined(); expect(detached.batchRootId).toBeUndefined(); }); test("已消费任务重放不重建图层、不复活已删除子层、不覆盖编辑", async () => { const { root, nodes } = sample(); const task = { id: "task", type: "canvas_image", status: "succeeded" as const, prompt: "", attempts: 1, createdAt: "", updatedAt: "", resultJson: JSON.stringify({ images: [{ dataUrl: "/a.png" }, { dataUrl: "/b.png" }] }) }; const result = await buildImageLayerTaskResult(root, task, nodes.slice(0, 2)); expect(result.node).toBe(root); expect(result.additionalNodes).toHaveLength(0); const restored = await buildImageLayerTaskResult({ ...root, metadata: { ...root.metadata, status: "error" } }, task, nodes.slice(0, 2)); expect(restored.node.metadata?.status).toBe("success"); expect(restored.additionalNodes).toHaveLength(0); }); });