/** * 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("下載中"); }); });