jim800121chen 9ab1a11ea4 feat(models): 補 7 個預設模型(B8)+ 詳細頁下載按鈕(B5)
# 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>
2026-06-28 07:38:25 +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 → 顯示下載按鈕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("下載中");
});
});