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

164 lines
5.6 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.

/**
* ModelCard 互動測試Phase 0.9 模型下載)
*
* 覆蓋:
* - 下載按鈕顯示條件converted + ready 顯示uploaded / 非 ready 不顯示
* - 點下載按鈕preventDefault + stopPropagation不觸發外層 <Link> 導航)
* - 下載中:按鈕 disabled + 顯示「下載中」
* - 成功 → toast.success失敗 → toast.error用 backend code 對應 i18n
* - 其他卡片下載中時,本卡按鈕 disabled
*
* Mock
* - sonner toast → 攔截 success / error
* - model-store downloadModel action → vi.spyOn 控制回傳
* - next/navigationjsdom 無 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(
<LocaleProvider>
<ModelCard model={model} />
</LocaleProvider>,
);
}
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("下載中");
});
});