ai-chatbot/tests/routes/video-canvas-tasks.test.ts

224 lines
7.7 KiB
TypeScript

import { test, expect } from "@playwright/test";
import {
canvasTaskRequestSchema,
reconcileCanvasTasks,
requireAnalysisResult,
canCancelCanvasTask,
type CanvasGenerationTask,
} from "@/lib/video-canvas/task-contract";
import { chooseStoryVideoSettings } from "@/lib/video-canvas/model-matching";
type TaskNode = { id: string; draftValues?: Record<string, any>; [key: string]: any };
const node: TaskNode = {
id: "n1",
title: "镜头",
kind: "media",
inputType: "image",
x: 0,
y: 0,
width: 300,
height: 200,
draftValues: { rows: [{ sceneDescription: "旧内容" }] },
};
const request = {
requestId: "11111111-1111-4111-8111-111111111111",
projectId: "22222222-2222-4222-8222-222222222222",
kind: "image" as const,
node,
params: { prompt: "cup" },
edges: [],
};
const task: CanvasGenerationTask = {
id: "task1",
requestId: request.requestId,
status: "succeeded",
createdAt: "2026-09-17T01:01:00Z",
request: request as any,
result: {
images: [
{ originalUrl: "https://example.com/1.png" },
{ originalUrl: "https://example.com/2.png" },
],
},
};
test("restores accepted tasks missing from a pre-submit canvas snapshot, including every image", () => {
const result = reconcileCanvasTasks<TaskNode>([], [task], "2026-09-17T01:00:00Z");
expect(result.map((n) => n.id)).toEqual(["n1", "n1-result-task1-1"]);
expect(result[0].draftValues?.url).toHaveLength(2);
expect(reconcileCanvasTasks<TaskNode>(result, [task], "2026-09-17T01:00:00Z")).toBe(result);
});
test("does not resurrect nodes removed after task creation or hidden on the server", () => {
expect(reconcileCanvasTasks<TaskNode>([], [task], "2026-09-17T01:02:00Z")).toEqual([]);
expect(
reconcileCanvasTasks<TaskNode>(
[],
[{ ...task, request: { ...request, node: node as any, hiddenNodeIds: ["n1"] } }],
"2026-09-17T01:00:00Z",
),
).toEqual([]);
});
test("late result from an older request cannot overwrite a new generation on the same node", () => {
const current = {
...node,
draftValues: { taskRequestId: "new-request", mediaUrl: "new-result" },
};
expect(reconcileCanvasTasks<TaskNode>([current], [task], "2026-09-17T01:00:00Z")[0]).toBe(
current,
);
});
test("task failure retains the previous script instead of marking an empty result successful", () => {
const result = reconcileCanvasTasks<TaskNode>(
[node],
[{ ...task, status: "failed", error: "模型错误" }],
"2026-09-17T01:00:00Z",
)[0];
expect(result.draftValues).toMatchObject({
rows: [{ sceneDescription: "旧内容" }],
generationStatus: "failed",
generationError: "模型错误",
});
});
test("queue and running states show actual state without invented progress", () => {
const pending = reconcileCanvasTasks<TaskNode>(
[node],
[{ ...task, status: "pending" }],
"2026-09-17T01:00:00Z",
);
expect(pending[0].draftValues).toMatchObject({ generationProgress: 0, pendingLabel: "排队中" });
expect(
reconcileCanvasTasks<TaskNode>(
pending,
[{ ...task, status: "running" }],
"2026-09-17T01:00:00Z",
)[0].draftValues?.pendingLabel,
).toBe("生成中");
});
test("interrupted tasks accept a late real result while cancelled queued tasks stay visibly cancelled", () => {
const pending = reconcileCanvasTasks<TaskNode>(
[node],
[{ ...task, status: "interrupted", error: "待核实" }],
"2026-09-17T01:00:00Z",
);
expect(
reconcileCanvasTasks<TaskNode>(pending, [task], "2026-09-17T01:00:00Z")[0].draftValues
?.generationStatus,
).toBe("complete");
expect(
reconcileCanvasTasks<TaskNode>(
[node],
[{ ...task, status: "cancelled" }],
"2026-09-17T01:00:00Z",
)[0].subtitle,
).toBe("已取消");
expect(["pending", "running", "succeeded", "interrupted"].map(canCancelCanvasTask)).toEqual([
true,
false,
false,
false,
]);
});
test("analysis rejects success envelopes without meaningful output or script rows", () => {
for (const data of [
undefined,
{},
{ analysisRows: [] },
{ analysisRows: [{}], raw: "some summary" },
])
expect(() => requireAnalysisResult(data, "story-script")).toThrow();
expect(() => requireAnalysisResult({ raw: " " }, "agent-plan")).toThrow();
expect(
requireAnalysisResult({ analysisRows: [{ sceneDescription: "杯柄特写" }] }, "story-script"),
).toBeTruthy();
});
test("task snapshots require stable project/request identities and bounded geometry", () => {
expect(canvasTaskRequestSchema.safeParse(request).success).toBe(true);
for (const patch of [
{ requestId: "bad" },
{ projectId: "bad" },
{ node: { ...node, width: Infinity } },
{ node: { ...node, id: "" } },
])
expect(canvasTaskRequestSchema.safeParse({ ...request, ...patch }).success).toBe(false);
});
const model = {
id: "video-model",
modelId: "wan2.6-i2v-flash",
providerType: "qianwen-video",
capabilities: {
kind: "video",
supportedModes: ["first-frame"],
durationOptions: [5, 10],
supportedRatios: ["adaptive"],
supportedResolutions: ["720p"],
supportGenerateAudio: true,
},
};
test("story batches match the actual input mode and discrete provider parameters", () => {
expect(chooseStoryVideoSettings([model], true, 7)).toMatchObject({
mode: "first-frame",
duration: 5,
ratio: "adaptive",
resolution: "720p",
generateAudio: true,
});
expect(() => chooseStoryVideoSettings([model], false, 5)).toThrow("文生视频");
const text = {
...model,
id: "text-model",
providerType: "vip98k",
modelId: "veo",
capabilities: { ...model.capabilities, supportedModes: ["text-to-video"] },
};
expect(chooseStoryVideoSettings([model, text], false, 9, model.id)).toMatchObject({
mode: "text-to-video",
model: "text-model",
duration: 10,
});
});
test("unknown model specifications cannot silently invent 720p or five seconds", () => {
expect(() =>
chooseStoryVideoSettings(
[{ ...model, capabilities: { kind: "video", supportedModes: ["first-frame"] } }],
true,
5,
),
).toThrow("规格");
});
test("refresh recovers the newest accepted request when the saved node still points to an older result", () => {
const newer = {
...task,
id: "task2",
requestId: "new-request",
createdAt: "2026-09-17T01:03:00Z",
result: { images: [{ originalUrl: "https://example.com/new.png" }] },
};
const old = { ...node, draftValues: { taskRequestId: task.requestId, mediaUrl: "old" } };
const result = reconcileCanvasTasks<TaskNode>([old], [task, newer], "2026-09-17T01:02:00Z");
expect(result[0].draftValues?.mediaUrl).toBe("https://example.com/new.png");
expect(result[0].draftValues?.pendingLabel).toBe("");
expect(
reconcileCanvasTasks<TaskNode>([], [task, newer], "2026-09-17T01:00:00Z")[0].draftValues
?.taskRequestId,
).toBe("new-request");
});
test("grid results preserve the grid action contract", () => {
const grid = { ...node, draftValues: { action: "IMAGE_GENERATE_NINE_GRID" } };
expect(
reconcileCanvasTasks<TaskNode>([grid], [task], "2026-09-17T01:00:00Z")[0].draftValues?.action,
).toBe("IMAGE_GENERATE_NINE_GRID");
});
test("a rerun retains distinct extra results without reusing the previous request's image", () => {
const first = reconcileCanvasTasks<TaskNode>([node], [task], "2026-09-17T01:00:00Z");
const newer = {
...task,
id: "task2",
requestId: "next",
createdAt: "2026-09-17T01:03:00Z",
result: { images: [{ originalUrl: "new1" }, { originalUrl: "new2" }] },
};
const next = reconcileCanvasTasks<TaskNode>(first, [task, newer], "2026-09-17T01:02:00Z");
expect(next.find((n) => n.id === "n1-result-task2-1")?.draftValues?.mediaUrl).toBe("new2");
expect(reconcileCanvasTasks<TaskNode>(next, [task, newer], "2026-09-17T01:02:00Z")).toBe(next);
});