205 lines
8.3 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|