refactor(core): 任务与模型协议治理 - 拆分服务职责并禁止已发起任务取消
This commit is contained in:
38
web/test/api-request.test.ts
Normal file
38
web/test/api-request.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { ApiError, request } from "../src/services/api/request";
|
||||
|
||||
describe("backend API request error semantics", () => {
|
||||
test("unwraps a successful backend envelope", async () => {
|
||||
await expect(request(Promise.resolve({ data: { code: 0, data: { id: "task-1" }, msg: "ok" }, status: 200 }))).resolves.toEqual({ id: "task-1" });
|
||||
});
|
||||
|
||||
test("preserves business code from a non-zero envelope", async () => {
|
||||
const thrown = await request(Promise.resolve({ data: { code: 40901, data: null, msg: "任务状态已变化" }, status: 200 })).catch((error) => error);
|
||||
|
||||
expect(thrown).toBeInstanceOf(ApiError);
|
||||
expect(thrown).toMatchObject({ name: "ApiError", status: 200, code: 40901, message: "任务状态已变化", retryable: false });
|
||||
});
|
||||
|
||||
test("preserves HTTP status, backend code and retryability", async () => {
|
||||
const axiosError = {
|
||||
isAxiosError: true,
|
||||
message: "Request failed with status code 429",
|
||||
response: {
|
||||
status: 429,
|
||||
data: { code: 42901, data: null, msg: "请求过于频繁,请稍后重试" },
|
||||
},
|
||||
};
|
||||
const thrown = await request(Promise.reject(axiosError)).catch((error) => error);
|
||||
|
||||
expect(thrown).toBeInstanceOf(ApiError);
|
||||
expect(thrown).toMatchObject({ status: 429, code: 42901, message: "请求过于频繁,请稍后重试", retryable: true });
|
||||
expect(thrown.cause).toBe(axiosError);
|
||||
});
|
||||
|
||||
test("converts Axios cancellation to AbortError", async () => {
|
||||
const thrown = await request(Promise.reject({ __CANCEL__: true, code: "ERR_CANCELED" })).catch((error) => error);
|
||||
|
||||
expect(thrown).toMatchObject({ name: "AbortError", message: "请求已取消" });
|
||||
});
|
||||
});
|
||||
@@ -156,7 +156,7 @@ test("Dreamina submission uncertainty is not an accepted background task", () =>
|
||||
expect(localDreaminaCancellationCopy(uncertain)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Canvas task surfaces route Dreamina uncertainty through shared display semantics", async () => {
|
||||
test("Canvas task surfaces route Dreamina uncertainty through shared display semantics without cancellation", async () => {
|
||||
const [nodeSource, detailSource, scriptSource, taskCenterSource, createSource] = await Promise.all([
|
||||
Bun.file(new URL("../src/components/canvas/canvas-node-content.tsx", import.meta.url)).text(),
|
||||
Bun.file(new URL("../src/pages/canvas/canvas-project-status-dialogs.tsx", import.meta.url)).text(),
|
||||
@@ -166,30 +166,8 @@ test("Canvas task surfaces route Dreamina uncertainty through shared display sem
|
||||
]);
|
||||
expect(nodeSource).toContain("generationTaskStageLabel(displayTask)");
|
||||
expect(nodeSource).toContain("generationTaskShowsProgress(displayTask)");
|
||||
const loadingContentSource = sourceSection(nodeSource, "function LoadingContent(", "function useTaskElapsed(");
|
||||
const cancelBranchStart = loadingContentSource.indexOf("{!submissionUncertain ? (");
|
||||
const cancelBranchEndMarker = ") : null}";
|
||||
const cancelBranchEnd = loadingContentSource.indexOf(cancelBranchEndMarker, cancelBranchStart);
|
||||
expect(cancelBranchStart).toBeGreaterThanOrEqual(0);
|
||||
expect(cancelBranchEnd).toBeGreaterThan(cancelBranchStart);
|
||||
|
||||
const cancelBranchSource = loadingContentSource.slice(cancelBranchStart, cancelBranchEnd + cancelBranchEndMarker.length);
|
||||
const cancelButtonStart = cancelBranchSource.indexOf("<button");
|
||||
const cancelButtonEndMarker = "</button>";
|
||||
const cancelButtonEnd = cancelBranchSource.indexOf(cancelButtonEndMarker, cancelButtonStart);
|
||||
expect(cancelButtonStart).toBeGreaterThan(0);
|
||||
expect(cancelButtonEnd).toBeGreaterThan(cancelButtonStart);
|
||||
expect(cancelBranchSource.slice(0, cancelButtonStart)).toBe("{!submissionUncertain ? ( ");
|
||||
|
||||
const cancelButtonSource = cancelBranchSource.slice(cancelButtonStart, cancelButtonEnd + cancelButtonEndMarker.length);
|
||||
const onClickStart = cancelButtonSource.indexOf("onClick={(event) => {");
|
||||
const onClickEnd = cancelButtonSource.indexOf("}}", onClickStart);
|
||||
expect(cancelButtonSource).toContain("<button");
|
||||
expect(onClickStart).toBeGreaterThanOrEqual(0);
|
||||
expect(onClickEnd).toBeGreaterThan(onClickStart);
|
||||
expect(cancelButtonSource.slice(onClickStart, onClickEnd)).toContain("onCancelTask?.(node);");
|
||||
expect(cancelButtonSource).toContain("<Square");
|
||||
expect(cancelButtonSource).toContain("取消");
|
||||
expect(nodeSource).not.toContain("onCancelTask");
|
||||
expect(nodeSource).not.toContain("取消生成");
|
||||
expect(nodeSource).not.toContain('node.metadata?.taskStage || (taskId ? "任务处理中" : "正在创建任务")');
|
||||
expect(detailSource).toContain("generationTaskStageLabel(task)");
|
||||
expect(detailSource).toContain("generationTaskShowsProgress(task) ? <TaskDetailItem");
|
||||
@@ -198,9 +176,9 @@ test("Canvas task surfaces route Dreamina uncertainty through shared display sem
|
||||
expect(scriptSource).toContain("generationTaskShowsProgress(displayTask)");
|
||||
expect(scriptSource).not.toContain('node.metadata.taskStage || "正在创建任务"');
|
||||
expect(nodeSource).toContain("isGenerationTaskSubmissionUncertain(errorDisplayTask)");
|
||||
expect(taskCenterSource).toContain('if (action === "retry" && currentTask && isGenerationTaskSubmissionUncertain(currentTask))');
|
||||
expect(taskCenterSource).toContain("if (currentTask && isGenerationTaskSubmissionUncertain(currentTask))");
|
||||
expect(taskCenterSource).toContain("不能自动重试;请先核对官方状态,避免重复生成");
|
||||
expect(taskCenterSource).toContain('onRetry={() => void runAction(task.id, "retry")}');
|
||||
expect(taskCenterSource).toContain('onRetry={() => void runAction(task.id)}');
|
||||
expect(taskCenterSource).toContain("<TaskListRow");
|
||||
expect(taskCenterSource).toContain("<TaskGridCard");
|
||||
expect(createSource).not.toContain('item.generationStage === "submission_unknown"');
|
||||
|
||||
69
web/test/image-api-contracts.test.ts
Normal file
69
web/test/image-api-contracts.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { consumeChatCompletionStreamText, consumeGeminiStreamText, consumeResponseStreamText, parseChatCompletionPayload, parseGeminiToolResponse, parseImagePayload, parseToolResponse } from "../src/services/api/image-response";
|
||||
import { normalizeQuality, resolveRequestSize, validateImageSize } from "../src/services/api/image-validation";
|
||||
|
||||
describe("image api contracts", () => {
|
||||
test("规范化质量和尺寸,拒绝超出边界的图像", () => {
|
||||
expect(normalizeQuality("1k")).toBe("low");
|
||||
expect(normalizeQuality("2k")).toBe("medium");
|
||||
expect(normalizeQuality("4k")).toBe("high");
|
||||
expect(resolveRequestSize("medium", "16:9")).toMatch(/^\d+x\d+$/);
|
||||
expect(resolveRequestSize("medium", "9:16")).toMatch(/^\d+x\d+$/);
|
||||
expect(() => validateImageSize(1025, 1024)).toThrow("16 的倍数");
|
||||
expect(() => validateImageSize(3072, 1008)).toThrow("宽高比");
|
||||
expect(() => validateImageSize(3840, 2176)).toThrow("总像素");
|
||||
});
|
||||
|
||||
test("解包 b64_json、url 和业务错误", () => {
|
||||
expect(parseImagePayload({ data: [{ b64_json: "abc" }, { url: "https://example.com/image.png" }] })).toEqual([
|
||||
{ id: expect.any(String), dataUrl: "data:image/png;base64,abc" },
|
||||
{ id: expect.any(String), dataUrl: "https://example.com/image.png" },
|
||||
]);
|
||||
expect(() => parseImagePayload({ data: [] })).toThrow("接口没有返回图片");
|
||||
expect(() => parseImagePayload({ code: 1001, msg: "额度不足" })).toThrow("额度不足");
|
||||
});
|
||||
|
||||
test("统一解析 Responses、Chat Completions 和 Gemini 工具调用", () => {
|
||||
expect(
|
||||
parseToolResponse({
|
||||
output: [{ type: "function_call", call_id: "call-1", name: "search", arguments: '{"q":"canvas"}' }],
|
||||
}),
|
||||
).toMatchObject({ toolCalls: [{ id: "call-1", function: { name: "search", arguments: '{"q":"canvas"}' } }] });
|
||||
expect(
|
||||
parseChatCompletionPayload({
|
||||
choices: [{ message: { content: "done", tool_calls: [{ id: "call-2", function: { name: "search", arguments: "{}" } }] } }],
|
||||
}),
|
||||
).toMatchObject({ content: "done", toolCalls: [{ id: "call-2", function: { name: "search" } }] });
|
||||
expect(
|
||||
parseGeminiToolResponse({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{ text: "thinking", thought: true },
|
||||
{ functionCall: { id: "call-3", name: "search", args: { q: "canvas" } }, thoughtSignature: "sig" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toMatchObject({ reasoning: "thinking", toolCalls: [{ id: "call-3", thoughtSignature: "sig", function: { name: "search", arguments: '{"q":"canvas"}' } }] });
|
||||
});
|
||||
|
||||
test("SSE 增量解析保留正文、reasoning 和工具调用", () => {
|
||||
const responseState = { buffer: "", text: "", reasoning: "" };
|
||||
consumeResponseStreamText(responseState, 'data: {"type":"response.output_text.delta","delta":"hel"}\n\n');
|
||||
consumeResponseStreamText(responseState, 'data: {"type":"response.reasoning.delta","delta":"why"}\n\n');
|
||||
consumeResponseStreamText(responseState, 'data: {"type":"response.output_text.delta","delta":"lo"}', undefined, undefined, true);
|
||||
expect(responseState).toMatchObject({ text: "hello", reasoning: "why", buffer: "" });
|
||||
|
||||
const chatState = { buffer: "", text: "", reasoning: "", toolCalls: new Map() };
|
||||
consumeChatCompletionStreamText(chatState, 'data: {"choices":[{"delta":{"content":"ok","reasoning_content":"r"}}]}\n\n');
|
||||
expect(chatState).toMatchObject({ text: "ok", reasoning: "r" });
|
||||
|
||||
const geminiState = { buffer: "", text: "", reasoning: "", toolCalls: [] };
|
||||
consumeGeminiStreamText(geminiState, 'data: {"candidates":[{"content":{"parts":[{"text":"hi"}]}}]}\n\n');
|
||||
expect(geminiState).toMatchObject({ text: "hi" });
|
||||
});
|
||||
});
|
||||
@@ -37,20 +37,26 @@ describe("media fallback", () => {
|
||||
});
|
||||
|
||||
describe("task cancellation policy", () => {
|
||||
test("offers cancellation only while a task is queued", () => {
|
||||
const shared = source("../src/pages/tasks/task-shared.tsx");
|
||||
test("does not expose cancellation after a task is created", () => {
|
||||
const list = source("../src/pages/tasks/task-list-row.tsx");
|
||||
const grid = source("../src/pages/tasks/task-grid-card.tsx");
|
||||
const page = source("../src/pages/tasks/index.tsx");
|
||||
|
||||
expect(shared).toContain('return task.status === "queued";');
|
||||
expect(list).toContain("const isCancellable = isTaskCancellable(task);");
|
||||
expect(list).toContain("{isCancellable ? (");
|
||||
expect(grid).toContain("const isCancellable = isTaskCancellable(task);");
|
||||
expect(grid).toContain("{isCancellable ? (");
|
||||
expect(page).toContain('if (action === "cancel" && currentTask && !isTaskCancellable(currentTask))');
|
||||
expect(page).toContain('message.warning("任务已开始生成,无法取消")');
|
||||
expect(page).toContain('detailTask.provider === "dreamina-cli" && isTaskCancellable(detailTask)');
|
||||
expect(page).not.toContain('detailTask.status === "queued" || detailTask.status === "running"');
|
||||
expect(list).not.toContain("isTaskCancellable");
|
||||
expect(list).not.toContain("取消任务");
|
||||
expect(grid).not.toContain("isTaskCancellable");
|
||||
expect(grid).not.toContain("取消任务");
|
||||
expect(page).not.toContain("cancelGenerationTask");
|
||||
expect(page).not.toContain('runAction(detailTask.id, "cancel")');
|
||||
expect(page).toContain('if (task.status === "queued" || task.status === "running")');
|
||||
expect(page).toContain("任务正在执行,不能删除本机记录");
|
||||
});
|
||||
|
||||
test("batch stop only applies to items still waiting locally", () => {
|
||||
const batches = source("../src/pages/canvas/use-canvas-generation-batches.ts");
|
||||
|
||||
expect(batches).toContain('item.status === "waiting" && !nodeById.get(item.nodeId)?.metadata?.taskId');
|
||||
expect(batches).toContain('item.status === "waiting" && stoppableItems.some((candidate) => candidate.id === item.id)');
|
||||
expect(batches).not.toContain('item.status === "waiting" || item.status === "submitting"');
|
||||
});
|
||||
});
|
||||
|
||||
36
web/test/video-api-contracts.test.ts
Normal file
36
web/test/video-api-contracts.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { unwrapEnvelope, videoTaskId } from "../src/services/api/video-response";
|
||||
import { isPublicMediaUrl, normalizeVideoResolution, normalizeVideoSeconds, normalizeVideoSize } from "../src/services/api/video-validation";
|
||||
|
||||
describe("video API response contracts", () => {
|
||||
test("解包裸响应和后端 envelope", () => {
|
||||
expect(unwrapEnvelope({ id: "task-1" }, "缺少任务")).toEqual({ id: "task-1" });
|
||||
expect(unwrapEnvelope({ code: 0, data: { id: "task-2" }, msg: "ok" }, "缺少任务")).toEqual({ id: "task-2" });
|
||||
expect(videoTaskId({ request_id: "request-1" })).toBe("request-1");
|
||||
});
|
||||
|
||||
test("业务失败和空数据不会被静默转换为成功", () => {
|
||||
expect(() => unwrapEnvelope({ code: 401, data: null, msg: "未授权" }, "缺少任务")).toThrow("未授权");
|
||||
expect(() => unwrapEnvelope({ code: 0, data: null, msg: "ok" }, "缺少任务")).toThrow("缺少任务");
|
||||
});
|
||||
});
|
||||
|
||||
describe("video request normalization", () => {
|
||||
test("规范化时长、比例尺寸和分辨率", () => {
|
||||
expect(normalizeVideoSeconds("0")).toBe("6");
|
||||
expect(normalizeVideoSeconds("8.9")).toBe("8");
|
||||
expect(normalizeVideoSize("16:9")).toBe("1280x720");
|
||||
expect(normalizeVideoSize("auto")).toBeNull();
|
||||
expect(normalizeVideoResolution("low")).toBe("480p");
|
||||
expect(normalizeVideoResolution("2k")).toBe("1440p");
|
||||
});
|
||||
|
||||
test("只把 HTTP(S) 地址视为公网媒体地址", () => {
|
||||
expect(isPublicMediaUrl("https://cdn.example.com/video.mp4")).toBe(true);
|
||||
expect(isPublicMediaUrl("http://localhost/video.mp4")).toBe(true);
|
||||
expect(isPublicMediaUrl("asset://resource-1")).toBe(false);
|
||||
expect(isPublicMediaUrl("data:video/mp4;base64,AAAA")).toBe(false);
|
||||
expect(isPublicMediaUrl("/resources/video.mp4")).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user