750 lines
26 KiB
TypeScript
750 lines
26 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);
|
|
});
|
|
|
|
// Recovery only queries an existing, server-recorded task; it never submits another job.
|
|
import {
|
|
canResumeCanvasTask,
|
|
getEnhanceCheckpoint,
|
|
getVideoCheckpoint,
|
|
} from "@/lib/video-canvas/task-recovery";
|
|
import { CanvasTaskLeaseLostError } from "@/lib/video-canvas/image-operation";
|
|
import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story";
|
|
import { pollTaskResult, EnhanceTerminalError } from "@/lib/canvas/enhance-task-poll";
|
|
const recoveryTime = Date.parse("2026-09-17T04:00:00Z");
|
|
const checkpoint = {
|
|
operation: "enhance" as const,
|
|
taskId: "saved-upstream",
|
|
sourceImageUrl: "https://example.com/source.png",
|
|
};
|
|
const recoverable = {
|
|
status: "interrupted",
|
|
createdAt: "2026-09-17T03:00:00Z",
|
|
updatedAt: "2026-09-17T03:59:00Z",
|
|
attempts: 1,
|
|
request: {
|
|
kind: "image-operation",
|
|
params: { operation: "enhance", imageUrl: checkpoint.sourceImageUrl },
|
|
},
|
|
result: { upstreamTask: checkpoint },
|
|
};
|
|
test("recovery enforces checkpoint identity, age, cooldown and bounded attempts", () => {
|
|
expect(canResumeCanvasTask(recoverable, recoveryTime)).toBe(true);
|
|
for (const changed of [
|
|
{ status: "failed" },
|
|
{ result: null },
|
|
{ attempts: 20 },
|
|
{ createdAt: "2026-09-15T00:00:00Z" },
|
|
{ updatedAt: "2026-09-17T03:59:50Z" },
|
|
{ attempts: 8 },
|
|
{
|
|
request: {
|
|
kind: "image-operation",
|
|
params: { operation: "enhance", imageUrl: "https://example.com/other.png" },
|
|
},
|
|
},
|
|
])
|
|
expect(canResumeCanvasTask({ ...recoverable, ...changed }, recoveryTime)).toBe(false);
|
|
expect(
|
|
getEnhanceCheckpoint({ ...recoverable, request: { kind: "video", params: {} } }),
|
|
).toBeNull();
|
|
});
|
|
test("restarting enhancement from its checkpoint makes zero POST calls", async () => {
|
|
let posts = 0,
|
|
gets = 0;
|
|
const post = async () => {
|
|
posts++;
|
|
throw Error("must not POST");
|
|
};
|
|
const result = await executeImageOperation(
|
|
recoverable.request.params,
|
|
"owner",
|
|
{
|
|
enhance: post,
|
|
extend: post,
|
|
removeBackground: post,
|
|
angleEdit: post,
|
|
pollEnhance: async (req) => {
|
|
gets++;
|
|
expect(new URL(req.url).searchParams.get("taskId")).toBe(checkpoint.taskId);
|
|
return Response.json({ success: true, original_url: "https://example.com/recovered.png" });
|
|
},
|
|
},
|
|
{ resume: checkpoint, wait: async () => {} },
|
|
);
|
|
expect(result.images[0].originalUrl).toContain("recovered.png");
|
|
expect(posts).toBe(0);
|
|
expect(gets).toBe(1);
|
|
});
|
|
test("invalid checkpoints and a replaced worker lease cannot reach the provider", async () => {
|
|
let calls = 0;
|
|
const handler = async () => {
|
|
calls++;
|
|
throw Error("provider touched");
|
|
};
|
|
const handlers = {
|
|
enhance: handler,
|
|
extend: handler,
|
|
removeBackground: handler,
|
|
angleEdit: handler,
|
|
pollEnhance: handler,
|
|
};
|
|
await expect(
|
|
executeImageOperation(recoverable.request.params, "owner", handlers, {
|
|
resume: { ...checkpoint, sourceImageUrl: "wrong" },
|
|
}),
|
|
).rejects.toThrow("不匹配");
|
|
await expect(
|
|
executeImageOperation(recoverable.request.params, "owner", handlers, {
|
|
assertLease: async () => {
|
|
throw new CanvasTaskLeaseLostError();
|
|
},
|
|
}),
|
|
).rejects.toBeInstanceOf(CanvasTaskLeaseLostError);
|
|
expect(calls).toBe(0);
|
|
});
|
|
test("terminal provider failure stops query recovery instead of staying pending", async () => {
|
|
const handler = async () =>
|
|
Response.json({ terminal: true, detail: "AOS任务失败: invalid image" }, { status: 500 });
|
|
try {
|
|
await executeImageOperation(
|
|
recoverable.request.params,
|
|
"owner",
|
|
{
|
|
enhance: handler,
|
|
extend: handler,
|
|
removeBackground: handler,
|
|
angleEdit: handler,
|
|
pollEnhance: handler,
|
|
},
|
|
{ resume: checkpoint, wait: async () => {} },
|
|
);
|
|
throw Error("expected provider failure");
|
|
} catch (error) {
|
|
expect(error).not.toBeInstanceOf(ImageOperationUncertainError);
|
|
expect(String(error)).toContain("AOS任务失败");
|
|
}
|
|
});
|
|
test("actual AOS polling propagates negative state immediately and bounds elapsed retries", async () => {
|
|
let calls = 0;
|
|
await expect(
|
|
pollTaskResult("https://example.com", "qa", "id", 1, 1000, async () => {
|
|
calls++;
|
|
return Response.json({ data: { state: -1, err_message: "bad image", progress: 0 } });
|
|
}),
|
|
).rejects.toBeInstanceOf(EnhanceTerminalError);
|
|
expect(calls).toBe(1);
|
|
const started = Date.now();
|
|
await expect(
|
|
pollTaskResult("https://example.com", "qa", "id", 1, 25, async () =>
|
|
Response.json({ data: { state: 0, progress: 10 } }),
|
|
),
|
|
).rejects.toThrow("轮询超时");
|
|
expect(Date.now() - started).toBeLessThan(500);
|
|
});
|
|
test("video analysis preserves zero timestamps and all shots without invented camera or audio facts", () => {
|
|
const rows = normalizeVideoAnalysisRows(
|
|
Array.from({ length: 12 }, (_, i) => ({ sceneDescription: `镜头${i}`, startTime: 0 })),
|
|
[],
|
|
"story",
|
|
);
|
|
expect(rows).toHaveLength(12);
|
|
expect(rows[0]).toMatchObject({
|
|
startTime: "0",
|
|
lighting: "",
|
|
backgroundMusic: "",
|
|
cameraMovement: "",
|
|
imagePrompt: "",
|
|
});
|
|
expect(normalizeVideoAnalysisRows([{}, { startTime: 0, backgroundMusic: "无" }])).toEqual([]);
|
|
expect(() =>
|
|
requireAnalysisResult({ summary: "没有镜头", analysisRows: [] }, "video-analysis"),
|
|
).toThrow("有效镜头");
|
|
});
|
|
test("video story refresh restores real rows, pending state and errors without a placeholder shot", () => {
|
|
const story = {
|
|
...node,
|
|
kind: "video-story",
|
|
inputType: "text",
|
|
draftValues: { action: "VIDEO_STORY_ANALYSIS", rows: [] },
|
|
};
|
|
const analysis = {
|
|
...task,
|
|
request: {
|
|
...task.request,
|
|
node: story as any,
|
|
kind: "analysis" as const,
|
|
params: { mode: "video-analysis" },
|
|
},
|
|
result: {
|
|
summary: "视频内容",
|
|
analysisRows: [{ sceneDescription: "杯子", startTime: 0 }],
|
|
framePreviews: ["https://example.com/frame.png"],
|
|
},
|
|
};
|
|
const pending = reconcileCanvasTasks(
|
|
[story],
|
|
[{ ...analysis, status: "running" }],
|
|
"2026-09-17T01:00:00Z",
|
|
);
|
|
expect(pending[0].draftValues).toMatchObject({ status: "loading", rows: [] });
|
|
const failed = reconcileCanvasTasks(
|
|
pending,
|
|
[{ ...analysis, status: "failed", error: "解析失败" }],
|
|
"2026-09-17T01:00:00Z",
|
|
);
|
|
expect(failed[0].draftValues).toMatchObject({ status: "error", error: "解析失败", rows: [] });
|
|
const restored = reconcileCanvasTasks<TaskNode>([], [analysis], "2026-09-17T01:00:00Z")[0];
|
|
expect(restored.kind).toBe("video-story");
|
|
expect(restored.draftValues).toMatchObject({ status: "ready", summary: "视频内容" });
|
|
expect(restored.draftValues?.rows[0]).toMatchObject({
|
|
startTime: "0",
|
|
keyframeUrl: "https://example.com/frame.png",
|
|
lighting: "",
|
|
});
|
|
});
|
|
|
|
test("video recovery requires an original server checkpoint and matching selected model", () => {
|
|
const upstreamTask = {
|
|
operation: "video",
|
|
provider: "aliyun",
|
|
taskId: "video-original",
|
|
modelRef: "model-row",
|
|
modelId: "wan-test",
|
|
providerId: "provider-row",
|
|
baseUrl: "https://provider.invalid",
|
|
};
|
|
const task = {
|
|
...recoverable,
|
|
request: { kind: "video", params: { preferredModelRef: "model-row" } },
|
|
result: { upstreamTask },
|
|
};
|
|
expect(getVideoCheckpoint(task)).toEqual(upstreamTask);
|
|
expect(canResumeCanvasTask(task, recoveryTime)).toBe(true);
|
|
for (const value of [
|
|
undefined,
|
|
{ ...upstreamTask, taskId: "" },
|
|
{ ...upstreamTask, provider: "unknown" },
|
|
{ ...upstreamTask, modelRef: "other" },
|
|
]) {
|
|
expect(getVideoCheckpoint({ ...task, result: { upstreamTask: value } })).toBeNull();
|
|
expect(canResumeCanvasTask({ ...task, result: { upstreamTask: value } }, recoveryTime)).toBe(
|
|
false,
|
|
);
|
|
}
|
|
expect(canResumeCanvasTask({ ...task, attempts: 20 }, recoveryTime)).toBe(false);
|
|
});
|
|
|
|
import { buildAudioRequest, officialAudioBase } from "@/lib/video-canvas/audio-contract";
|
|
import { getAudioCheckpoint } from "@/lib/video-canvas/task-recovery";
|
|
test("official Alibaba audio requests match each model capabilities", () => {
|
|
const tts = buildAudioRequest(
|
|
{ mode: "tts", modelRef: "a", text: "你好", voice: "Cherry", instructions: "温柔自然" },
|
|
"qwen3-tts-instruct-flash",
|
|
);
|
|
expect(tts.path).toContain("multimodal-generation");
|
|
expect(tts.body.input).toMatchObject({
|
|
text: "你好",
|
|
voice: "Cherry",
|
|
instructions: "温柔自然",
|
|
optimize_instructions: true,
|
|
});
|
|
expect(tts.body.input).not.toHaveProperty("rate");
|
|
expect(() =>
|
|
buildAudioRequest(
|
|
{ mode: "tts", modelRef: "a", text: "你好", voice: "Cherry", rate: 2 },
|
|
"qwen3-tts-flash",
|
|
),
|
|
).toThrow("不支持数值");
|
|
expect(() =>
|
|
buildAudioRequest(
|
|
{ mode: "tts", modelRef: "a", text: "你好", voice: "unsupported" },
|
|
"qwen3-tts-flash",
|
|
),
|
|
).toThrow("音色");
|
|
const modern = buildAudioRequest(
|
|
{ mode: "tts", modelRef: "a", text: "你好", voice: "longanlingxin", format: "mp3", rate: 1.5 },
|
|
"qwen-audio-3.0-tts-plus",
|
|
);
|
|
expect(modern.body.input).toMatchObject({ format: "mp3", rate: 1.5, language_hints: ["zh"] });
|
|
});
|
|
test("music validates lyrics and excludes singing fields from instrumental output", () => {
|
|
const music = buildAudioRequest(
|
|
{
|
|
mode: "music",
|
|
modelRef: "a",
|
|
text: "轻快吉他",
|
|
lyrics: "不应该传的歌词",
|
|
instrumental: true,
|
|
format: "mp3",
|
|
},
|
|
"fun-music-v1",
|
|
);
|
|
expect(music.body.input).toEqual({ prompt: "轻快吉他", is_instrumental: true, format: "mp3" });
|
|
expect(() =>
|
|
buildAudioRequest(
|
|
{ mode: "music", modelRef: "a", lyrics: "太短", instrumental: false },
|
|
"fun-music-v1",
|
|
),
|
|
).toThrow("歌词");
|
|
expect(() =>
|
|
buildAudioRequest({ mode: "music", modelRef: "a", text: "音乐" }, "qwen3-tts-flash"),
|
|
).toThrow("不匹配");
|
|
});
|
|
test("audio credentials can only target official domains and correct region", () => {
|
|
expect(
|
|
officialAudioBase("https://dashscope.aliyuncs.com/compatible-mode/v1", "fun-music-v1"),
|
|
).toBe("https://dashscope.aliyuncs.com");
|
|
expect(() => officialAudioBase("https://dashscope.aliyuncs.com.evil.test")).toThrow();
|
|
expect(() => officialAudioBase("http://dashscope.aliyuncs.com")).toThrow();
|
|
expect(() => officialAudioBase("https://dashscope-intl.aliyuncs.com", "fun-music-v1")).toThrow(
|
|
"北京",
|
|
);
|
|
});
|
|
test("only matching persisted audio result may resume archival", () => {
|
|
const task = {
|
|
request: { kind: "audio", params: { modelRef: "ref" } },
|
|
result: {
|
|
upstreamAudio: {
|
|
operation: "audio",
|
|
modelRef: "ref",
|
|
modelId: "qwen3-tts-flash",
|
|
url: "https://example.com/audio.wav",
|
|
format: "wav",
|
|
},
|
|
},
|
|
};
|
|
expect(getAudioCheckpoint(task)).toMatchObject({ modelRef: "ref" });
|
|
expect(
|
|
getAudioCheckpoint({ ...task, request: { kind: "audio", params: { modelRef: "another" } } }),
|
|
).toBeNull();
|
|
expect(
|
|
getAudioCheckpoint({
|
|
...task,
|
|
result: { upstreamAudio: { ...task.result.upstreamAudio, url: "file:///tmp/private" } },
|
|
}),
|
|
).toBeNull();
|
|
});
|