224 lines
7.7 KiB
TypeScript
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);
|
|
});
|