visionA/visionA-frontend/src/lib/api/conversion.test.ts
jim800121chen e02059eff2 feat(visionA-frontend): Phase 0.8 conversion UI — sidebar tab + 6 view + 5 e2e flow tests
Phase 0.8 對應後端轉檔功能的 frontend UI。完整 state machine(idle / uploading /
queued / running / succeeded / failed / expired)+ XHR upload progress + polling +
half-auto result handling(加到模型庫 / 下載)。

新增 src/app/conversion/(11 元件 + 12 test files,~5,000 行 prod+test,310/310 tests 全綠):

- page.tsx:state router(mount → bootstrap → 依 store.uiState 切換 view)+ toast on
  store.error(aborted / active_job_exists 不 toast,避免雙重提示)
- IdleForm.tsx + FileDropzone.tsx + ChipSelector.tsx:上傳表單(拖放 + .onnx/.tflite
  format 驗證 + 500MB 大小限制 + KL520/630/720/730 chip 選擇 + ref images ≤100×10MB)
- UploadingView.tsx:XHR upload progress 0-100% + EWMA ETA(alpha=0.3 平滑,<5s 顯示
  「即將完成」避抖動)+ beforeunload warning + AlertDialog 取消
- ProcessingView.tsx:StageIndicator 三段式(解析模型 → 量化編譯 → 編譯 NEF)+
  Progress bar 三模式(queued / determinate / indeterminate)+ tab title `(轉檔中)` 防疊加 +
  409 active_job_exists banner(switchedFromActiveJob flag)
- SuccessView.tsx + PromoteDialog.tsx:兩按鈕(加到模型庫 / 下載)視覺平衡不暗示預設答案 +
  7 天 expires_at 倒數(mount 時 setTimeout 鎖過期那刻 + 60s tick)+ PromoteDialog 單欄位
  name 驗證(≤100 字元 / 無 /\\)+ spinner-during-close 阻擋 + 409 already_imported 特殊
  訊息 + toast.success router.push 跳模型清單
- FailedView.tsx:5 個 known error code 翻譯(UNSUPPORTED_FORMAT / INVALID_CHECKSUM /
  QUANTIZATION_FAILED / MODEL_TOO_LARGE / QUOTA_EXCEEDED)+ unknown fallback +
  3 個建議解決方法 + Job ID 前 8 字元(供回報)+ 「重新開始」
- ExpiredView.tsx:橘色 hero(過期不 alarming)+ source/chip 摘要 + 「重新轉檔」→
  store.reset()

新增 src/stores/conversion-store.ts(Zustand store + 29 tests):
- 7 個 UI state machine(不允許跳階段)
- recursive setTimeout polling(running 5s / queued 10s / 5xx 指數退避 cap 30s)+
  visibilitychange 暫停/恢復
- 不持久化 jobId(純靠 backend getActiveConversion() lazy rebuild ownership)
- AbortController 防 stale request + 取消上傳
- switchedFromActiveJob flag(409 自動切到既存 job + UI 顯示 banner)
- formDraft(chip / taskName 提到 store,failed→idle 後保留設定,file picker 重選;
  File 物件不能序列化只留 local)

新增 src/lib/api/conversion.ts + types/conversion.ts(5 client 函式 + 22 tests):
- initConversion:XHR multipart + onUploadProgress + AbortSignal
- getActiveConversion / getConversion / promoteConversionToModels:標準 fetch
- getConversionDownloadURL:純函式,回 `/api/conversion/{job_id}/download` 給 anchor
  download 觸發(server-side 302 → FAA,token 不過 frontend JS)
- ConversionAPIError(status, code, message, requestId?),code 統一全小寫對齊
  conversion.error.<code> i18n key 命名

新增 src/lib/utils/eta.ts(EWMA 演算法純函式 + 19 tests):抽出 smoothSpeed /
estimateRemainingSeconds / instantSpeedBytesPerSec / computeEtaUpdate(test 友善)。

