295 lines
11 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|