import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { expect, test } from "@playwright/test"; import { deriveLayoutReplaceBatchAdvance } from "@/lib/canvas/layout-replace-batch-state"; import { getAvailableCanvasLayoutReplaceSources, getReadyCanvasLayoutReplaceSource, type LayoutReplaceSource, mergeUniqueLayoutReplaceSources, } from "@/lib/canvas/layout-replace-source-selection"; import { buildCanvasLayoutReplacePrompt, rankCanvasLayoutReplaceModels, } from "@/lib/canvas/layout-replace-model-selection"; import { useCanvasStore } from "@/store/canvas"; import type { CanvasNode } from "@/types/canvas"; function readSource(path: string) { return readFileSync(resolve(process.cwd(), path), "utf8"); } test.describe("canvas AI layout replacement entry", () => { test("keeps the AI feature wired without routing to PSD", () => { const imageNode = readSource("components/canvas/nodes/ImageNode.tsx"); const workspace = readSource("components/canvas/CanvasWorkspace.tsx"); const toolbarConfig = readSource("lib/canvas-toolbar-config.ts"); const capabilityRegistry = readSource("lib/canvas/capabilities/registry.ts"); expect(toolbarConfig).toContain('key: "imageLayoutReplace"'); expect(toolbarConfig).toContain('label: "排版替换"'); expect(toolbarConfig).not.toContain('key: "imagePsdTemplateReplace"'); expect(capabilityRegistry).toContain('id: "image.layout_replace"'); expect(capabilityRegistry).toContain('clientAction: "layout-replace"'); expect(imageNode).toContain("CanvasLayoutReplaceBatchDialog"); expect(imageNode).toContain('handleAction("layout-replace")'); expect(imageNode).not.toContain("handleSubmitLayoutReplace"); expect(imageNode).not.toContain("/psd-template?launch="); expect(workspace).toContain("selectedImageNodes.length >= 1"); expect(workspace).toContain(".slice(0, 5)"); expect(workspace).toContain('requestImageNodeAction(selectedImageNodes[0].id, "layout-replace"'); }); test("supports five sources, sequential progress, recovery, and canvas writeback", () => { const dialog = readSource( "components/canvas/CanvasLayoutReplaceBatchDialog.tsx" ); const batchRoute = readSource( "app/api/canvas/layout-replace/batches/route.ts" ); const batchDetailRoute = readSource( "app/api/canvas/layout-replace/batches/[batchId]/route.ts" ); const batchStore = readSource("lib/canvas/layout-replace-batch.ts"); const taskQueue = readSource("lib/canvas/image-task-queue.ts"); const taskExecutor = readSource("lib/canvas/layout-replace-task.ts"); const migration = readSource( "lib/db/migrations/0098_add_canvas_layout_replace_batches.sql" ); expect(dialog).toContain("const MAX_BATCH_SOURCES = 5"); expect(dialog).toContain("从画布中添加"); expect(dialog).toContain("open={open && !activeCanvasImageSelectionSession}"); expect(dialog).toContain("确认添加到图片卡槽"); expect(dialog).toContain("上传目标模版图"); expect(dialog).toContain("批量处理 {sources.length} 张"); expect(dialog).toContain("添加所选到画布"); expect(dialog).toContain("getLatestLayoutReplaceBatch"); expect(dialog).toContain("markLayoutReplaceBatchItemsAdded"); expect(dialog).toContain("retryLayoutReplaceBatchItem"); expect(dialog).toContain("max-h-[calc(100dvh-1rem)]"); expect(batchRoute).toContain("createCanvasLayoutReplaceBatch({"); expect(batchRoute).toContain("await enqueueCanvasImageTask(created.firstTaskId)"); expect(batchRoute).toContain("ensureCanvasImageTaskActive"); expect(batchDetailRoute).toContain("markCanvasLayoutReplaceBatchItemsAdded"); expect(batchDetailRoute).toContain("retryCanvasLayoutReplaceBatchItem"); expect(batchStore).toContain('status: index === 0 ? "pending" : "waiting"'); expect(batchStore).toContain('status: index === 0 ? "pending" : "queued"'); expect(batchStore).toContain("if (!completedItem) return null"); expect(batchStore).toContain('eq(canvasImageTask.status, "waiting")'); expect(taskQueue).toContain("completeCanvasLayoutReplaceBatchItem"); expect(taskQueue).toContain("ensureCanvasImageTaskActive"); expect(taskExecutor).toContain("saveLayoutReplaceResultToGallery"); expect(taskExecutor).toContain('sourceType: "canvas_layout_replace"'); expect(taskExecutor).toContain( 'layoutReplaceServerAlgorithm: "rigid-product-scene-replace/v2"' ); expect(taskExecutor).toContain("allowFailover: false"); expect(taskExecutor).not.toContain("buildLayoutLockedCompositeImage({"); expect(taskExecutor).not.toContain("analyzeReplacementExecutionPlan({"); expect(migration).toContain('CREATE TABLE IF NOT EXISTS "CanvasLayoutReplaceBatch"'); expect(migration).toContain('CREATE TABLE IF NOT EXISTS "CanvasLayoutReplaceBatchItem"'); }); test("always excludes FAL and prefers a healthy multi-image edit model", () => { const candidates = [ { model: { id: "snapgen-gpt-image", modelId: "snapgen/gpt-image-2", supportsImageGen: true, supportsVision: true, healthStatus: "healthy", capabilities: { maxReferenceImages: 8 }, }, provider: { type: "snapgen-image" }, }, { model: { id: "fal-default", modelId: "fal-ai/nano-banana-2", supportsImageGen: true, supportsVision: true, healthStatus: "healthy", isDefault: true, }, provider: { type: "fal-image" }, }, { model: { id: "seedream-edit", modelId: "bytedance/seedream-v5.0-pro/edit", supportsImageGen: true, supportsVision: true, healthStatus: "healthy", capabilities: { maxReferenceImages: 8 }, }, provider: { type: "vip98k" }, }, { model: { id: "gpt-image-edit", modelId: "openai/gpt-image-2/edit", supportsImageGen: true, supportsVision: true, healthStatus: "healthy", capabilities: { maxReferenceImages: 10 }, }, provider: { type: "atlascloud" }, }, ]; const ranked = rankCanvasLayoutReplaceModels(candidates, "fal-default"); expect(ranked.map((item) => item.model.id)).toEqual([ "snapgen-gpt-image", "gpt-image-edit", "seedream-edit", ]); expect(ranked.every((item) => item.provider.type !== "fal-image")).toBe(true); }); test("uses a rigid product identity prompt instead of detail-page copy rules", () => { const prompt = buildCanvasLayoutReplacePrompt({ referenceHeight: 726, referenceWidth: 543, sourceProductAspectRatio: 0.76, supplementalPrompt: "保留深胡桃木颜色", }); expect(prompt).toContain("图1是新商品身份图"); expect(prompt).toContain("图2是目标场景与构图模版"); expect(prompt).toContain("真实商品像素层"); expect(prompt).toContain("横向或纵向拉伸"); expect(prompt).toContain("543x726"); expect(prompt).toContain("0.760:1"); expect(prompt).toContain("绝对不能压窄商品宽度"); expect(prompt).toContain("画布宽度约 77%"); expect(prompt).toContain("画布 x≈11% 处"); expect(prompt).toContain("x≈89% 处"); expect(prompt).toContain("图2旧商品原本偏窄的占位不得继承"); expect(prompt).toContain("保留深胡桃木颜色"); expect(prompt).not.toContain("按钮、标签、分割线、品牌区"); }); test("selects eligible canvas images without exceeding the remaining slots", () => { const store = useCanvasStore.getState(); store.endCanvasImageSelection("layout-owner"); store.startCanvasImageSelection({ ownerNodeId: "layout-owner", allowedNodeIds: ["image-1", "image-2", "image-3"], maxCount: 2, }); store.toggleCanvasImageSelection("image-1"); store.toggleCanvasImageSelection("not-allowed"); store.toggleCanvasImageSelection("image-2"); store.toggleCanvasImageSelection("image-3"); expect(useCanvasStore.getState().canvasImageSelectionSession?.selectedNodeIds).toEqual([ "image-1", "image-2", ]); store.toggleCanvasImageSelection("image-1"); expect(useCanvasStore.getState().canvasImageSelectionSession?.selectedNodeIds).toEqual([ "image-2", ]); store.endCanvasImageSelection("another-owner"); expect(useCanvasStore.getState().canvasImageSelectionSession).not.toBeNull(); store.endCanvasImageSelection("layout-owner"); expect(useCanvasStore.getState().canvasImageSelectionSession).toBeNull(); }); test("allows four consecutive canvas additions and distinguishes duplicate URLs by node", () => { const nodes = Array.from({ length: 5 }, (_, index) => ({ id: `image-${index + 1}`, type: "image", position: { x: index * 100, y: 0 }, size: { width: 100, height: 100 }, data: { src: index < 2 ? "https://cdn.example.com/shared.png" : `https://cdn.example.com/image-${index + 1}.png`, title: `图片 ${index + 1}`, // Imported and restored images can be AI-ready without this legacy status. status: index === 0 ? "completed" : undefined, }, })) as CanvasNode[]; const canvasSources = nodes .map(getReadyCanvasLayoutReplaceSource) .filter((source): source is LayoutReplaceSource => Boolean(source)); expect(canvasSources).toHaveLength(5); let selected = [canvasSources[0]!]; for (let round = 1; round < 5; round += 1) { const available = getAvailableCanvasLayoutReplaceSources( canvasSources, selected ); expect(available).toHaveLength(5 - round); selected = mergeUniqueLayoutReplaceSources( selected, [available[0]!], 5 ); expect(selected).toHaveLength(round + 1); } expect(selected.map((source) => source.nodeId)).toEqual([ "image-1", "image-2", "image-3", "image-4", "image-5", ]); }); test("keeps canvas picking isolated from normal drag and selection toolbars", () => { const workspace = readSource("components/canvas/CanvasWorkspace.tsx"); const editor = readSource("app/canvas/[id]/CanvasEditorLegacyPageClient.tsx"); expect(workspace).toContain("toggleCanvasImageSelection(node.id)"); expect(workspace).toContain( "disableDragging={tool !== 'select' || Boolean(canvasImageSelectionSession)}" ); expect(workspace).toContain( "!canvasImageSelectionSession && selectedImageNodes.length > 0" ); expect(editor).toContain( "if (useCanvasStore.getState().canvasImageSelectionSession) return;" ); }); test("advances one queued item and derives terminal batch states", () => { const active = deriveLayoutReplaceBatchAdvance([ { status: "succeeded", taskId: "task-1" }, { status: "queued", taskId: "task-2" }, { status: "queued", taskId: "task-3" }, ]); expect(active).toMatchObject({ failedCount: 0, isComplete: false, nextTaskId: "task-2", status: "running", succeededCount: 1, }); const partial = deriveLayoutReplaceBatchAdvance([ { status: "succeeded", taskId: "task-1" }, { status: "failed", taskId: "task-2" }, ]); expect(partial).toMatchObject({ failedCount: 1, isComplete: true, nextTaskId: null, status: "partial_failed", succeededCount: 1, }); }); });