新增 src/app/conversion/e2e-conversion-flow.test.tsx(5 e2e flow tests):
- happy path .onnx + KL720 + 0 ref images(idle → upload → polling → succeeded → 加到模型庫)
- variant 5 ref images
- upload fail → retry(form 設定保留)
- polling 5xx 指數退避 → 恢復繼續
- expired job → ExpiredView → 重新轉檔

修改 sidebar.tsx:左側 nav 新增「轉檔」tab(Wand2 icon,模型庫之後)。
修改 i18n 字典:新增 ~150 個 conversion.* keys(中英雙語對齊)。

PRD §9 14 條功能驗收條件全達成(4 條整合驗收等 stage 部署)。

驗證:pnpm lint / test (310/310) / build 全綠。

對齊文件:
- .autoflow/02-prd/features/feature-converter-integration.md
- .autoflow/03-design/wireframes/wireframe-conversion.md
- .autoflow/03-design/flows/flow-conversion.md
- .autoflow/04-architecture/api/api-conversion.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:56:54 +08:00

553 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Conversion API Client 單元測試Phase 0.8 F-T2
*
* 覆蓋 5 個 endpoint
* 1. initConversion — XHR multipart upload + progress / abort / 409
* 2. getActiveConversion — has_active true / false / 異常
* 3. getConversion — 200 / 403 / 404
* 4. promoteConversionToModels — 201 / 409 / parse 缺欄
* 5. getConversionDownloadURL — 純函式輸入輸出
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ConversionAPIError,
getActiveConversion,
getConversion,
getConversionDownloadURL,
initConversion,
promoteConversionToModels,
} from "./conversion";
/* -------------------------------------------------------------------------- */
/* XHR mock helper — 取代 jsdom 的 XMLHttpRequest */
/* -------------------------------------------------------------------------- */
interface MockXHRInstance {
open: ReturnType<typeof vi.fn>;
send: ReturnType<typeof vi.fn>;
abort: ReturnType<typeof vi.fn>;
setRequestHeader: ReturnType<typeof vi.fn>;
getResponseHeader: ReturnType<typeof vi.fn>;
upload: { onprogress: ((ev: ProgressEvent) => void) | null };
withCredentials: boolean;
status: number;
responseText: string;
onload: (() => void) | null;
onerror: (() => void) | null;
ontimeout: (() => void) | null;
// 觸發測試輔助
_fireLoad: (status: number, body: unknown, headers?: Record<string, string>) => void;
_fireError: () => void;
_fireProgress: (loaded: number, total: number) => void;
_formDataSent: FormData | null;
}
function installMockXHR(): { instances: MockXHRInstance[] } {
const instances: MockXHRInstance[] = [];
// 必須是真 constructor function不能用 vi.fn().mockImplementation — 那回的是 mock不可 `new`
function MockXHR(this: MockXHRInstance) {
const headers: Record<string, string> = {};
// 直接賦值到 this避免 this 別名被 lint 警示
this.open = vi.fn();
this.abort = vi.fn();
this.setRequestHeader = vi.fn();
this.getResponseHeader = vi.fn((k: string) => headers[k.toLowerCase()] ?? null);
this.upload = { onprogress: null };
this.withCredentials = false;
this.status = 0;
this.responseText = "";
this.onload = null;
this.onerror = null;
this.ontimeout = null;
this._formDataSent = null;
// send / fire* 需要持有 instance 參考;用 instance push 後再裝上
instances.push(this);
const idx = instances.length - 1;
this.send = vi.fn((body: unknown) => {
instances[idx]._formDataSent = body instanceof FormData ? body : null;
});
this._fireLoad = (status, body, hdrs) => {
const ref = instances[idx];
ref.status = status;
ref.responseText =
typeof body === "string" ? body : body == null ? "" : JSON.stringify(body);
if (hdrs) {
for (const [k, v] of Object.entries(hdrs)) headers[k.toLowerCase()] = v;
}
ref.onload?.();
};
this._fireError = () => {
instances[idx].onerror?.();
};
this._fireProgress = (loaded, total) => {
instances[idx].upload.onprogress?.({
lengthComputable: true,
loaded,
total,
} as ProgressEvent);
};
}
// 覆蓋 global XMLHttpRequest
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = MockXHR;
return { instances };
}
/* -------------------------------------------------------------------------- */
/* fetch mock helper — 給 4 個非 init 的 endpoint */
/* -------------------------------------------------------------------------- */
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
vi.restoreAllMocks();
});
/* ========================================================================== */
/* 1. initConversion */
/* ========================================================================== */
describe("initConversion", () => {
it("happy pathmultipart 含 model + ref_images + platform回 normalized Job", async () => {
const { instances } = installMockXHR();
const file = new File(["onnx-bytes"], "yolov5s.onnx", {
type: "application/octet-stream",
});
const ref = new File(["img"], "ref.jpg", { type: "image/jpeg" });
const promise = initConversion({
file,
refImages: [ref],
targetChip: "KL720",
taskName: "yolov5s_test",
});
// 確認 XHR 已建立並送 form
expect(instances.length).toBe(1);
const xhr = instances[0];
expect(xhr.open).toHaveBeenCalledWith(
"POST",
expect.stringMatching(/\/api\/conversion\/init$/),
true,
);
expect(xhr.withCredentials).toBe(true);
const form = xhr._formDataSent;
expect(form).toBeInstanceOf(FormData);
// model 檔
expect((form!.get("model") as File).name).toBe("yolov5s.onnx");
// ref_images[](多筆同 key
const refs = form!.getAll("ref_images[]");
expect(refs.length).toBe(1);
expect((refs[0] as File).name).toBe("ref.jpg");
// text fields — backend 用 `model_id` 欄裝 taskName、`platform` 用 wire 數字格式
expect(form!.get("model_id")).toBe("yolov5s_test");
expect(form!.get("platform")).toBe("720");
expect(form!.get("version")).toBe("v1.0.0");
// 模擬後端 200 回 envelope
xhr._fireLoad(200, {
success: true,
data: {
job_id: "550e8400-e29b-41d4-a716-446655440000",
status: "running",
stage: "onnx",
progress: 0,
created_at: "2026-04-30T12:00:00Z",
expires_at: "2026-05-07T12:00:00Z",
source_filename: "yolov5s.onnx",
target_chip: "720",
},
});
const job = await promise;
expect(job.job_id).toBe("550e8400-e29b-41d4-a716-446655440000");
expect(job.status).toBe("running");
expect(job.target_chip).toBe("KL720"); // wire 520→KL520 normalize
expect(job.stage).toBe("onnx");
});
it("沒給 taskName 時用檔名當 model_id", async () => {
const { instances } = installMockXHR();
const file = new File(["x"], "model.onnx");
const promise = initConversion({ file, targetChip: "KL520" });
const xhr = instances[0];
xhr._fireLoad(200, {
success: true,
data: {
job_id: "j1",
status: "running",
stage: "onnx",
created_at: "2026-04-30T12:00:00Z",
expires_at: "2026-05-07T12:00:00Z",
source_filename: "model.onnx",
target_chip: "520",
},
});
await promise;
expect(xhr._formDataSent!.get("model_id")).toBe("model.onnx");
expect(xhr._formDataSent!.get("platform")).toBe("520");
});
it("onUploadProgress 被呼叫", async () => {
const { instances } = installMockXHR();
const file = new File(["x".repeat(100)], "m.onnx");
const onUploadProgress = vi.fn();
const promise = initConversion({
file,
targetChip: "KL520",
onUploadProgress,
});
const xhr = instances[0];
xhr._fireProgress(50, 100);
xhr._fireProgress(100, 100);
xhr._fireLoad(200, {
success: true,
data: {
job_id: "j2",
status: "running",
stage: "onnx",
created_at: "2026-04-30T12:00:00Z",
expires_at: "2026-05-07T12:00:00Z",
source_filename: "m.onnx",
target_chip: "520",
},
});
await promise;
expect(onUploadProgress).toHaveBeenCalledWith(50, 100);
expect(onUploadProgress).toHaveBeenCalledWith(100, 100);
expect(onUploadProgress).toHaveBeenCalledTimes(2);
});
it("409 active_job_exists → throw ConversionAPIError(409, 'active_job_exists', requestId)", async () => {
const { instances } = installMockXHR();
const file = new File(["x"], "m.onnx");
const promise = initConversion({ file, targetChip: "KL520" });
instances[0]._fireLoad(
409,
{
success: false,
error: {
code: "active_job_exists",
message: "你已有進行中任務",
},
},
{ "X-Request-Id": "req-abc" },
);
await expect(promise).rejects.toBeInstanceOf(ConversionAPIError);
try {
await promise;
} catch (e) {
const err = e as ConversionAPIError;
expect(err.status).toBe(409);
expect(err.code).toBe("active_job_exists");
expect(err.message).toBe("你已有進行中任務");
expect(err.requestId).toBe("req-abc");
}
});
it("network error → throw ConversionAPIError(0, 'network_error')", async () => {
const { instances } = installMockXHR();
const file = new File(["x"], "m.onnx");
const promise = initConversion({ file, targetChip: "KL520" });
instances[0]._fireError();
await expect(promise).rejects.toMatchObject({
name: "ConversionAPIError",
status: 0,
code: "network_error",
});
});
it("AbortSignal.abort → 呼叫 xhr.abort 並 reject ConversionAPIError(0, 'aborted')", async () => {
const { instances } = installMockXHR();
const file = new File(["x"], "m.onnx");
const ctrl = new AbortController();
const promise = initConversion({
file,
targetChip: "KL520",
signal: ctrl.signal,
});
ctrl.abort();
await expect(promise).rejects.toMatchObject({
name: "ConversionAPIError",
code: "aborted",
});
expect(instances[0].abort).toHaveBeenCalled();
});
it("已 abort 的 signal → 立刻 reject 不送 XHR", async () => {
const { instances } = installMockXHR();
const file = new File(["x"], "m.onnx");
const ctrl = new AbortController();
ctrl.abort();
const promise = initConversion({
file,
targetChip: "KL520",
signal: ctrl.signal,
});
await expect(promise).rejects.toMatchObject({ code: "aborted" });
expect(instances[0].send).not.toHaveBeenCalled();
});
});
/* ========================================================================== */
/* 2. getActiveConversion */
/* ========================================================================== */
describe("getActiveConversion", () => {
it("has_active=true → 回 normalized Job", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
success: true,
data: {
has_active: true,
job: {
job_id: "j-active",
status: "running",
stage: "bie",
progress: 45,
created_at: "2026-04-30T12:00:00Z",
expires_at: "2026-05-07T12:00:00Z",
source_filename: "yolov5s.onnx",
target_chip: "720",
},
},
}),
);
const job = await getActiveConversion();
expect(job).not.toBeNull();
expect(job!.job_id).toBe("j-active");
expect(job!.target_chip).toBe("KL720");
expect(job!.stage).toBe("bie");
expect(job!.progress).toBe(45);
});
it("has_active=false → 回 null", async () => {
fetchMock.mockResolvedValue(
jsonResponse({ success: true, data: { has_active: false, job: null } }),
);
const job = await getActiveConversion();
expect(job).toBeNull();
});
it("backend 401 → throw ConversionAPIError(401, 'unauthorized')", async () => {
// F-T2 review Major #1code 統一全小寫,對齊 conversion.md §6 i18n key 命名
fetchMock.mockResolvedValue(new Response("", { status: 401 }));
await expect(getActiveConversion()).rejects.toMatchObject({
name: "ConversionAPIError",
status: 401,
code: "unauthorized",
});
});
});
/* ========================================================================== */
/* 3. getConversion */
/* ========================================================================== */
describe("getConversion", () => {
it("成功回 normalized Jobcompleted → succeeded", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
success: true,
data: {
job_id: "j-1",
status: "completed",
progress: 100,
created_at: "2026-04-30T12:00:00Z",
expires_at: "2026-05-07T12:00:00Z",
source_filename: "m.onnx",
target_chip: "520",
error_code: null,
error_message: null,
},
}),
);
const job = await getConversion("j-1");
expect(job.status).toBe("succeeded"); // completed → succeeded
expect(job.target_chip).toBe("KL520");
expect(job.error).toBeUndefined();
});
it("failed 時 error_code/message 包成 error 物件", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
success: true,
data: {
job_id: "j-2",
status: "failed",
progress: 30,
created_at: "2026-04-30T12:00:00Z",
expires_at: "2026-05-07T12:00:00Z",
source_filename: "m.onnx",
target_chip: "720",
error_code: "QUANTIZATION_FAILED",
error_message: "Custom op X not supported",
},
}),
);
const job = await getConversion("j-2");
expect(job.status).toBe("failed");
expect(job.error).toEqual({
code: "QUANTIZATION_FAILED",
message: "Custom op X not supported",
});
});
it("404 not_found → throw ConversionAPIError(404, 'not_found')", async () => {
fetchMock.mockResolvedValue(
jsonResponse(
{ success: false, error: { code: "not_found", message: "任務不存在" } },
404,
),
);
await expect(getConversion("missing")).rejects.toMatchObject({
name: "ConversionAPIError",
status: 404,
code: "not_found",
});
});
it("403 forbidden → throw ConversionAPIError(403, 'forbidden')", async () => {
fetchMock.mockResolvedValue(
jsonResponse(
{ success: false, error: { code: "forbidden", message: "你無權存取此任務" } },
403,
),
);
await expect(getConversion("other-user-job")).rejects.toMatchObject({
status: 403,
code: "forbidden",
});
});
it("空 jobId → 立刻 throw ConversionAPIError 不打 fetch", async () => {
await expect(getConversion("")).rejects.toMatchObject({
code: "validation_failed",
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
/* ========================================================================== */
/* 4. promoteConversionToModels */
/* ========================================================================== */
describe("promoteConversionToModels", () => {
it("成功回 { model_id }", async () => {
fetchMock.mockResolvedValue(
jsonResponse(
{
success: true,
data: {
model_id: "m-abc-123",
source: "converted",
source_job_id: "j-1",
name: "yolov5s_kl720",
target_chip: "kl720",
file_size: 12345678,
status: "ready",
created_at: "2026-04-30T12:30:00Z",
},
},
201,
),
);
const res = await promoteConversionToModels("j-1", { name: "yolov5s_kl720" });
expect(res).toEqual({ model_id: "m-abc-123" });
// 驗證 request body
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe("POST");
expect(init.body).toBe(JSON.stringify({ name: "yolov5s_kl720" }));
});
it("body 省略時送空物件", async () => {
fetchMock.mockResolvedValue(
jsonResponse(
{ success: true, data: { model_id: "m-1" } },
200,
),
);
await promoteConversionToModels("j-2");
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.body).toBe("{}");
});
it("409 job_not_completed → throw ConversionAPIError(409, 'job_not_completed')", async () => {
fetchMock.mockResolvedValue(
jsonResponse(
{ success: false, error: { code: "job_not_completed", message: "尚未完成" } },
409,
),
);
await expect(promoteConversionToModels("j-3")).rejects.toMatchObject({
status: 409,
code: "job_not_completed",
});
});
it("response 缺 model_id → throw ConversionAPIError(500, 'parse_error')", async () => {
fetchMock.mockResolvedValue(
jsonResponse({ success: true, data: { source: "converted" } }, 201),
);
await expect(promoteConversionToModels("j-4")).rejects.toMatchObject({
code: "parse_error",
status: 500,
});
});
it("空 jobId → 立刻 throw 不打 fetch", async () => {
await expect(promoteConversionToModels("")).rejects.toMatchObject({
code: "validation_failed",
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
/* ========================================================================== */
/* 5. getConversionDownloadURL */
/* ========================================================================== */
describe("getConversionDownloadURL", () => {
it("組相對 URL不含 base給 anchor / location.href 用)", () => {
expect(getConversionDownloadURL("550e8400-e29b-41d4-a716-446655440000")).toBe(
"/api/conversion/550e8400-e29b-41d4-a716-446655440000/download",
);
});
it("encodeURIComponent 防止 jobId 含特殊字元注入", () => {
expect(getConversionDownloadURL("a/b?c=d")).toBe(
"/api/conversion/a%2Fb%3Fc%3Dd/download",
);
});
});