對齊 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>
164 lines
5.6 KiB
TypeScript
164 lines
5.6 KiB
TypeScript
/**
|
||
* 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/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(
|
||
<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("下載中");
|
||
});
|
||
});
|