426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
import {
|
|
executeImageOperation,
|
|
imageOperationSchema,
|
|
ImageOperationUncertainError,
|
|
} from "@/lib/video-canvas/image-operation";
|
|
import {
|
|
mergeStoryTaskResult,
|
|
requireStoryPromptResult,
|
|
} from "@/lib/video-canvas/story-task-attachment";
|
|
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);
|
|
});
|
|
|
|
test("image postprocessing validates provider parameters before dispatch", () => {
|
|
expect(
|
|
imageOperationSchema.safeParse({
|
|
operation: "angle-edit",
|
|
imageUrl: "https://example.com/a.png",
|
|
horizontalAngle: 180,
|
|
verticalAngle: -30,
|
|
zoom: 5,
|
|
}).success,
|
|
).toBe(true);
|
|
for (const value of [
|
|
{ operation: "shell", imageUrl: "https://example.com/a.png" },
|
|
{ operation: "enhance", imageUrl: "file:///etc/passwd" },
|
|
{ operation: "angle-edit", imageUrl: "https://example.com/a.png" },
|
|
{ operation: "enhance", imageUrl: "https://example.com/a.png", scaleFactor: 100 },
|
|
])
|
|
expect(imageOperationSchema.safeParse(value).success).toBe(false);
|
|
});
|
|
test("an accepted enhance task is polled without repeating POST and retains its upstream identity", async () => {
|
|
let submits = 0,
|
|
polls = 0;
|
|
const checkpoints: any[] = [];
|
|
const handler = async () =>
|
|
Response.json({ success: true, original_url: "https://example.com/result.png" });
|
|
const result = await executeImageOperation(
|
|
{ operation: "enhance", imageUrl: "https://example.com/source.png", scaleFactor: 4 },
|
|
"owner",
|
|
{
|
|
enhance: async (req, user) => {
|
|
submits++;
|
|
expect(user).toBe("owner");
|
|
expect(await req.json()).toMatchObject({ scaleFactor: 4 });
|
|
return Response.json(
|
|
{ success: true, processing: true, taskId: "upstream-1" },
|
|
{ status: 202 },
|
|
);
|
|
},
|
|
extend: handler,
|
|
removeBackground: handler,
|
|
angleEdit: handler,
|
|
pollEnhance: async (req, user) => {
|
|
polls++;
|
|
expect(user).toBe("owner");
|
|
expect(new URL(req.url).searchParams.get("taskId")).toBe("upstream-1");
|
|
return polls < 2 ? Response.json({ processing: true }, { status: 202 }) : handler();
|
|
},
|
|
},
|
|
{
|
|
wait: async () => {},
|
|
checkpoint: async (value) => {
|
|
checkpoints.push(value);
|
|
},
|
|
},
|
|
);
|
|
expect(submits).toBe(1);
|
|
expect(polls).toBe(2);
|
|
expect(checkpoints[0].upstreamTask.taskId).toBe("upstream-1");
|
|
expect(result.images[0].originalUrl).toBe("https://example.com/result.png");
|
|
});
|
|
test("a pending or disconnected upstream query is reported as uncertain rather than submitted again", async () => {
|
|
const handler = async () =>
|
|
Response.json({ processing: true, taskId: "upstream-1" }, { status: 202 });
|
|
for (const pollEnhance of [
|
|
handler,
|
|
async () => {
|
|
throw Error("connection lost");
|
|
},
|
|
async () => Response.json({ error: "upstream temporarily unavailable" }, { status: 502 }),
|
|
]) {
|
|
await expect(
|
|
executeImageOperation(
|
|
{ operation: "enhance", imageUrl: "https://example.com/source.png" },
|
|
"owner",
|
|
{
|
|
enhance: handler,
|
|
extend: handler,
|
|
removeBackground: handler,
|
|
angleEdit: handler,
|
|
pollEnhance,
|
|
},
|
|
{ wait: async () => {}, maxPolls: 2 },
|
|
),
|
|
).rejects.toBeInstanceOf(ImageOperationUncertainError);
|
|
}
|
|
});
|
|
test("angle operation sends normalized numeric fields and accepts the dedicated response shape", async () => {
|
|
const handler = async (req: Request) => {
|
|
expect(await req.json()).toEqual({
|
|
image_url: "https://example.com/source.png",
|
|
horizontal_angle: 180,
|
|
vertical_angle: 45,
|
|
zoom: 5,
|
|
});
|
|
return Response.json({ success: true, image_url: "https://example.com/angle.png" });
|
|
};
|
|
const result = await executeImageOperation(
|
|
{
|
|
operation: "angle-edit",
|
|
imageUrl: "https://example.com/source.png",
|
|
horizontalAngle: 180,
|
|
verticalAngle: 45,
|
|
zoom: 5,
|
|
},
|
|
"owner",
|
|
{
|
|
enhance: handler,
|
|
extend: handler,
|
|
removeBackground: handler,
|
|
angleEdit: handler,
|
|
pollEnhance: handler,
|
|
},
|
|
);
|
|
expect(result.images[0].originalUrl).toContain("angle.png");
|
|
expect(
|
|
reconcileCanvasTasks<TaskNode>(
|
|
[node],
|
|
[{ ...task, request: { ...task.request, kind: "image-operation" }, result }],
|
|
task.createdAt,
|
|
)[0].inputType,
|
|
).toBe("image");
|
|
});
|
|
test("postprocessing preserves upstream failures and rejects an empty success", async () => {
|
|
for (const payload of [{ success: false, error: "积分不足" }, { success: true }]) {
|
|
const handler = async () => Response.json(payload);
|
|
await expect(
|
|
executeImageOperation(
|
|
{ operation: "remove-bg", imageUrl: "https://example.com/a.png" },
|
|
"owner",
|
|
{
|
|
enhance: handler,
|
|
extend: handler,
|
|
removeBackground: handler,
|
|
angleEdit: handler,
|
|
pollEnhance: handler,
|
|
},
|
|
),
|
|
).rejects.toThrow();
|
|
}
|
|
});
|
|
const asset = { id: "asset-1", name: "杯子", description: "白色", type: "prop", imageUrl: "" };
|
|
const attachment = { type: "story-asset" as const, nodeId: "script", asset };
|
|
test("asset results merge by stable identity after reordering and are only applied once", () => {
|
|
const draft = { storyAssets: [{ ...asset, id: "other" }, asset] };
|
|
const result = mergeStoryTaskResult(draft, attachment, task.result, "job");
|
|
expect(result.storyAssets[1].imageUrl).toBe("https://example.com/1.png");
|
|
expect(result.storyAssets[0].imageUrl).toBe("");
|
|
expect(mergeStoryTaskResult(result, attachment, task.result, "job")).toBe(result);
|
|
});
|
|
test("late asset results preserve user edits and deletions", () => {
|
|
for (const storyAssets of [
|
|
[{ ...asset, imageUrl: "user-upload" }],
|
|
[{ ...asset, description: "changed" }],
|
|
[],
|
|
]) {
|
|
const result = mergeStoryTaskResult({ storyAssets }, attachment, task.result, "job");
|
|
expect(result.storyAssets).toEqual(storyAssets);
|
|
expect(result.storyTaskResults.job.status).toBe("conflict");
|
|
}
|
|
});
|
|
test("prompt results merge only their original rows without replacing dialogue or edited rows", () => {
|
|
const rows = [
|
|
{ id: "r1", dialogue: "原对白", imagePrompt: "", motionPrompt: "" },
|
|
{ id: "r2", dialogue: "第二行", imagePrompt: "", motionPrompt: "" },
|
|
];
|
|
const target = { type: "story-prompts" as const, nodeId: "script", rows };
|
|
const data = {
|
|
analysisRows: [
|
|
{ imagePrompt: "image 1", motionPrompt: "move 1" },
|
|
{ imagePrompt: "image 2", motionPrompt: "move 2" },
|
|
],
|
|
};
|
|
const current = [{ ...rows[1], dialogue: "用户修改" }, rows[0]];
|
|
const result = mergeStoryTaskResult({ rows: current }, target, data, "job");
|
|
expect(result.rows[0]).toEqual(current[0]);
|
|
expect(result.rows[1]).toEqual({ ...rows[0], imagePrompt: "image 1", motionPrompt: "move 1" });
|
|
expect(result.storyTaskResults.job.status).toBe("conflict");
|
|
});
|
|
test("incomplete prompt batches cannot be marked successful", () => {
|
|
const target = { type: "story-prompts" as const, nodeId: "script", rows: [{ id: "r1" }] };
|
|
for (const data of [{}, { analysisRows: [] }, { analysisRows: [{ imagePrompt: "x" }] }])
|
|
expect(() => requireStoryPromptResult(data, target)).toThrow();
|
|
});
|
|
test("recovered task results populate a script while retaining the independent output node", () => {
|
|
const script = { ...node, id: "script", draftValues: { storyAssets: [asset] } };
|
|
const attached = { ...task, request: { ...task.request, attachment } };
|
|
const result = reconcileCanvasTasks<TaskNode>([script], [attached], "2026-09-17T01:00:00Z");
|
|
expect(result.find((n) => n.id === "script")?.draftValues?.storyAssets[0].imageUrl).toBe(
|
|
"https://example.com/1.png",
|
|
);
|
|
expect(result.some((n) => n.id === "n1")).toBe(true);
|
|
expect(reconcileCanvasTasks<TaskNode>(result, [attached], "2026-09-17T01:00:00Z")).toBe(result);
|
|
});
|