# B8 預設模型(簡單版:寫死常數 + 打包 + 不簽 token) 模型庫「預設模型」原本實際是空的(舊 seed 是 demo 假資料、production 不啟用)。 補 7 個公用預設模型(kl520×4 + kl720×3,來源 local-tool models.json): - metadata 寫死成 Go 常數(presets.go),不進 DB → 公用、不可刪、無 seed 重複 - 7 個 .nef 打包進 image(assets/preset-models/,~61MB;Dockerfile COPY) - download 走 visionA 自己(簡單版不簽 token,preset 公用本不需授權): download handler 三分支 preset→visionA URL / converted→FAA / uploaded→501 - 新 GET /preset-models/*filepath 靜態 serve(無 auth、Content-Disposition attachment 用 mime.FormatMediaType、path-traversal 防禦) - list/get 含 preset(對所有人可見) # B5 詳細頁下載按鈕 + preset 可下載 - model-detail-client.tsx 補下載按鈕(行為對齊列表卡片) - isModelDownloadable 加 preset(source==="preset" → true) - normalizeModelSummary 對 target_chip toLowerCase(修 preset 大寫 KL520 在小寫晶片篩選下被隱藏的 bug;收斂所有來源大小寫) # 測試 backend:presets/preset 下載三分支/靜態 serve/path-traversal/條件對稱 全綠 frontend:詳細頁下載鈕 + preset 可下載 + target_chip 大小寫篩選回歸 38 PASS Reviewer 兩輪通過(Major-1 + Minor-1/2 修畢、複審 0 問題) # backlog(未做的優化) preset 下載改 HMAC presigned / preset .nef 改放 FAA 減 image / metadata 後台可管理 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 → 顯示下載按鈕(backend B8 內建模型可下載)", () => {
|
||
renderCard({ ...convertedReady, source: "preset" });
|
||
expect(screen.getByTestId("model-card-download")).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("下載中");
|
||
});
|
||
});
|