visionA/visionA-frontend/src/stores/model-store.test.ts
jim800121chen 53e8ab4ae1 feat(visionA-frontend): Phase 0.9 模型庫下載 — 前端對接 FAA delegated download
對齊 ADR-017 v1.2 決策 2:model-card 下載按鈕 → 打 visionA endpoint 拿 url+token
→ fetch 跨 origin 直連 FAA + Authorization Bearer → blob 觸發下載(不經 visionA、不經 AWS)。

- src/lib/api/model-download.ts: getModelDownload(打 GET /api/models/:id/download)+
  downloadModelFile(fetch + Bearer header + blob,credentials:"omit" 不帶 visionA cookie)+
  triggerBlobDownload(延遲 revoke + finally 釋放)+ deriveDownloadFilename + ModelDownloadError
- model-store: downloadingId 互斥(同時只一個下載、finally 必清)+ downloadModel action +
  isModelDownloadable(source==converted && status==ready)
- model-card: 下載按鈕(converted+ready 才顯示、上傳類隱藏;preventDefault+stopPropagation
  防觸發外層 Link 導航;loading;toast 錯誤)
- i18n zh/en: models.action.download.* / models.download.*(各 error code 對應訊息)

關鍵差異:模型庫下載跨 origin + 需 Bearer header,不能用 <a href> navigation(無法帶 header),
必須 fetch+blob。與既有 conversion download(同 origin navigation)分流。

測試: 43 unit + 互動 test(mock fetch / 按鈕互動 / 顯示條件 / error 分流);tsc/lint/build 全綠。
Reviewer: 0 Critical / 0 Major / 3 Minor / 4 Suggestion,通過(ADR 決策 2 規格 7/7 符合)。

待 stage 實測: FAA 端須設 CORS(允許 visionA origin + preflight Allow-Headers: Authorization),
撞到會落 network_error。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:50:17 +08:00

173 lines
5.7 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.

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ModelDownloadError } from "@/lib/api/model-download";
import { isModelDownloadable, useModelStore, type ModelSummary } from "./model-store";
// mock 下載 API clientstore 的 downloadModel action 依賴它)
vi.mock("@/lib/api/model-download", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/model-download")>(
"@/lib/api/model-download",
);
return {
...actual,
getModelDownload: vi.fn(),
downloadModelFile: vi.fn(),
};
});
import { downloadModelFile, getModelDownload } from "@/lib/api/model-download";
const mockGetModelDownload = vi.mocked(getModelDownload);
const mockDownloadModelFile = vi.mocked(downloadModelFile);
function reset() {
useModelStore.setState({
models: [],
selectedModel: null,
isLoading: false,
downloadingId: null,
});
}
const convertedReady: ModelSummary = {
id: "m1",
name: "YOLOv5s",
targetChip: "kl520",
fileSize: 1024,
source: "converted",
status: "ready",
createdAt: "2026-01-01T00:00:00Z",
};
describe("model-storeF5 骨架)", () => {
beforeEach(reset);
it("初始為空 list", () => {
const s = useModelStore.getState();
expect(s.models).toEqual([]);
expect(s.selectedModel).toBeNull();
expect(s.isLoading).toBe(false);
});
it("_setModels 能塞入 list雛形 / 測試用)", () => {
useModelStore.getState()._setModels([
{
id: "m1",
name: "YOLOv5s",
targetChip: "kl520",
fileSize: 1024,
source: "uploaded",
status: "ready",
createdAt: "2026-01-01T00:00:00Z",
},
]);
expect(useModelStore.getState().models).toHaveLength(1);
expect(useModelStore.getState().models[0].name).toBe("YOLOv5s");
});
it("fetchModels 雛形實作不拋錯(為 F6 預留 stub", async () => {
await expect(useModelStore.getState().fetchModels()).resolves.toBeUndefined();
});
});
describe("isModelDownloadablePhase 0.9", () => {
it("converted + ready → 可下載", () => {
expect(isModelDownloadable({ source: "converted", status: "ready" })).toBe(true);
});
it("uploaded即使 ready→ 不可下載", () => {
expect(isModelDownloadable({ source: "uploaded", status: "ready" })).toBe(false);
});
it("converted 但非 readyscanning→ 不可下載", () => {
expect(isModelDownloadable({ source: "converted", status: "scanning" })).toBe(false);
});
it("preset → 不可下載", () => {
expect(isModelDownloadable({ source: "preset", status: "ready" })).toBe(false);
});
});
describe("downloadModel actionPhase 0.9", () => {
beforeEach(() => {
reset();
vi.clearAllMocks();
});
afterEach(() => vi.restoreAllMocks());
it("happy path取授權 → 下載 → 回 { ok: true },過程中 downloadingId = 該 model結束清空", async () => {
mockGetModelDownload.mockResolvedValue({
downloadUrl: "https://faa/files/u/m1.nef",
token: "fdt_tok",
expiresAt: "2026-06-07T12:00:00Z",
});
mockDownloadModelFile.mockResolvedValue(undefined);
const promise = useModelStore.getState().downloadModel(convertedReady);
// action 同步階段已設 downloadingId
expect(useModelStore.getState().downloadingId).toBe("m1");
const result = await promise;
expect(result).toEqual({ ok: true });
expect(mockGetModelDownload).toHaveBeenCalledWith("m1");
// 檔名從 URL path 推導m1.nef
expect(mockDownloadModelFile).toHaveBeenCalledWith(
"https://faa/files/u/m1.nef",
"fdt_tok",
"m1.nef",
);
// 結束後清空 loading
expect(useModelStore.getState().downloadingId).toBeNull();
});
it("不可下載的 modeluploaded→ 不打 API回 upload_not_supported", async () => {
const result = await useModelStore.getState().downloadModel({
...convertedReady,
source: "uploaded",
});
expect(result).toEqual({
ok: false,
code: "upload_not_supported",
message: expect.any(String),
});
expect(mockGetModelDownload).not.toHaveBeenCalled();
});
it("已有下載進行中 → 回 busy不重複打 API", async () => {
useModelStore.setState({ downloadingId: "other" });
const result = await useModelStore.getState().downloadModel(convertedReady);
expect(result).toMatchObject({ ok: false, code: "busy" });
expect(mockGetModelDownload).not.toHaveBeenCalled();
});
it("getModelDownload 拋 ModelDownloadError → 回對應 code並清空 downloadingId", async () => {
mockGetModelDownload.mockRejectedValue(
new ModelDownloadError(403, "forbidden", "no"),
);
const result = await useModelStore.getState().downloadModel(convertedReady);
expect(result).toMatchObject({ ok: false, code: "forbidden" });
expect(useModelStore.getState().downloadingId).toBeNull();
});
it("downloadModelFile 拋 network_errorCORS→ 回 network_error", async () => {
mockGetModelDownload.mockResolvedValue({
downloadUrl: "https://faa/files/u/m1.nef",
token: "fdt_tok",
expiresAt: "2026-06-07T12:00:00Z",
});
mockDownloadModelFile.mockRejectedValue(
new ModelDownloadError(0, "network_error", "Failed to fetch"),
);
const result = await useModelStore.getState().downloadModel(convertedReady);
expect(result).toMatchObject({ ok: false, code: "network_error" });
expect(useModelStore.getState().downloadingId).toBeNull();
});
it("非 ModelDownloadError 的例外 → 退化成 unknown", async () => {
mockGetModelDownload.mockRejectedValue(new Error("weird"));
const result = await useModelStore.getState().downloadModel(convertedReady);
expect(result).toMatchObject({ ok: false, code: "unknown" });
});
});