對齊 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>
173 lines
5.7 KiB
TypeScript
173 lines
5.7 KiB
TypeScript
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 client(store 的 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-store(F5 骨架)", () => {
|
||
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("isModelDownloadable(Phase 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 但非 ready(scanning)→ 不可下載", () => {
|
||
expect(isModelDownloadable({ source: "converted", status: "scanning" })).toBe(false);
|
||
});
|
||
|
||
it("preset → 不可下載", () => {
|
||
expect(isModelDownloadable({ source: "preset", status: "ready" })).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("downloadModel action(Phase 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("不可下載的 model(uploaded)→ 不打 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_error(CORS)→ 回 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" });
|
||
});
|
||
});
|