ai-chatbot/tests/routes/canvas-layout-replace-entry.test.ts

295 lines
11 KiB
TypeScript

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,
});
});
});