diff --git a/visionA-frontend/src/components/models/model-card.test.tsx b/visionA-frontend/src/components/models/model-card.test.tsx new file mode 100644 index 0000000..8a41215 --- /dev/null +++ b/visionA-frontend/src/components/models/model-card.test.tsx @@ -0,0 +1,163 @@ +/** + * ModelCard 互動測試(Phase 0.9 模型下載) + * + * 覆蓋: + * - 下載按鈕顯示條件:converted + ready 顯示;uploaded / 非 ready 不顯示 + * - 點下載按鈕:preventDefault + stopPropagation(不觸發外層 導航) + * - 下載中:按鈕 disabled + 顯示「下載中」 + * - 成功 → toast.success;失敗 → toast.error(用 backend code 對應 i18n) + * - 其他卡片下載中時,本卡按鈕 disabled + * + * Mock: + * - sonner toast → 攔截 success / error + * - model-store downloadModel action → vi.spyOn 控制回傳 + * - next/navigation(jsdom 無 app router context) + */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; + +import { LocaleProvider } from "@/lib/i18n/context"; +import { useModelStore, type ModelSummary } from "@/stores/model-store"; + +import { ModelCard } from "./model-card"; + +vi.mock("sonner", () => { + const success = vi.fn(); + const error = vi.fn(); + return { toast: Object.assign(vi.fn(), { success, error }) }; +}); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + prefetch: vi.fn(), + }), +})); + +import { toast } from "sonner"; + +const convertedReady: ModelSummary = { + id: "m1", + name: "YOLOv5s", + targetChip: "kl520", + fileSize: 1024, + source: "converted", + status: "ready", + createdAt: "2026-01-01T00:00:00Z", +}; + +function renderCard(model: ModelSummary) { + return render( + + + , + ); +} + +beforeEach(() => { + useModelStore.setState({ downloadingId: null }); + (toast.success as Mock).mockReset(); + (toast.error as Mock).mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + useModelStore.setState({ downloadingId: null }); +}); + +describe("ModelCard 下載按鈕顯示條件", () => { + it("converted + ready → 顯示下載按鈕", () => { + renderCard(convertedReady); + expect(screen.getByTestId("model-card-download")).toBeInTheDocument(); + }); + + it("uploaded(即使 ready)→ 不顯示下載按鈕", () => { + renderCard({ ...convertedReady, source: "uploaded" }); + expect(screen.queryByTestId("model-card-download")).not.toBeInTheDocument(); + }); + + it("converted 但 scanning → 不顯示下載按鈕", () => { + renderCard({ ...convertedReady, status: "scanning" }); + expect(screen.queryByTestId("model-card-download")).not.toBeInTheDocument(); + }); + + it("preset → 不顯示下載按鈕", () => { + renderCard({ ...convertedReady, source: "preset" }); + expect(screen.queryByTestId("model-card-download")).not.toBeInTheDocument(); + }); +}); + +describe("ModelCard 下載互動", () => { + it("點下載按鈕:preventDefault + stopPropagation(不觸發 Link 導航)", async () => { + const spy = vi + .spyOn(useModelStore.getState(), "downloadModel") + .mockResolvedValue({ ok: true }); + + renderCard(convertedReady); + const btn = screen.getByTestId("model-card-download"); + + // 自建 event 驗 preventDefault / stopPropagation 都被呼叫 + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + const preventDefault = vi.spyOn(event, "preventDefault"); + const stopPropagation = vi.spyOn(event, "stopPropagation"); + fireEvent(btn, event); + + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + await waitFor(() => expect(spy).toHaveBeenCalledWith(convertedReady)); + }); + + it("下載成功 → toast.success", async () => { + vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({ ok: true }); + renderCard(convertedReady); + fireEvent.click(screen.getByTestId("model-card-download")); + await waitFor(() => expect(toast.success).toHaveBeenCalledOnce()); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("下載失敗(forbidden)→ toast.error,描述用對應 i18n", async () => { + vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({ + ok: false, + code: "forbidden", + message: "no", + }); + renderCard(convertedReady); + fireEvent.click(screen.getByTestId("model-card-download")); + await waitFor(() => expect(toast.error).toHaveBeenCalledOnce()); + // 第二參數 description 對應 models.download.error.forbidden(繁中) + const call = (toast.error as Mock).mock.calls[0]; + expect(call[1].description).toBe("你沒有權限下載此模型"); + }); + + it("未知 code → toast.error 描述退化成 unknown 文案", async () => { + vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({ + ok: false, + code: "some_unmapped_code", + message: "x", + }); + renderCard(convertedReady); + fireEvent.click(screen.getByTestId("model-card-download")); + await waitFor(() => expect(toast.error).toHaveBeenCalledOnce()); + const call = (toast.error as Mock).mock.calls[0]; + expect(call[1].description).toBe("下載失敗,請稍後再試"); + }); + + it("其他卡片下載中(downloadingId != null)→ 本卡按鈕 disabled", () => { + useModelStore.setState({ downloadingId: "other-model" }); + renderCard(convertedReady); + expect(screen.getByTestId("model-card-download")).toBeDisabled(); + }); + + it("本卡下載中 → 顯示「下載中…」且 disabled", () => { + useModelStore.setState({ downloadingId: "m1" }); + renderCard(convertedReady); + const btn = screen.getByTestId("model-card-download"); + expect(btn).toBeDisabled(); + expect(btn).toHaveTextContent("下載中"); + }); +}); diff --git a/visionA-frontend/src/components/models/model-card.tsx b/visionA-frontend/src/components/models/model-card.tsx index 38ad08b..83a3f11 100644 --- a/visionA-frontend/src/components/models/model-card.tsx +++ b/visionA-frontend/src/components/models/model-card.tsx @@ -10,15 +10,27 @@ * 不再用 local-tool 的 accuracy / fps / supportedHardware(那些是內建 preset 的附加 metadata) * - 狀態 Badge 對齊 flow-model-upload §5.4(uploading / scanning / ready / rejected) * - 比較模式(Checkbox)保留,但預設關閉(雛形不做 comparison) + * - Phase 0.9:可下載的 model(converted + ready)顯示「下載」按鈕,走 FAA delegated download。 + * 整張卡片包在 裡 → 下載按鈕需 preventDefault + stopPropagation 避免觸發導航。 */ +import { DownloadIcon } from "lucide-react"; import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; import { useT } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; -import type { ModelStatus, ModelSummary } from "@/stores/model-store"; +import { + isModelDownloadable, + useModelStore, + type ModelStatus, + type ModelSummary, +} from "@/stores/model-store"; interface ModelCardProps { model: ModelSummary; @@ -43,6 +55,39 @@ export function ModelCard({ model }: ModelCardProps) { const t = useT(); const statusMeta = STATUS_VARIANT[model.status]; + const downloadModel = useModelStore((s) => s.downloadModel); + // per-card loading:只在「下載中的是這張卡」時顯示 spinner。 + const isDownloading = useModelStore((s) => s.downloadingId === model.id); + // 任一卡片下載中時,其他卡片的下載按鈕 disable(store 同時只允許一個下載)。 + const isAnyDownloading = useModelStore((s) => s.downloadingId !== null); + const [localBusy, setLocalBusy] = useState(false); + + const downloadable = isModelDownloadable(model); + + const handleDownload = async (e: React.MouseEvent) => { + // 整張卡片是 → 阻止冒泡到外層導航。 + e.preventDefault(); + e.stopPropagation(); + if (localBusy || isAnyDownloading) return; + + setLocalBusy(true); + const result = await downloadModel(model); + setLocalBusy(false); + + if (result.ok) { + toast.success(t("models.download.toast.start"), { + description: t("models.download.toast.hint"), + }); + } else { + // 用 backend code 對應 i18n;找不到對應 key 時 t() 回 key 本身,仍退化到 unknown。 + const key = `models.download.error.${result.code}`; + const desc = t(key); + toast.error(t("models.download.error.title"), { + description: desc === key ? t("models.download.error.unknown") : desc, + }); + } + }; + return ( + {downloadable && ( +
+ +
+ )}
diff --git a/visionA-frontend/src/lib/api/model-download.test.ts b/visionA-frontend/src/lib/api/model-download.test.ts new file mode 100644 index 0000000..6102208 --- /dev/null +++ b/visionA-frontend/src/lib/api/model-download.test.ts @@ -0,0 +1,292 @@ +/** + * Model Download API Client 單元測試(Phase 0.9) + * + * 覆蓋: + * 1. getModelDownload — 200 正規化 / 404 / 403 / 501 / 502 / parse 缺欄 + * 2. downloadModelFile — happy(fetch + Bearer → blob → anchor click)/ FAA 非 2xx / CORS network_error / abort + * 3. triggerBlobDownload — anchor 屬性 + revokeObjectURL + * 4. deriveDownloadFilename — 從 URL path 取檔名 / fallback + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + deriveDownloadFilename, + downloadModelFile, + getModelDownload, + ModelDownloadError, + triggerBlobDownload, +} from "./model-download"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/* ========================================================================== */ +/* 1. getModelDownload */ +/* ========================================================================== */ + +describe("getModelDownload", () => { + it("200 → 正規化 { downloadUrl, token, expiresAt }(snake_case)", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + success: true, + data: { + download_url: "https://faa.example.com:5081/files/models/u1/j1.nef", + token: "fdt_abc123", + expires_at: "2026-06-07T12:02:00Z", + }, + }), + ); + + const grant = await getModelDownload("m1"); + expect(grant.downloadUrl).toBe( + "https://faa.example.com:5081/files/models/u1/j1.nef", + ); + expect(grant.token).toBe("fdt_abc123"); + expect(grant.expiresAt).toBe("2026-06-07T12:02:00Z"); + }); + + it("camelCase 也吃(downloadUrl / expiresAt)", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + success: true, + data: { + downloadUrl: "https://faa/x.nef", + token: "fdt_x", + expiresAt: "2026-06-07T00:00:00Z", + }, + }), + ); + const grant = await getModelDownload("m1"); + expect(grant.downloadUrl).toBe("https://faa/x.nef"); + expect(grant.token).toBe("fdt_x"); + }); + + it("空 modelId → validation_failed(不打 API)", async () => { + await expect(getModelDownload("")).rejects.toMatchObject({ + name: "ModelDownloadError", + code: "validation_failed", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + [404, "model_not_found"], + [403, "forbidden"], + [501, "upload_not_supported"], + [502, "sign_failed"], + ])("backend %i → ModelDownloadError code=%s(小寫)", async (status, code) => { + fetchMock.mockResolvedValue( + jsonResponse({ success: false, error: { code, message: "x" } }, status), + ); + await expect(getModelDownload("m1")).rejects.toMatchObject({ + name: "ModelDownloadError", + status, + code, + }); + }); + + it("200 但缺 token → parse_error", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + success: true, + data: { download_url: "https://faa/x.nef" }, + }), + ); + await expect(getModelDownload("m1")).rejects.toMatchObject({ + code: "parse_error", + }); + }); +}); + +/* ========================================================================== */ +/* 2. downloadModelFile */ +/* ========================================================================== */ + +describe("downloadModelFile", () => { + let createObjectURL: ReturnType; + let revokeObjectURL: ReturnType; + let clickSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + createObjectURL = vi.fn(() => "blob:mock-url"); + revokeObjectURL = vi.fn(); + globalThis.URL.createObjectURL = createObjectURL as unknown as typeof URL.createObjectURL; + globalThis.URL.revokeObjectURL = revokeObjectURL as unknown as typeof URL.revokeObjectURL; + // anchor.click 在 jsdom 預設不觸發 navigation,但我們仍想斷言它被呼叫 + clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("happy path:帶 Authorization Bearer → blob → anchor click + 延遲 revoke", async () => { + // 直接給帶 .blob() 的物件,避免 fake timers 下 jsdom Response/Blob stream 內部報錯 + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + blob: () => Promise.resolve(new Blob(["nef-bytes"])), + } as unknown as Response); + + await downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef"); + + // 驗 fetch 帶對的 header + credentials omit + expect(fetchMock).toHaveBeenCalledWith( + "https://faa/x.nef", + expect.objectContaining({ + method: "GET", + credentials: "omit", + headers: { Authorization: "Bearer fdt_tok" }, + }), + ); + expect(createObjectURL).toHaveBeenCalledOnce(); + expect(clickSpy).toHaveBeenCalledOnce(); + // revoke 延遲觸發 + expect(revokeObjectURL).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1000); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:mock-url"); + }); + + it("FAA 非 2xx → download_failed(帶 status)", async () => { + fetchMock.mockResolvedValue(new Response("nope", { status: 404 })); + await expect( + downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef"), + ).rejects.toMatchObject({ code: "download_failed", status: 404 }); + }); + + it("fetch throw(CORS / 連不上)→ network_error", async () => { + fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); + await expect( + downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef"), + ).rejects.toMatchObject({ code: "network_error" }); + }); + + it("AbortError → aborted", async () => { + const abortErr = new Error("The operation was aborted"); + abortErr.name = "AbortError"; + fetchMock.mockRejectedValue(abortErr); + await expect( + downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef"), + ).rejects.toMatchObject({ code: "aborted" }); + }); + + it("空 token → validation_failed(不打 fetch)", async () => { + await expect( + downloadModelFile("https://faa/x.nef", "", "x.nef"), + ).rejects.toMatchObject({ code: "validation_failed" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +/* ========================================================================== */ +/* 3. triggerBlobDownload */ +/* ========================================================================== */ + +describe("triggerBlobDownload", () => { + beforeEach(() => { + vi.useFakeTimers(); + globalThis.URL.createObjectURL = vi.fn( + () => "blob:trigger-url", + ) as unknown as typeof URL.createObjectURL; + globalThis.URL.revokeObjectURL = vi.fn() as unknown as typeof URL.revokeObjectURL; + }); + afterEach(() => vi.useRealTimers()); + + it("建立 anchor、設 download 屬性、click 後從 DOM 移除", () => { + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); + let capturedDownload = ""; + let capturedHref = ""; + const appendSpy = vi + .spyOn(document.body, "appendChild") + .mockImplementation((node) => { + const a = node as HTMLAnchorElement; + capturedDownload = a.download; + capturedHref = a.href; + return node; + }); + const removeSpy = vi + .spyOn(document.body, "removeChild") + .mockImplementation((node) => node); + + triggerBlobDownload(new Blob(["x"]), "my-model.nef"); + + expect(capturedDownload).toBe("my-model.nef"); + expect(capturedHref).toContain("blob:trigger-url"); + expect(clickSpy).toHaveBeenCalledOnce(); + expect(appendSpy).toHaveBeenCalledOnce(); + expect(removeSpy).toHaveBeenCalledOnce(); + }); +}); + +/* ========================================================================== */ +/* 4. deriveDownloadFilename */ +/* ========================================================================== */ + +describe("deriveDownloadFilename", () => { + it("從 URL path 取最後一段含副檔名", () => { + expect( + deriveDownloadFilename( + "https://faa.example.com:5081/files/models/u1/j1.nef", + "YOLOv5s", + ), + ).toBe("j1.nef"); + }); + + it("URL 帶 query string 也只取 pathname 最後段", () => { + expect( + deriveDownloadFilename("https://faa/files/u/abc.nef?token=x", "name"), + ).toBe("abc.nef"); + }); + + it("path 無副檔名 → fallback 用 modelName.nef", () => { + expect(deriveDownloadFilename("https://faa/files/u/blob", "My Model")).toBe( + "My_Model.nef", + ); + }); + + it("URL 無法解析 → fallback;非法字元被取代", () => { + expect(deriveDownloadFilename("not a url", "a/b c")).toBe("a_b_c.nef"); + }); + + it("modelName 已含 .nef 不重複加", () => { + expect(deriveDownloadFilename("bad", "model.nef")).toBe("model.nef"); + }); +}); + +/* ========================================================================== */ +/* ModelDownloadError 形狀 */ +/* ========================================================================== */ + +describe("ModelDownloadError", () => { + it("保留 status / code / message / requestId", () => { + const e = new ModelDownloadError(403, "forbidden", "no", "req-1"); + expect(e.name).toBe("ModelDownloadError"); + expect(e.status).toBe(403); + expect(e.code).toBe("forbidden"); + expect(e.message).toBe("no"); + expect(e.requestId).toBe("req-1"); + expect(e).toBeInstanceOf(Error); + }); +}); diff --git a/visionA-frontend/src/lib/api/model-download.ts b/visionA-frontend/src/lib/api/model-download.ts new file mode 100644 index 0000000..4882bf8 --- /dev/null +++ b/visionA-frontend/src/lib/api/model-download.ts @@ -0,0 +1,233 @@ +/** + * Model Download API Client(Phase 0.9 — 模型庫「FAA delegated download」對接) + * + * 對齊: + * - `docs/autoflow/04-architecture/adr/adr-017-model-library-access.md` §10.4 + 決策 2 + * - backend endpoint `GET /api/models/:id/download`(commit c63886a,stage e2e 驗過) + * + * ⚠️ 為什麼這支「下載」跟既有轉檔下載(conversion.ts `getConversionDownloadURL`)完全不同: + * - 轉檔下載:同 origin、走 visionA backend、用 `` browser navigation(302 redirect)。 + * - 模型庫下載:**跨 origin 直連 FAA(如 stage-9527:5081)+ 必須帶 `Authorization: Bearer {token}`**。 + * → browser navigation(`` / `window.location.href`)**無法帶 Authorization header**, + * 所以一定要用 `fetch(url, { headers: { Authorization } }) → blob → 動態 觸發下載`。 + * + * 兩步驟流程(ADR-017 決策 2 v1.2 實測流程): + * 1. `getModelDownload(modelId)`:打 visionA `GET /api/models/:id/download`, + * 回 `{ downloadUrl, token, expiresAt }`(visionA 已向 MC 簽好 opaque `fdt_` token)。 + * 2. `downloadModelFile(downloadUrl, token, filename)`:帶 `Authorization: Bearer {token}` + * 直接 GET `downloadUrl`(FAA),取 blob,建立 object URL + 動態 anchor click 觸發瀏覽器下載。 + * + * 錯誤分層: + * - client 不翻譯成中文 — i18n 留在 store / UI(用 `error.code` 對應 `models.download.error.`)。 + * - backend 回的 code(model_not_found / forbidden / upload_not_supported / sign_failed …)原樣透出。 + */ + +import { ApiError, api } from "@/lib/api"; + +/* -------------------------------------------------------------------------- */ +/* Types */ +/* -------------------------------------------------------------------------- */ + +/** `GET /api/models/:id/download` 正規化後的回傳。 */ +export interface ModelDownloadGrant { + /** FAA 直連下載 URL(跨 origin,如 https://stage-9527...:5081/files/models/{userID}/{jobID}.nef) */ + downloadUrl: string; + /** MC 簽的 opaque delegated download token(`fdt_...`);只用於下一步的 Authorization header */ + token: string; + /** ISO 8601 — token 過期時間 */ + expiresAt: string; +} + +/* -------------------------------------------------------------------------- */ +/* Error class */ +/* -------------------------------------------------------------------------- */ + +/** + * 模型下載專用錯誤。store / UI 用 `error.code` 對應 i18n key(`models.download.error.`)。 + * + * code 統一全小寫(對齊 conversion.ts `ConversionAPIError` 的命名規範,避免 UI 端做大小寫處理)。 + * 常見 code: + * - `model_not_found`(404)/ `forbidden`(403)/ `upload_not_supported`(501,第一階段不支援上傳類) + * - `sign_failed`(502,MC 簽 token 失敗) + * - `download_failed`(FAA GET 非 2xx;可能含 CORS 被擋 → network_error) + * - `network_error` / `timeout` / `aborted` / `parse_error`(client 端網路層) + */ +export class ModelDownloadError extends Error { + readonly status: number; + readonly code: string; + readonly requestId?: string; + + constructor(status: number, code: string, message: string, requestId?: string) { + super(message); + this.name = "ModelDownloadError"; + this.status = status; + this.code = code; + this.requestId = requestId; + if (typeof Error.captureStackTrace === "function") { + Error.captureStackTrace(this, ModelDownloadError); + } + } +} + +/** 把底層 `ApiError` / 一般 Error 包成 `ModelDownloadError`(code 統一小寫)。 */ +function wrapError(err: unknown): ModelDownloadError { + if (err instanceof ModelDownloadError) return err; + if (err instanceof ApiError) { + return new ModelDownloadError(err.status, err.code.toLowerCase(), err.message); + } + if (err instanceof Error) { + const maybeCode = (err as unknown as { code?: unknown }).code; + const code = + typeof maybeCode === "string" ? maybeCode.toLowerCase() : "network_error"; + return new ModelDownloadError(0, code, err.message); + } + return new ModelDownloadError(0, "unknown", String(err)); +} + +/* -------------------------------------------------------------------------- */ +/* 1. GET /api/models/:id/download — 取 FAA 下載授權 */ +/* -------------------------------------------------------------------------- */ + +/** + * 向 visionA backend 取得模型的 FAA delegated download 授權。 + * + * 走既有 `api.get` wrapper(自動帶 cookie session、解 envelope、ApiError mapping)。 + * 寬容讀取 snake_case / camelCase(download_url / downloadUrl 等)。 + * + * @throws {ModelDownloadError} 404 model_not_found / 403 forbidden / + * 501 upload_not_supported / 502 sign_failed / 其他網路層錯誤 + */ +export async function getModelDownload(modelId: string): Promise { + if (!modelId) { + throw new ModelDownloadError(0, "validation_failed", "modelId is required"); + } + try { + const raw = await api.get>( + `/api/models/${encodeURIComponent(modelId)}/download`, + ); + const r = raw ?? {}; + const downloadUrl = String(r.download_url ?? r.downloadUrl ?? ""); + const token = String(r.token ?? ""); + const expiresAt = String(r.expires_at ?? r.expiresAt ?? ""); + if (!downloadUrl || !token) { + throw new ModelDownloadError( + 500, + "parse_error", + "download: missing download_url or token in response", + ); + } + return { downloadUrl, token, expiresAt }; + } catch (err) { + throw wrapError(err); + } +} + +/* -------------------------------------------------------------------------- */ +/* 2. fetch FAA + Bearer header + blob → 觸發瀏覽器下載 */ +/* -------------------------------------------------------------------------- */ + +/** + * 帶 `Authorization: Bearer {token}` 跨 origin 直連 FAA 下載檔案,並觸發瀏覽器存檔。 + * + * 為什麼不能用 ``:browser navigation 無法帶自訂 header(Authorization), + * 而 FAA `TryReadAccessToken` 只認 `Authorization: Bearer`(不認 query / 自訂 header)。 + * → 必須 fetch → response.blob() → URL.createObjectURL → 動態 anchor click → revokeObjectURL。 + * + * CORS 註記:FAA 端需允許 visionA 前端 origin(ADR-017 決策 2 Q3)。若 FAA 未設 CORS, + * 跨 origin fetch 會 throw `TypeError: Failed to fetch` → 落到 `network_error`(UI 顯示下載失敗)。 + * 這是 FAA 端設定問題,**前端 code 不為 CORS 改**。 + * + * @param downloadUrl FAA 絕對 URL(來自 getModelDownload) + * @param token opaque `fdt_` token(只用於 Authorization header,不寫進 URL / log) + * @param filename 存檔檔名(如 `{jobID}.nef`) + * @throws {ModelDownloadError} download_failed(FAA 非 2xx)/ network_error / aborted + */ +export async function downloadModelFile( + downloadUrl: string, + token: string, + filename: string, + signal?: AbortSignal, +): Promise { + if (!downloadUrl || !token) { + throw new ModelDownloadError(0, "validation_failed", "downloadUrl and token are required"); + } + + let res: Response; + try { + res = await fetch(downloadUrl, { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + // 跨 origin 直連 FAA:不帶 visionA cookie(FAA 用 delegated token 認證,cookie 無意義) + credentials: "omit", + signal, + }); + } catch (err) { + if (err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message))) { + throw new ModelDownloadError(0, "aborted", "Download aborted"); + } + // TypeError: Failed to fetch — 通常是 CORS 被擋 / 連不上 FAA + throw new ModelDownloadError( + 0, + "network_error", + `Failed to reach FAA: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (!res.ok) { + throw new ModelDownloadError( + res.status, + "download_failed", + `FAA download failed: HTTP ${res.status}`, + res.headers.get("X-Request-Id") ?? undefined, + ); + } + + const blob = await res.blob(); + triggerBlobDownload(blob, filename); +} + +/** + * 建立 object URL + 動態 anchor click 觸發瀏覽器存檔,完成後 revoke 避免記憶體洩漏。 + * + * 抽成獨立函式:便於測試(驗 anchor 屬性 / revoke 被呼叫)、也讓上面 fetch 流程更乾淨。 + */ +export function triggerBlobDownload(blob: Blob, filename: string): void { + const objectUrl = URL.createObjectURL(blob); + try { + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = filename; + anchor.rel = "noopener"; + // 不掛進 DOM 也能 click(現代瀏覽器支援),但部分 Firefox 版本需 append;保險起見 append + remove + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + } finally { + // 立即 revoke 可能在某些瀏覽器中斷下載,延遲一拍再 revoke + setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + } +} + +/* -------------------------------------------------------------------------- */ +/* Helper */ +/* -------------------------------------------------------------------------- */ + +/** + * 從 FAA download URL 推導存檔檔名(`.../models/{userID}/{jobID}.nef` → `{jobID}.nef`)。 + * + * 回退:URL 解析失敗 / 無副檔名時用 `{modelName}.nef`(modelName 已去除非法字元)。 + */ +export function deriveDownloadFilename(downloadUrl: string, modelName: string): string { + try { + // downloadUrl 可能含 query string;取 pathname 最後一段 + const path = new URL(downloadUrl).pathname; + const last = path.split("/").filter(Boolean).pop() ?? ""; + if (last && /\.[a-z0-9]+$/i.test(last)) { + return decodeURIComponent(last); + } + } catch { + // URL 解析失敗 → 走 fallback + } + const safe = (modelName || "model").replace(/[^\w.\-]+/g, "_"); + return safe.endsWith(".nef") ? safe : `${safe}.nef`; +} diff --git a/visionA-frontend/src/lib/i18n/dictionaries/en.ts b/visionA-frontend/src/lib/i18n/dictionaries/en.ts index ae1720a..082bc6e 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/en.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/en.ts @@ -221,6 +221,29 @@ export const en: Dictionary = { "models.upload.error.urlFailed": "Server is busy, please try again.", "models.upload.error.networkLost": "Network lost, upload paused.", "models.upload.toast.uploaded": "Model \"{name}\" uploaded.", + // ── Model download (Phase 0.9 FAA delegated download) ── + "models.action.download": "Download", + "models.action.downloading": "Downloading…", + "models.action.download.aria": "Download the model file to your device", + "models.action.download.unsupportedTooltip": + "Only converted models are downloadable in this phase", + "models.download.toast.start": "Download started", + "models.download.toast.hint": + "If you don't see a download prompt, check your browser settings", + "models.download.error.title": "Download failed", + "models.download.error.model_not_found": "Model not found.", + "models.download.error.forbidden": "You don't have permission to download this model.", + "models.download.error.upload_not_supported": + "Uploaded models are not downloadable in this phase.", + "models.download.error.sign_failed": + "Failed to obtain a download grant, please try again later.", + "models.download.error.download_failed": + "Failed to download from the file server, please try again later.", + "models.download.error.network_error": + "Network error, please check your connection and retry.", + "models.download.error.timeout": "Download timed out, please try again later.", + "models.download.error.busy": "A download is already in progress, please wait.", + "models.download.error.unknown": "Download failed, please try again later.", // ── Workspace ── "workspace.title": "Workspace", diff --git a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts index 9c42e31..c363ef1 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts @@ -219,6 +219,24 @@ export const zhHant: Dictionary = { "models.upload.error.urlFailed": "伺服器忙碌,請稍後再試", "models.upload.error.networkLost": "網路中斷,上傳已暫停", "models.upload.toast.uploaded": "模型「{name}」已上傳", + // ── 模型下載(Phase 0.9 FAA delegated download)── + "models.action.download": "下載", + "models.action.downloading": "下載中…", + "models.action.download.aria": "下載模型檔到本機", + "models.action.download.unsupportedTooltip": "第一階段僅支援轉檔產生的模型下載", + "models.download.toast.start": "下載已開始", + "models.download.toast.hint": "若沒看到下載提示,請檢查瀏覽器設定", + "models.download.error.title": "下載失敗", + // 各 error code(對齊 backend:model_not_found / forbidden / upload_not_supported / sign_failed) + "models.download.error.model_not_found": "找不到此模型", + "models.download.error.forbidden": "你沒有權限下載此模型", + "models.download.error.upload_not_supported": "第一階段尚不支援上傳類模型下載", + "models.download.error.sign_failed": "取得下載授權失敗,請稍後再試", + "models.download.error.download_failed": "從檔案伺服器下載失敗,請稍後再試", + "models.download.error.network_error": "網路連線失敗,請檢查網路後重試", + "models.download.error.timeout": "下載逾時,請稍後再試", + "models.download.error.busy": "已有下載進行中,請稍候", + "models.download.error.unknown": "下載失敗,請稍後再試", // ── Workspace ── "workspace.title": "推論工作區", diff --git a/visionA-frontend/src/stores/model-store.test.ts b/visionA-frontend/src/stores/model-store.test.ts index 60df337..f27969a 100644 --- a/visionA-frontend/src/stores/model-store.test.ts +++ b/visionA-frontend/src/stores/model-store.test.ts @@ -1,11 +1,45 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useModelStore } from "./model-store"; +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( + "@/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 }); + 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); @@ -36,3 +70,103 @@ describe("model-store(F5 骨架)", () => { 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" }); + }); +}); diff --git a/visionA-frontend/src/stores/model-store.ts b/visionA-frontend/src/stores/model-store.ts index 62236ca..1d37dce 100644 --- a/visionA-frontend/src/stores/model-store.ts +++ b/visionA-frontend/src/stores/model-store.ts @@ -19,6 +19,12 @@ import { create } from "zustand"; import { ApiError, api } from "@/lib/api"; +import { + deriveDownloadFilename, + downloadModelFile, + getModelDownload, + ModelDownloadError, +} from "@/lib/api/model-download"; /* -------------------------------------------------------------------------- */ /* Types — 對齊 api-spec.md §4 */ @@ -116,12 +122,31 @@ function normalizeInitResult(raw: unknown): UploadInitResult { /* Store */ /* -------------------------------------------------------------------------- */ +/** download action 的回傳:成功 / 失敗(帶 i18n code 給 UI 顯示 toast / inline 錯誤)。 */ +export type DownloadResult = + | { ok: true } + | { ok: false; code: string; message: string }; + +/** + * 判斷一個 model 是否可下載(Phase 0.9 第一階段:只支援轉檔 promote 的 model)。 + * + * 為什麼只有 `converted`:第一階段 FAA delegated download 只覆蓋「轉檔→promote」類 model + * (ADR-017 §10.4 B1 object_key 斷層)。上傳類 model 在 visionA 自己的 storage、沒有 FAA object_key, + * backend 會回 501 upload_not_supported。UI 依此條件隱藏下載按鈕、避免使用者點了才吃 501。 + */ +export function isModelDownloadable(model: Pick): boolean { + return model.source === "converted" && model.status === "ready"; +} + interface ModelState { models: ModelSummary[]; selectedModel: Model | null; isLoading: boolean; error: string | null; + /** 正在下載中的 model id(同時間只允許一個下載;null = 無)。供 UI 顯示 per-card loading。 */ + downloadingId: string | null; + /** 呼叫 `GET /api/models` */ fetchModels: () => Promise; /** 呼叫 `GET /api/models/:id` */ @@ -138,6 +163,11 @@ interface ModelState { finalizeUpload: (modelId: string, etag: string | null) => Promise; /** 呼叫 `DELETE /api/models/:id` */ deleteModel: (id: string) => Promise; + /** + * 下載模型檔(兩步:`GET /api/models/:id/download` 取 FAA 授權 → 帶 Bearer token 直連 FAA 取 blob)。 + * 回 `DownloadResult`,由 UI 決定顯示 toast / inline 錯誤(用 `code` 對應 i18n)。 + */ + downloadModel: (model: ModelSummary) => Promise; /** 測試 / 雛形用 */ _setModels: (models: ModelSummary[]) => void; @@ -149,6 +179,7 @@ export const useModelStore = create()((set, get) => ({ selectedModel: null, isLoading: false, error: null, + downloadingId: null, fetchModels: async () => { set({ isLoading: true, error: null }); @@ -218,6 +249,39 @@ export const useModelStore = create()((set, get) => ({ } }, + downloadModel: async (model) => { + // 防呆:不可下載的 model(非 converted / 非 ready)直接回對應錯誤、不打 API。 + if (!isModelDownloadable(model)) { + return { + ok: false, + code: "upload_not_supported", + message: "model is not downloadable", + }; + } + // 同時間只允許一個下載中(避免重複點擊 / 多檔同時拉爆頻寬)。 + if (get().downloadingId) { + return { ok: false, code: "busy", message: "another download is in progress" }; + } + + set({ downloadingId: model.id }); + try { + const grant = await getModelDownload(model.id); + const filename = deriveDownloadFilename(grant.downloadUrl, model.name); + await downloadModelFile(grant.downloadUrl, grant.token, filename); + return { ok: true }; + } catch (err) { + // ModelDownloadError 帶 code(i18n 用);其他 Error 退化成 unknown。 + if (err instanceof ModelDownloadError) { + return { ok: false, code: err.code, message: err.message }; + } + const message = err instanceof Error ? err.message : String(err); + return { ok: false, code: "unknown", message }; + } finally { + // 無論成功 / 失敗都要清掉 loading 狀態(避免按鈕卡在 disabled)。 + set({ downloadingId: null }); + } + }, + _setModels: (models) => set({ models }), _setSelected: (selectedModel) => set({ selectedModel }), }));