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

205 lines
8.3 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 { 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(migration).toContain('CREATE TABLE IF NOT EXISTS "CanvasLayoutReplaceBatch"');
expect(migration).toContain('CREATE TABLE IF NOT EXISTS "CanvasLayoutReplaceBatchItem"');
});
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,
});
});
});