Compare commits
No commits in common. "7d8ad4857eb98dd1477ae5538a2e827ca4b0e58f" and "838d10b0840367fd98ed97415d6bc6052012e08b" have entirely different histories.
7d8ad4857e
...
838d10b084
@ -1,172 +0,0 @@
|
||||
/**
|
||||
* DeviceDetailClient 移除裝置(unpair)測試
|
||||
*
|
||||
* 覆蓋(對齊 model-detail-client.test.tsx 的覆蓋風格):
|
||||
* - 移除按鈕顯示(裝置載入後)
|
||||
* - 點移除 → 開確認對話框(二次確認,破壞性操作不可一鍵直接移除)
|
||||
* - 確認 → 觸發 store.unpairDevice(id)
|
||||
* - 成功 → toast.success + router.push("/devices")
|
||||
* - 失敗(FORBIDDEN)→ toast.error,描述用對應 i18n
|
||||
* - 失敗(未對應 code)→ toast.error 描述退化成 unknown 文案
|
||||
* - loading 態:unpairingId === id → 觸發鈕 disabled + 顯示「移除中」
|
||||
*
|
||||
* Mock:
|
||||
* - sonner toast → 攔截 success / error
|
||||
* - next/navigation useRouter → 攔截 push
|
||||
* - device-store fetchDevice / unpairDevice → vi.spyOn 控制;selectedDevice 用 setState 注入
|
||||
*/
|
||||
|
||||
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 { useDeviceStore, type Device } from "@/stores/device-store";
|
||||
|
||||
import { DeviceDetailClient } from "./device-detail-client";
|
||||
|
||||
vi.mock("sonner", () => {
|
||||
const success = vi.fn();
|
||||
const error = vi.fn();
|
||||
return { toast: Object.assign(vi.fn(), { success, error }) };
|
||||
});
|
||||
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
const onlineDevice: Device = {
|
||||
id: "dev-1",
|
||||
name: "KL520 Dev Kit",
|
||||
type: "kl520",
|
||||
status: "connected",
|
||||
remoteStatus: "online",
|
||||
lastSeenAt: "2026-04-21T00:00:00Z",
|
||||
firmwareVersion: "2.3.1",
|
||||
};
|
||||
|
||||
function renderDetail(id = "dev-1") {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<DeviceDetailClient id={id} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// fetchDevice 在 useEffect 會被呼叫 — stub 成 no-op,避免打網路;selectedDevice 自行注入。
|
||||
vi.spyOn(useDeviceStore.getState(), "fetchDevice").mockResolvedValue(undefined);
|
||||
useDeviceStore.setState({
|
||||
devices: [],
|
||||
selectedDevice: onlineDevice,
|
||||
isLoading: false,
|
||||
connectingId: null,
|
||||
disconnectingId: null,
|
||||
unpairingId: null,
|
||||
error: null,
|
||||
});
|
||||
pushMock.mockReset();
|
||||
(toast.success as Mock).mockReset();
|
||||
(toast.error as Mock).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useDeviceStore.setState({ selectedDevice: null, unpairingId: null });
|
||||
});
|
||||
|
||||
describe("DeviceDetailClient 移除裝置按鈕", () => {
|
||||
it("裝置載入後顯示移除按鈕", () => {
|
||||
renderDetail();
|
||||
expect(screen.getByTestId("device-remove-trigger")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("點移除按鈕 → 開二次確認對話框(不直接觸發 unpair)", async () => {
|
||||
const spy = vi.spyOn(useDeviceStore.getState(), "unpairDevice");
|
||||
renderDetail();
|
||||
|
||||
fireEvent.click(screen.getByTestId("device-remove-trigger"));
|
||||
|
||||
// 確認框出現(含確認鈕),但尚未呼叫 unpair
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("device-remove-confirm")).toBeInTheDocument(),
|
||||
);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("確認後 → 觸發 unpairDevice(id)", async () => {
|
||||
const spy = vi
|
||||
.spyOn(useDeviceStore.getState(), "unpairDevice")
|
||||
.mockResolvedValue({ ok: true });
|
||||
renderDetail();
|
||||
|
||||
fireEvent.click(screen.getByTestId("device-remove-trigger"));
|
||||
fireEvent.click(await screen.findByTestId("device-remove-confirm"));
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith("dev-1"));
|
||||
});
|
||||
|
||||
it("移除成功 → toast.success + 導回 /devices", async () => {
|
||||
vi.spyOn(useDeviceStore.getState(), "unpairDevice").mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
renderDetail();
|
||||
|
||||
fireEvent.click(screen.getByTestId("device-remove-trigger"));
|
||||
fireEvent.click(await screen.findByTestId("device-remove-confirm"));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledOnce());
|
||||
expect(pushMock).toHaveBeenCalledWith("/devices");
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("移除失敗(FORBIDDEN)→ toast.error,描述用對應 i18n,不導頁", async () => {
|
||||
vi.spyOn(useDeviceStore.getState(), "unpairDevice").mockResolvedValue({
|
||||
ok: false,
|
||||
code: "FORBIDDEN",
|
||||
message: "not owner",
|
||||
});
|
||||
renderDetail();
|
||||
|
||||
fireEvent.click(screen.getByTestId("device-remove-trigger"));
|
||||
fireEvent.click(await screen.findByTestId("device-remove-confirm"));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledOnce());
|
||||
const call = (toast.error as Mock).mock.calls[0];
|
||||
expect(call[1].description).toBe("你沒有權限移除此裝置");
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("移除失敗(未對應 code)→ toast.error 描述退化成 unknown 文案", async () => {
|
||||
vi.spyOn(useDeviceStore.getState(), "unpairDevice").mockResolvedValue({
|
||||
ok: false,
|
||||
code: "SOME_UNMAPPED_CODE",
|
||||
message: "x",
|
||||
});
|
||||
renderDetail();
|
||||
|
||||
fireEvent.click(screen.getByTestId("device-remove-trigger"));
|
||||
fireEvent.click(await screen.findByTestId("device-remove-confirm"));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledOnce());
|
||||
const call = (toast.error as Mock).mock.calls[0];
|
||||
expect(call[1].description).toBe("發生錯誤,請稍後再試");
|
||||
});
|
||||
|
||||
it("移除中(unpairingId === id)→ 觸發按鈕 disabled 且顯示「移除中」", () => {
|
||||
useDeviceStore.setState({ unpairingId: "dev-1", selectedDevice: onlineDevice });
|
||||
renderDetail();
|
||||
const btn = screen.getByTestId("device-remove-trigger");
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn).toHaveTextContent("移除中");
|
||||
});
|
||||
});
|
||||
@ -19,27 +19,13 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AlertTriangle, ArrowLeft, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, ArrowLeft } from "lucide-react";
|
||||
|
||||
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@ -54,32 +40,14 @@ interface DeviceDetailClientProps {
|
||||
|
||||
export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
const selectedDevice = useDeviceStore((s) => s.selectedDevice);
|
||||
const isLoading = useDeviceStore((s) => s.isLoading);
|
||||
const fetchDevice = useDeviceStore((s) => s.fetchDevice);
|
||||
const unpairDevice = useDeviceStore((s) => s.unpairDevice);
|
||||
const isUnpairing = useDeviceStore((s) => s.unpairingId === id);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) void fetchDevice(id);
|
||||
}, [id, fetchDevice]);
|
||||
|
||||
async function handleRemove() {
|
||||
const result = await unpairDevice(id);
|
||||
if (result.ok) {
|
||||
toast.success(t("devices.remove.toast.success"));
|
||||
router.push("/devices");
|
||||
} else {
|
||||
// 用 backend code 對應 i18n;找不到對應 key 時退化到 unknown(對齊 model download toast)。
|
||||
const key = `devices.remove.error.${result.code}`;
|
||||
const desc = t(key);
|
||||
toast.error(t("devices.remove.error.title"), {
|
||||
description: desc === key ? t("devices.remove.error.unknown") : desc,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading && !selectedDevice) {
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-4 px-6 py-8">
|
||||
@ -160,7 +128,7 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
||||
errorMessage={selectedDevice.errorMessage ?? null}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-2">
|
||||
{isOnline && selectedDevice.flashedModel && (
|
||||
<Link href={`/workspace/${selectedDevice.id}`}>
|
||||
<Button>{t("devices.openWorkspace")}</Button>
|
||||
@ -178,58 +146,6 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 移除裝置(破壞性操作):destructive 樣式 + 二次確認對話框。
|
||||
對齊 model 詳細頁刪除 UX(AlertDialog / loading 態 / 成功導頁 / 失敗 toast)。
|
||||
不論裝置在線與否都可移除——移除是帳號層級的解除配對、與即時連線無關。 */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isUnpairing}
|
||||
data-testid="device-remove-trigger"
|
||||
>
|
||||
{isUnpairing ? (
|
||||
<>
|
||||
<Spinner size="sm" label={t("devices.remove.removing")} />
|
||||
{t("devices.remove.removing")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("devices.remove.action")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("devices.remove.confirm.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("devices.remove.confirm.description").replace(
|
||||
"{name}",
|
||||
displayName,
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isUnpairing}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRemove}
|
||||
disabled={isUnpairing}
|
||||
data-testid="device-remove-confirm"
|
||||
>
|
||||
{t("devices.remove.confirm.action")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,214 +0,0 @@
|
||||
/**
|
||||
* /models 頁單元測試 — B2「按來源分三區」
|
||||
*
|
||||
* 對齊 B2(feedback 表)拍板:
|
||||
* - 三區固定順序:preset → converted → uploaded
|
||||
* - 依 model.source 分組
|
||||
* - 移除 source 篩選、保留 targetChip 篩選且「跨三區作用」
|
||||
* - 某區無 model 仍顯示區標題 + 精簡空狀態(不整區隱藏)
|
||||
* - 預設區永遠有 preset model
|
||||
*
|
||||
* Mock:
|
||||
* - model-store fetchModels → no-op(不打網路);models 用 _setModels 直接灌
|
||||
* - next/navigation(ModelCard 內 Link / jsdom 無 app router context)
|
||||
*/
|
||||
|
||||
import { render, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useModelStore, type ModelSummary } from "@/stores/model-store";
|
||||
|
||||
import ModelsPage, { SECTION_ORDER, groupModelsBySource } from "./page";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function model(partial: Partial<ModelSummary> & Pick<ModelSummary, "id" | "source">): ModelSummary {
|
||||
return {
|
||||
name: `model-${partial.id}`,
|
||||
targetChip: "kl520",
|
||||
fileSize: 1024,
|
||||
status: "ready",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<ModelsPage />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** 取得某 source 區塊的 DOM(data-source)。 */
|
||||
function section(source: "preset" | "converted" | "uploaded"): HTMLElement {
|
||||
const el = document.querySelector<HTMLElement>(
|
||||
`[data-testid="model-section"][data-source="${source}"]`,
|
||||
);
|
||||
if (!el) throw new Error(`section ${source} not found`);
|
||||
return el;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// mount 時 page 會呼叫 fetchModels;mock 成 no-op,避免打網路、也不覆蓋我們灌的 models。
|
||||
useModelStore.setState({
|
||||
models: [],
|
||||
isLoading: false,
|
||||
fetchModels: vi.fn(async () => {}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useModelStore.setState({ models: [], isLoading: false });
|
||||
});
|
||||
|
||||
describe("<ModelsPage /> 三區分組", () => {
|
||||
it("永遠渲染三個區塊,順序固定 preset → converted → uploaded", () => {
|
||||
renderPage();
|
||||
const sections = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-testid="model-section"]'),
|
||||
);
|
||||
expect(sections).toHaveLength(3);
|
||||
expect(sections.map((s) => s.getAttribute("data-source"))).toEqual([
|
||||
"preset",
|
||||
"converted",
|
||||
"uploaded",
|
||||
]);
|
||||
});
|
||||
|
||||
it("依 source 把 model 分到對應區(三種 source 各分對區)", () => {
|
||||
useModelStore.setState({
|
||||
models: [
|
||||
model({ id: "p1", source: "preset" }),
|
||||
model({ id: "c1", source: "converted" }),
|
||||
model({ id: "u1", source: "uploaded" }),
|
||||
],
|
||||
});
|
||||
renderPage();
|
||||
|
||||
expect(within(section("preset")).getByText("model-p1")).toBeTruthy();
|
||||
expect(within(section("preset")).queryByText("model-c1")).toBeNull();
|
||||
expect(within(section("preset")).queryByText("model-u1")).toBeNull();
|
||||
|
||||
expect(within(section("converted")).getByText("model-c1")).toBeTruthy();
|
||||
expect(within(section("converted")).queryByText("model-p1")).toBeNull();
|
||||
|
||||
expect(within(section("uploaded")).getByText("model-u1")).toBeTruthy();
|
||||
expect(within(section("uploaded")).queryByText("model-c1")).toBeNull();
|
||||
});
|
||||
|
||||
it("預設區顯示所有 preset model", () => {
|
||||
useModelStore.setState({
|
||||
models: [
|
||||
model({ id: "p1", source: "preset" }),
|
||||
model({ id: "p2", source: "preset" }),
|
||||
model({ id: "u1", source: "uploaded" }),
|
||||
],
|
||||
});
|
||||
renderPage();
|
||||
|
||||
const presetGrid = within(section("preset")).getByTestId("model-grid");
|
||||
expect(within(presetGrid).getByText("model-p1")).toBeTruthy();
|
||||
expect(within(presetGrid).getByText("model-p2")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ModelsPage /> 空區空狀態", () => {
|
||||
it("某區無 model → 顯示區標題 + 精簡空狀態(不整區隱藏)", () => {
|
||||
useModelStore.setState({
|
||||
models: [model({ id: "p1", source: "preset" })],
|
||||
});
|
||||
renderPage();
|
||||
|
||||
// 三區都還在
|
||||
expect(section("uploaded")).toBeTruthy();
|
||||
// 上傳區無 model → 顯示空狀態,非 grid
|
||||
expect(within(section("uploaded")).getByTestId("model-grid-empty")).toBeTruthy();
|
||||
expect(within(section("uploaded")).queryByTestId("model-grid")).toBeNull();
|
||||
// 區標題仍在
|
||||
expect(within(section("uploaded")).getByText("我上傳的")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* targetChip 篩選跨三區作用 — 直接測純函式 groupModelsBySource。
|
||||
*
|
||||
* 不透過 Radix Select 的 UI 互動(jsdom 缺 pointer capture API,Select 開合不穩定 → flaky)。
|
||||
* 純函式測試直接驗「篩選 + 分組」這個核心邏輯,deterministic。頁面把 select 的值丟給同一個
|
||||
* 函式(page.tsx useMemo),所以函式對 = 跨三區作用對。
|
||||
*/
|
||||
describe("groupModelsBySource — targetChip 篩選跨三區作用", () => {
|
||||
const data: ModelSummary[] = [
|
||||
model({ id: "p520", source: "preset", targetChip: "kl520" }),
|
||||
model({ id: "p720", source: "preset", targetChip: "kl720" }),
|
||||
model({ id: "c720", source: "converted", targetChip: "kl720" }),
|
||||
model({ id: "u520", source: "uploaded", targetChip: "kl520" }),
|
||||
];
|
||||
|
||||
it("targetChip='all' → 不篩,三組各自拿到自己 source 的全部 model", () => {
|
||||
const g = groupModelsBySource(data, "all");
|
||||
expect(g.preset.map((m) => m.id).sort()).toEqual(["p520", "p720"]);
|
||||
expect(g.converted.map((m) => m.id)).toEqual(["c720"]);
|
||||
expect(g.uploaded.map((m) => m.id)).toEqual(["u520"]);
|
||||
});
|
||||
|
||||
it("targetChip='kl720' → 三區都只留 KL720;KL520-only 的 uploaded 區變空", () => {
|
||||
const g = groupModelsBySource(data, "kl720");
|
||||
expect(g.preset.map((m) => m.id)).toEqual(["p720"]); // p520 被濾掉
|
||||
expect(g.converted.map((m) => m.id)).toEqual(["c720"]);
|
||||
expect(g.uploaded).toEqual([]); // u520 是 KL520 → 套 KL720 後空
|
||||
});
|
||||
|
||||
it("分組永遠回三個 key(即使某 source 完全沒 model)", () => {
|
||||
const g = groupModelsBySource([], "all");
|
||||
expect(Object.keys(g).sort()).toEqual(["converted", "preset", "uploaded"]);
|
||||
expect(g.preset).toEqual([]);
|
||||
expect(g.converted).toEqual([]);
|
||||
expect(g.uploaded).toEqual([]);
|
||||
});
|
||||
|
||||
it("SECTION_ORDER 固定為 preset → converted → uploaded", () => {
|
||||
expect([...SECTION_ORDER]).toEqual(["preset", "converted", "uploaded"]);
|
||||
});
|
||||
});
|
||||
|
||||
/** 篩選後某區變空 → DOM 仍渲染該區 + 精簡空狀態(整合驗證,灌已篩好的資料模擬)。 */
|
||||
describe("<ModelsPage /> 篩選後空區仍顯示空狀態", () => {
|
||||
it("uploaded 區無 model(模擬篩掉後)→ 顯示區標題 + 空狀態,不整區隱藏", () => {
|
||||
useModelStore.setState({
|
||||
models: [
|
||||
model({ id: "p720", source: "preset", targetChip: "kl720" }),
|
||||
model({ id: "c720", source: "converted", targetChip: "kl720" }),
|
||||
],
|
||||
});
|
||||
renderPage();
|
||||
|
||||
expect(section("uploaded")).toBeTruthy();
|
||||
expect(within(section("uploaded")).getByTestId("model-grid-empty")).toBeTruthy();
|
||||
expect(within(section("uploaded")).getByText("我上傳的")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ModelsPage /> loading", () => {
|
||||
it("isLoading=true → 各區顯示 grid skeleton", () => {
|
||||
useModelStore.setState({ isLoading: true, models: [] });
|
||||
renderPage();
|
||||
const skeletons = document.querySelectorAll(
|
||||
'[data-testid="model-grid-skeleton"]',
|
||||
);
|
||||
// 三區各一個 skeleton grid
|
||||
expect(skeletons).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@ -4,11 +4,6 @@
|
||||
* 模型庫 — /models
|
||||
*
|
||||
* 對齊 pages.md §8.1、flow-model-upload.md §4.1。
|
||||
*
|
||||
* B2(feedback 表):頁面改成「按 model 來源分三區」——
|
||||
* ① 預設模型(preset) → ② 我轉檔的(converted) → ③ 我上傳的(uploaded),順序固定。
|
||||
* 原本的 source 篩選與分區重複、已移除;只留 targetChip 篩選,且它跨三區作用。
|
||||
* 某區無 model(沒上傳過、或套晶片篩選後變空)仍顯示區標題 + 精簡空狀態,不整區隱藏。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@ -17,45 +12,10 @@ import {
|
||||
ModelFilters,
|
||||
type ModelFilterValue,
|
||||
} from "@/components/models/model-filters";
|
||||
import { ModelSection } from "@/components/models/model-section";
|
||||
import { ModelGrid } from "@/components/models/model-grid";
|
||||
import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import {
|
||||
type ModelSource,
|
||||
type ModelSummary,
|
||||
type TargetChip,
|
||||
useModelStore,
|
||||
} from "@/stores/model-store";
|
||||
|
||||
/** 分區順序固定:preset → converted → uploaded(對齊 B2 拍板)。 */
|
||||
export const SECTION_ORDER: readonly ModelSource[] = [
|
||||
"preset",
|
||||
"converted",
|
||||
"uploaded",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 把 models 先套 targetChip 篩選(跨三區作用)、再依 source 分三組。
|
||||
* 抽成純函式:分組 / 篩選邏輯可被 deterministic 單元測試,不受 Radix Select 在 jsdom 的限制。
|
||||
*
|
||||
* @param targetChip "all" = 不篩;否則只留 m.targetChip === targetChip 的 model。
|
||||
*/
|
||||
export function groupModelsBySource(
|
||||
models: ModelSummary[],
|
||||
targetChip: TargetChip | "all",
|
||||
): Record<ModelSource, ModelSummary[]> {
|
||||
const groups: Record<ModelSource, ModelSummary[]> = {
|
||||
preset: [],
|
||||
converted: [],
|
||||
uploaded: [],
|
||||
};
|
||||
for (const m of models) {
|
||||
if (targetChip !== "all" && m.targetChip !== targetChip) continue;
|
||||
// m.source 已由 store normalize 收斂為三種之一(預設 "uploaded")。
|
||||
groups[m.source].push(m);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
import { useModelStore } from "@/stores/model-store";
|
||||
|
||||
export default function ModelsPage() {
|
||||
const t = useT();
|
||||
@ -64,17 +24,21 @@ export default function ModelsPage() {
|
||||
const fetchModels = useModelStore((s) => s.fetchModels);
|
||||
const [filter, setFilter] = useState<ModelFilterValue>({
|
||||
targetChip: "all",
|
||||
source: "all",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void fetchModels();
|
||||
}, [fetchModels]);
|
||||
|
||||
// 先套 targetChip 篩選(跨三區作用),再依 source 分三組。
|
||||
const grouped = useMemo(
|
||||
() => groupModelsBySource(models, filter.targetChip),
|
||||
[models, filter],
|
||||
);
|
||||
const filtered = useMemo(() => {
|
||||
return models.filter((m) => {
|
||||
if (filter.targetChip !== "all" && m.targetChip !== filter.targetChip)
|
||||
return false;
|
||||
if (filter.source !== "all" && m.source !== filter.source) return false;
|
||||
return true;
|
||||
});
|
||||
}, [models, filter]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8">
|
||||
@ -86,18 +50,7 @@ export default function ModelsPage() {
|
||||
<ModelUploadDialog />
|
||||
</div>
|
||||
<ModelFilters value={filter} onChange={setFilter} />
|
||||
<div className="space-y-8">
|
||||
{SECTION_ORDER.map((source) => (
|
||||
<ModelSection
|
||||
key={source}
|
||||
source={source}
|
||||
title={t(`models.section.${source}`)}
|
||||
emptyText={t(`models.section.empty.${source}`)}
|
||||
models={grouped[source]}
|
||||
loading={isLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ModelGrid models={filtered} loading={isLoading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,75 +0,0 @@
|
||||
/**
|
||||
* ActivityTimeline 測試
|
||||
*
|
||||
* 回歸重點:
|
||||
* F7/F8 接 WS 事件後,後端可能推入 activityIcons/activityColors map 尚未涵蓋的
|
||||
* 未知 type → lookup 回 undefined → <Icon /> render undefined 會 crash。修法加
|
||||
* fallback(Circle / text-muted-foreground)。這裡塞一筆未知 type 的 activity,
|
||||
* 確認 render 不 throw。
|
||||
*
|
||||
* 另驗:已知 type 正常渲染、空狀態。
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
useActivityStore,
|
||||
type ActivityEntry,
|
||||
type ActivityType,
|
||||
} from "@/stores/activity-store";
|
||||
|
||||
import { ActivityTimeline } from "./activity-timeline";
|
||||
|
||||
function renderTimeline() {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<ActivityTimeline />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useActivityStore.setState({ activities: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useActivityStore.setState({ activities: [] });
|
||||
});
|
||||
|
||||
describe("ActivityTimeline", () => {
|
||||
it("空狀態:顯示空文案", () => {
|
||||
expect(() => renderTimeline()).not.toThrow();
|
||||
expect(screen.getByText(/還沒有任何活動/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("已知 type 正常渲染", () => {
|
||||
const entry: ActivityEntry = {
|
||||
id: "a1",
|
||||
type: "device_paired",
|
||||
message: "配對成功",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
useActivityStore.setState({ activities: [entry] });
|
||||
|
||||
expect(() => renderTimeline()).not.toThrow();
|
||||
expect(screen.getByText("配對成功")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("未知 type(後端推未涵蓋事件)→ fallback icon/色,不 crash", () => {
|
||||
// 模擬 F7/F8 接 WS 後推入 map 尚未涵蓋的 type。
|
||||
const entry: ActivityEntry = {
|
||||
id: "a-unknown",
|
||||
type: "some_future_event" as ActivityType,
|
||||
message: "未知事件",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
useActivityStore.setState({ activities: [entry] });
|
||||
|
||||
expect(() => renderTimeline()).not.toThrow();
|
||||
expect(screen.getByText("未知事件")).toBeInTheDocument();
|
||||
// 列表有渲染(fallback icon 沒讓整列消失)
|
||||
expect(screen.getByTestId("activity-list")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -21,7 +21,6 @@ import { useEffect, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Link2,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
@ -105,11 +104,8 @@ export function ActivityTimeline() {
|
||||
) : (
|
||||
<ul className="space-y-3" data-testid="activity-list">
|
||||
{activities.map((activity) => {
|
||||
// F7/F8 接 WS 事件後,後端可能推入 activityIcons/activityColors 尚未
|
||||
// 涵蓋的未知 type → map lookup 回 undefined。fallback 到中性 icon/色,
|
||||
// 避免 <Icon /> render undefined 而 crash。
|
||||
const Icon = activityIcons[activity.type] ?? Circle;
|
||||
const color = activityColors[activity.type] ?? "text-muted-foreground";
|
||||
const Icon = activityIcons[activity.type];
|
||||
const color = activityColors[activity.type];
|
||||
return (
|
||||
<li key={activity.id} className="flex items-start gap-3">
|
||||
<Icon
|
||||
|
||||
@ -1,109 +0,0 @@
|
||||
/**
|
||||
* ConnectedDevicesList 測試
|
||||
*
|
||||
* 回歸重點(React #185 無限 re-render):
|
||||
* zustand v5 selector 內 .filter() 每次回新陣列 reference → useSyncExternalStore
|
||||
* Object.is 永遠 false → 無限 re-render → 白屏。修法把 filter 移到 selector 外。
|
||||
* 這裡 render 含 online/offline 混合的 device 清單,確認元件能正常 render 而不
|
||||
* 爆「Maximum update depth exceeded」(若迴圈仍在,render 會 throw)。
|
||||
*
|
||||
* Mock:
|
||||
* - next/navigation(jsdom 無 app router context,<Link> 內部需要)
|
||||
*/
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useDeviceStore, type DeviceSummary } from "@/stores/device-store";
|
||||
|
||||
import { ConnectedDevicesList } from "./connected-devices-list";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeDevice(over: Partial<DeviceSummary>): DeviceSummary {
|
||||
return {
|
||||
id: "d1",
|
||||
name: "Device 1",
|
||||
type: "kneron",
|
||||
status: "connected",
|
||||
remoteStatus: "online",
|
||||
lastSeenAt: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<ConnectedDevicesList />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useDeviceStore.setState({ devices: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useDeviceStore.setState({ devices: [] });
|
||||
});
|
||||
|
||||
describe("ConnectedDevicesList", () => {
|
||||
it("無 online 裝置時顯示空狀態(不無限迴圈)", () => {
|
||||
useDeviceStore.getState()._setDevices([
|
||||
makeDevice({ id: "off1", remoteStatus: "offline" }),
|
||||
]);
|
||||
// 若 selector 迴圈仍在,render 會 throw "Maximum update depth exceeded"。
|
||||
expect(() => renderList()).not.toThrow();
|
||||
// 沒有 online 裝置 → 不渲染列表,顯示空狀態文案
|
||||
expect(screen.queryByTestId("connected-devices-list")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/目前沒有裝置線上/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("混合 online/offline 裝置:只列 online,且不無限迴圈", () => {
|
||||
useDeviceStore.getState()._setDevices([
|
||||
makeDevice({ id: "on1", name: "Online A", remoteStatus: "online" }),
|
||||
makeDevice({ id: "off1", name: "Offline B", remoteStatus: "offline" }),
|
||||
makeDevice({ id: "on2", name: "Online C", remoteStatus: "online" }),
|
||||
]);
|
||||
expect(() => renderList()).not.toThrow();
|
||||
|
||||
const list = screen.getByTestId("connected-devices-list");
|
||||
expect(list).toBeInTheDocument();
|
||||
expect(screen.getByText("Online A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Online C")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Offline B")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-render(state 更新)後 selector 穩定、不爆迴圈", () => {
|
||||
useDeviceStore.getState()._setDevices([
|
||||
makeDevice({ id: "on1", name: "Online A", remoteStatus: "online" }),
|
||||
]);
|
||||
renderList();
|
||||
expect(screen.getByText("Online A")).toBeInTheDocument();
|
||||
|
||||
// 再次更新 store(模擬裝置狀態變化)→ 不應觸發無限迴圈。
|
||||
// 外部 store 更新需用 act 包覆讓 React flush re-render。
|
||||
expect(() => {
|
||||
act(() => {
|
||||
useDeviceStore.getState()._setDevices([
|
||||
makeDevice({ id: "on1", name: "Online A", remoteStatus: "online" }),
|
||||
makeDevice({ id: "on2", name: "Online D", remoteStatus: "online" }),
|
||||
]);
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(screen.getByText("Online D")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -26,16 +26,10 @@ import { useDeviceStore } from "@/stores/device-store";
|
||||
|
||||
export function ConnectedDevicesList() {
|
||||
const t = useT();
|
||||
// 雲端版語意:列「線上」裝置(remoteStatus=online),不是 USB 連接。
|
||||
//
|
||||
// selector 只取穩定 reference 的 s.devices,filter 在 component body 做。
|
||||
// zustand v5 底層用 useSyncExternalStore + Object.is 比較 snapshot,
|
||||
// 已移除 v4 內建的 selector 淺比較;若在 selector 內 .filter() 每次回新陣列
|
||||
// reference → Object.is 永遠 false → 無限 re-render(React #185)。
|
||||
// 對齊專案其他頁慣例(devices/models 頁、activity-timeline):selector 回穩定
|
||||
// reference,衍生計算放 render body。
|
||||
const allDevices = useDeviceStore((s) => s.devices);
|
||||
const devices = allDevices.filter((d) => d.remoteStatus === "online");
|
||||
// 雲端版語意:列「線上」裝置(remoteStatus=online),不是 USB 連接
|
||||
const devices = useDeviceStore((s) =>
|
||||
s.devices.filter((d) => d.remoteStatus === "online"),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
||||
@ -161,45 +161,3 @@ describe("ModelCard 下載互動", () => {
|
||||
expect(btn).toHaveTextContent("下載中");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelCard 來源 Badge 配色(B2 三區來源以顏色區分)", () => {
|
||||
it("preset → chart-1 色", () => {
|
||||
renderCard({ ...convertedReady, source: "preset" });
|
||||
const badge = screen.getByTestId("model-source-badge");
|
||||
expect(badge).toHaveAttribute("data-source", "preset");
|
||||
expect(badge.className).toContain("text-chart-1");
|
||||
expect(badge).toHaveTextContent("預設");
|
||||
});
|
||||
|
||||
it("converted → chart-2 色", () => {
|
||||
renderCard({ ...convertedReady, source: "converted" });
|
||||
const badge = screen.getByTestId("model-source-badge");
|
||||
expect(badge).toHaveAttribute("data-source", "converted");
|
||||
expect(badge.className).toContain("text-chart-2");
|
||||
expect(badge).toHaveTextContent("已轉檔");
|
||||
});
|
||||
|
||||
it("uploaded → chart-3 色(先前無 badge,現補上)", () => {
|
||||
renderCard({ ...convertedReady, source: "uploaded" });
|
||||
const badge = screen.getByTestId("model-source-badge");
|
||||
expect(badge).toHaveAttribute("data-source", "uploaded");
|
||||
expect(badge.className).toContain("text-chart-3");
|
||||
expect(badge).toHaveTextContent("自行上傳");
|
||||
});
|
||||
|
||||
it("三種來源顏色彼此不同(chart-1/2/3)", () => {
|
||||
const colorOf = (source: ModelSummary["source"]) => {
|
||||
const { unmount } = renderCard({ ...convertedReady, source });
|
||||
const cls = screen.getByTestId("model-source-badge").className;
|
||||
const match = cls.match(/text-chart-\d/);
|
||||
unmount();
|
||||
return match?.[0];
|
||||
};
|
||||
const colors = new Set([
|
||||
colorOf("preset"),
|
||||
colorOf("converted"),
|
||||
colorOf("uploaded"),
|
||||
]);
|
||||
expect(colors.size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@ -28,7 +28,6 @@ import { cn } from "@/lib/utils";
|
||||
import {
|
||||
isModelDownloadable,
|
||||
useModelStore,
|
||||
type ModelSource,
|
||||
type ModelStatus,
|
||||
type ModelSummary,
|
||||
} from "@/stores/model-store";
|
||||
@ -45,29 +44,6 @@ const STATUS_VARIANT: Record<ModelStatus, { variant: "default" | "secondary" | "
|
||||
rejected: { variant: "destructive", key: "models.status.rejected" },
|
||||
};
|
||||
|
||||
/**
|
||||
* 來源 Badge 配色映射(B2:三區來源以顏色區分)。
|
||||
*
|
||||
* 用設計系統 chart-* token(design-review m1:不硬編碼裸 Tailwind 色階):
|
||||
* preset → chart-1 / converted → chart-2 / uploaded → chart-3
|
||||
* 與 model-section 區標題色點同色呼應,dark mode 由 token 自動處理。
|
||||
* className 採 tint 風格(bg/10 底 + 同色文字 + /30 邊框),三色彼此區分度足夠。
|
||||
*/
|
||||
const SOURCE_BADGE: Record<ModelSource, { className: string; key: string }> = {
|
||||
preset: {
|
||||
className: "border-chart-1/30 bg-chart-1/10 text-chart-1",
|
||||
key: "models.source.preset",
|
||||
},
|
||||
converted: {
|
||||
className: "border-chart-2/30 bg-chart-2/10 text-chart-2",
|
||||
key: "models.source.converted",
|
||||
},
|
||||
uploaded: {
|
||||
className: "border-chart-3/30 bg-chart-3/10 text-chart-3",
|
||||
key: "models.source.uploaded",
|
||||
},
|
||||
};
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
@ -78,7 +54,6 @@ function formatFileSize(bytes: number): string {
|
||||
export function ModelCard({ model }: ModelCardProps) {
|
||||
const t = useT();
|
||||
const statusMeta = STATUS_VARIANT[model.status];
|
||||
const sourceMeta = SOURCE_BADGE[model.source];
|
||||
|
||||
const downloadModel = useModelStore((s) => s.downloadModel);
|
||||
// per-card loading:只在「下載中的是這張卡」時顯示 spinner。
|
||||
@ -131,14 +106,14 @@ export function ModelCard({ model }: ModelCardProps) {
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{model.targetChip.toUpperCase()}
|
||||
</Badge>
|
||||
{sourceMeta && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-xs", sourceMeta.className)}
|
||||
data-testid="model-source-badge"
|
||||
data-source={model.source}
|
||||
>
|
||||
{t(sourceMeta.key)}
|
||||
{model.source === "preset" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("models.source.preset")}
|
||||
</Badge>
|
||||
)}
|
||||
{model.source === "converted" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("models.source.converted")}
|
||||
</Badge>
|
||||
)}
|
||||
{model.category && (
|
||||
|
||||
@ -3,8 +3,7 @@
|
||||
/**
|
||||
* ModelFilters — 模型列表篩選器(雛形簡化版)
|
||||
*
|
||||
* B2 起 /models 頁改成「按來源分三區」,原本的 source 篩選與分區重複、已移除。
|
||||
* 此處只保留 targetChip 篩選,且它會「跨三區作用」(選 KL520 時三區都只顯示 KL520)。
|
||||
* 對齊 api-spec §4 — 提供 targetChip + source 兩個常用篩選。
|
||||
* Phase 1 會擴充成搜尋、分類、標籤等(design-review 缺失項:Search)。
|
||||
*/
|
||||
|
||||
@ -18,10 +17,11 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import type { TargetChip } from "@/stores/model-store";
|
||||
import type { ModelSource, TargetChip } from "@/stores/model-store";
|
||||
|
||||
export interface ModelFilterValue {
|
||||
targetChip: TargetChip | "all";
|
||||
source: ModelSource | "all";
|
||||
}
|
||||
|
||||
interface ModelFiltersProps {
|
||||
@ -60,6 +60,22 @@ export function ModelFilters({ value, onChange }: ModelFiltersProps) {
|
||||
<SelectItem value="kl730">KL730</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={value.source}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...value, source: v as ModelFilterValue["source"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-40" aria-label={t("models.filters.source")}>
|
||||
<SelectValue placeholder={t("models.filters.source")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("models.filters.all")}</SelectItem>
|
||||
<SelectItem value="uploaded">{t("models.source.uploaded")}</SelectItem>
|
||||
<SelectItem value="preset">{t("models.source.preset")}</SelectItem>
|
||||
<SelectItem value="converted">{t("models.source.converted")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -23,19 +23,9 @@ interface ModelGridProps {
|
||||
loading?: boolean;
|
||||
/** 空狀態按下 CTA 觸發(通常跳到上傳 dialog) */
|
||||
onUploadClick?: () => void;
|
||||
/**
|
||||
* 區塊內空狀態文案(給「按來源分區」用)。提供時,models 為空只顯示一行精簡提示
|
||||
* (配合區標題),不顯示整頁式的大型 EmptyState + CTA。未提供時維持原本大型 EmptyState。
|
||||
*/
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
export function ModelGrid({
|
||||
models,
|
||||
loading,
|
||||
onUploadClick,
|
||||
emptyText,
|
||||
}: ModelGridProps) {
|
||||
export function ModelGrid({ models, loading, onUploadClick }: ModelGridProps) {
|
||||
const t = useT();
|
||||
|
||||
if (loading) {
|
||||
@ -52,17 +42,6 @@ export function ModelGrid({
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
// 分區模式:只顯示精簡 inline 空狀態(區標題由外層 ModelSection 負責)。
|
||||
if (emptyText !== undefined) {
|
||||
return (
|
||||
<p
|
||||
className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center text-sm"
|
||||
data-testid="model-grid-empty"
|
||||
>
|
||||
{emptyText}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
|
||||
@ -1,76 +0,0 @@
|
||||
/**
|
||||
* ModelSection 測試
|
||||
*
|
||||
* 覆蓋(B2 三區來源以顏色區分):
|
||||
* - 區標題色點顏色與來源對應(preset→chart-1 / converted→chart-2 / uploaded→chart-3),
|
||||
* 與 model-card 來源 Badge 同色呼應。
|
||||
* - 未知 source → fallback 到中性色(不 crash)。
|
||||
*
|
||||
* Mock:
|
||||
* - next/navigation(ModelGrid → ModelCard 內 <Link>,jsdom 無 app router context)
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
|
||||
import { ModelSection } from "./model-section";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderSection(source: string) {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<ModelSection
|
||||
title={`區 ${source}`}
|
||||
models={[]}
|
||||
emptyText="(空)"
|
||||
source={source}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ModelSection 區標題色點", () => {
|
||||
it("preset → chart-1 色點", () => {
|
||||
renderSection("preset");
|
||||
expect(screen.getByTestId("model-section-dot").className).toContain(
|
||||
"bg-chart-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("converted → chart-2 色點", () => {
|
||||
renderSection("converted");
|
||||
expect(screen.getByTestId("model-section-dot").className).toContain(
|
||||
"bg-chart-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("uploaded → chart-3 色點", () => {
|
||||
renderSection("uploaded");
|
||||
expect(screen.getByTestId("model-section-dot").className).toContain(
|
||||
"bg-chart-3",
|
||||
);
|
||||
});
|
||||
|
||||
it("未知 source → fallback 中性色,不 crash", () => {
|
||||
expect(() => renderSection("mystery")).not.toThrow();
|
||||
expect(screen.getByTestId("model-section-dot").className).toContain(
|
||||
"bg-border",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ModelSection — 模型庫「按來源分區」的單一區塊
|
||||
*
|
||||
* 對齊 B2(feedback 表):/models 頁改成按 model 來源分三區
|
||||
* ① 預設模型(preset) → ② 我轉檔的(converted) → ③ 我上傳的(uploaded)
|
||||
*
|
||||
* 每區 = 區標題 + 該 source 的 ModelGrid。即使區內無 model(如使用者沒上傳過、
|
||||
* 或套用晶片篩選後變空),仍顯示區標題 + 精簡空狀態(emptyText 傳給 ModelGrid)。
|
||||
* 唯一例外:loading 時整頁交給各區 ModelGrid 顯示 skeleton。
|
||||
*/
|
||||
|
||||
import { ModelGrid } from "@/components/models/model-grid";
|
||||
import type { ModelSummary } from "@/stores/model-store";
|
||||
|
||||
/**
|
||||
* 區標題色點配色(B2:與 model-card 來源 Badge 同色呼應)。
|
||||
*
|
||||
* 用設計系統 chart-* token(design-review m1:不硬編碼裸色階):
|
||||
* preset → chart-1 / converted → chart-2 / uploaded → chart-3
|
||||
* source 為其他值時 fallback 到中性色(border 色),確保不 crash。
|
||||
*/
|
||||
const SOURCE_DOT_CLASS: Record<string, string> = {
|
||||
preset: "bg-chart-1",
|
||||
converted: "bg-chart-2",
|
||||
uploaded: "bg-chart-3",
|
||||
};
|
||||
|
||||
interface ModelSectionProps {
|
||||
/** 區標題(已翻譯文字) */
|
||||
title: string;
|
||||
/** 此區的 model(已依 source 分組 + 套用晶片篩選) */
|
||||
models: ModelSummary[];
|
||||
/** 區內無 model 時顯示的精簡空狀態文案(已翻譯) */
|
||||
emptyText: string;
|
||||
loading?: boolean;
|
||||
/** 給測試 / DOM 定位用(如 "preset" / "converted" / "uploaded") */
|
||||
source: string;
|
||||
}
|
||||
|
||||
export function ModelSection({
|
||||
title,
|
||||
models,
|
||||
emptyText,
|
||||
loading,
|
||||
source,
|
||||
}: ModelSectionProps) {
|
||||
return (
|
||||
<section className="space-y-3" data-testid="model-section" data-source={source}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid="model-section-dot"
|
||||
className={`size-2.5 shrink-0 rounded-full ${
|
||||
SOURCE_DOT_CLASS[source] ?? "bg-border"
|
||||
}`}
|
||||
/>
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<span className="text-muted-foreground text-sm">({models.length})</span>
|
||||
</div>
|
||||
<ModelGrid models={models} loading={loading} emptyText={emptyText} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -159,19 +159,6 @@ export const en: Dictionary = {
|
||||
"devices.status.error": "Error",
|
||||
"devices.status.disconnected": "Disconnected",
|
||||
|
||||
// ── Devices: remove (unpair) ──
|
||||
"devices.remove.action": "Remove device",
|
||||
"devices.remove.removing": "Removing…",
|
||||
"devices.remove.confirm.title": "Remove this device?",
|
||||
"devices.remove.confirm.description":
|
||||
"This unpairs “{name}” from your account and revokes its access. To use it again, you'll need to pair it from local agent. This cannot be undone.",
|
||||
"devices.remove.confirm.action": "Remove",
|
||||
"devices.remove.toast.success": "Device removed",
|
||||
"devices.remove.error.title": "Couldn't remove device",
|
||||
"devices.remove.error.FORBIDDEN": "You don't have permission to remove this device.",
|
||||
"devices.remove.error.NOT_FOUND": "This device no longer exists.",
|
||||
"devices.remove.error.unknown": "Something went wrong. Please try again.",
|
||||
|
||||
// ── Remote Device Badge ──
|
||||
"remote.status.online": "Online",
|
||||
"remote.status.offline": "Offline",
|
||||
@ -197,17 +184,12 @@ export const en: Dictionary = {
|
||||
"models.source.converted": "Converted",
|
||||
"models.filters.label": "Model filters",
|
||||
"models.filters.hardware": "Hardware",
|
||||
"models.filters.source": "Source",
|
||||
"models.filters.all": "All",
|
||||
"models.empty.title": "No models yet",
|
||||
"models.empty.description":
|
||||
"Upload your first .nef model to deploy it to any paired Kneron device.",
|
||||
"models.empty.action": "Upload your first model",
|
||||
"models.section.preset": "Preset models",
|
||||
"models.section.converted": "Converted by you",
|
||||
"models.section.uploaded": "Uploaded by you",
|
||||
"models.section.empty.preset": "No preset models available.",
|
||||
"models.section.empty.converted": "You haven't converted any models yet.",
|
||||
"models.section.empty.uploaded": "You haven't uploaded any models yet.",
|
||||
"models.detail.description": "Description",
|
||||
"models.detail.version": "Version",
|
||||
"models.detail.checksum": "Checksum",
|
||||
|
||||
@ -160,19 +160,6 @@ export const zhHant: Dictionary = {
|
||||
"devices.status.error": "錯誤",
|
||||
"devices.status.disconnected": "未連接",
|
||||
|
||||
// ── Devices: 移除裝置(unpair) ──
|
||||
"devices.remove.action": "移除裝置",
|
||||
"devices.remove.removing": "移除中…",
|
||||
"devices.remove.confirm.title": "確定要移除此裝置?",
|
||||
"devices.remove.confirm.description":
|
||||
"這會解除「{name}」與你帳號的配對並撤銷其存取權限。若要再次使用,需從 local agent 重新配對。此操作無法復原。",
|
||||
"devices.remove.confirm.action": "移除",
|
||||
"devices.remove.toast.success": "已移除裝置",
|
||||
"devices.remove.error.title": "移除裝置失敗",
|
||||
"devices.remove.error.FORBIDDEN": "你沒有權限移除此裝置",
|
||||
"devices.remove.error.NOT_FOUND": "此裝置已不存在",
|
||||
"devices.remove.error.unknown": "發生錯誤,請稍後再試",
|
||||
|
||||
// ── Remote Device Badge(雲端 tunnel 狀態) ──
|
||||
"remote.status.online": "在線",
|
||||
"remote.status.offline": "離線",
|
||||
@ -198,17 +185,12 @@ export const zhHant: Dictionary = {
|
||||
"models.source.converted": "已轉檔",
|
||||
"models.filters.label": "模型篩選",
|
||||
"models.filters.hardware": "硬體",
|
||||
"models.filters.source": "來源",
|
||||
"models.filters.all": "全部",
|
||||
"models.empty.title": "還沒有任何模型",
|
||||
"models.empty.description":
|
||||
"上傳你的第一個 .nef 模型到雲端,就能部署到任何一台配對過的 Kneron 裝置",
|
||||
"models.empty.action": "上傳第一個模型",
|
||||
"models.section.preset": "預設模型",
|
||||
"models.section.converted": "我轉檔的",
|
||||
"models.section.uploaded": "我上傳的",
|
||||
"models.section.empty.preset": "目前沒有可用的預設模型",
|
||||
"models.section.empty.converted": "你還沒有轉檔過任何模型",
|
||||
"models.section.empty.uploaded": "你還沒有上傳過任何模型",
|
||||
"models.detail.description": "說明",
|
||||
"models.detail.version": "版本",
|
||||
"models.detail.checksum": "校驗碼",
|
||||
|
||||
@ -22,7 +22,6 @@ beforeEach(() => {
|
||||
isLoading: false,
|
||||
connectingId: null,
|
||||
disconnectingId: null,
|
||||
unpairingId: null,
|
||||
error: null,
|
||||
});
|
||||
// OF2:api.ts 不再需要 token getter(cookie session 由瀏覽器自動帶)
|
||||
@ -164,97 +163,3 @@ describe("useDeviceStore", () => {
|
||||
expect(err.code).toBe("INTERNAL_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDeviceStore.unpairDevice", () => {
|
||||
const summary = {
|
||||
id: "dev-1",
|
||||
name: "KL520",
|
||||
type: "kl520",
|
||||
status: "connected" as const,
|
||||
remoteStatus: "online" as const,
|
||||
};
|
||||
|
||||
it("成功時打對 endpoint、回 { ok: true }、從 list 移除該裝置且清空 unpairingId", async () => {
|
||||
useDeviceStore.setState({
|
||||
devices: [summary, { ...summary, id: "dev-2" }],
|
||||
selectedDevice: { ...summary },
|
||||
});
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
jsonResponse({ success: true, data: { id: "dev-1", unpaired: true } }),
|
||||
);
|
||||
|
||||
const result = await useDeviceStore.getState().unpairDevice("dev-1");
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
// 打到 POST /api/devices/dev-1/unpair
|
||||
const calledUrl = String(fetchSpy.mock.calls[0]?.[0]);
|
||||
expect(calledUrl).toContain("/api/devices/dev-1/unpair");
|
||||
expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
|
||||
|
||||
const state = useDeviceStore.getState();
|
||||
expect(state.devices.map((d) => d.id)).toEqual(["dev-2"]);
|
||||
// selectedDevice 是被移除的那台 → 清為 null
|
||||
expect(state.selectedDevice).toBeNull();
|
||||
expect(state.unpairingId).toBeNull();
|
||||
});
|
||||
|
||||
it("成功移除非當前 selected 的裝置 → 不動 selectedDevice", async () => {
|
||||
useDeviceStore.setState({
|
||||
devices: [summary, { ...summary, id: "dev-2" }],
|
||||
selectedDevice: { ...summary, id: "dev-2" },
|
||||
});
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
jsonResponse({ success: true, data: { id: "dev-1", unpaired: true } }),
|
||||
);
|
||||
|
||||
await useDeviceStore.getState().unpairDevice("dev-1");
|
||||
|
||||
expect(useDeviceStore.getState().selectedDevice?.id).toBe("dev-2");
|
||||
});
|
||||
|
||||
it("呼叫期間 unpairingId 設為該 id(loading 態)", async () => {
|
||||
let observedDuringCall: string | null = "not-set";
|
||||
vi.spyOn(globalThis, "fetch").mockImplementationOnce(async () => {
|
||||
// fetch 進行中時 store 應已標記 unpairingId
|
||||
observedDuringCall = useDeviceStore.getState().unpairingId;
|
||||
return jsonResponse({ success: true, data: { id: "dev-1", unpaired: true } });
|
||||
});
|
||||
|
||||
await useDeviceStore.getState().unpairDevice("dev-1");
|
||||
|
||||
expect(observedDuringCall).toBe("dev-1");
|
||||
expect(useDeviceStore.getState().unpairingId).toBeNull();
|
||||
});
|
||||
|
||||
it("403 FORBIDDEN(非 owner)→ 回 { ok:false, code:'FORBIDDEN' },不移除 list,清空 unpairingId", async () => {
|
||||
useDeviceStore.setState({ devices: [summary] });
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ success: false, error: { code: "FORBIDDEN", message: "not owner" } },
|
||||
403,
|
||||
),
|
||||
);
|
||||
|
||||
const result = await useDeviceStore.getState().unpairDevice("dev-1");
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "FORBIDDEN" });
|
||||
// 失敗時 list 不變
|
||||
expect(useDeviceStore.getState().devices.map((d) => d.id)).toEqual(["dev-1"]);
|
||||
expect(useDeviceStore.getState().unpairingId).toBeNull();
|
||||
expect(useDeviceStore.getState().error).toBe("not owner");
|
||||
});
|
||||
|
||||
it("404 NOT_FOUND(裝置已刪)→ 回 { ok:false, code:'NOT_FOUND' }", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ success: false, error: { code: "NOT_FOUND", message: "device not found" } },
|
||||
404,
|
||||
),
|
||||
);
|
||||
|
||||
const result = await useDeviceStore.getState().unpairDevice("dev-1");
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "NOT_FOUND" });
|
||||
expect(useDeviceStore.getState().unpairingId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -109,18 +109,6 @@ function normalizeDevice(raw: unknown): Device {
|
||||
/* Store */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* unpair(移除裝置)action 的回傳:成功 / 失敗(帶 i18n code 給 UI 顯示 toast)。
|
||||
*
|
||||
* 與 model-store deleteModel 回傳 boolean 的差異:unpair 是破壞性操作,UI 需要對
|
||||
* 「沒有權限(403)/ 裝置不存在(404)/ 其他」分流顯示不同文案,故回 code 而非 boolean。
|
||||
* code 由 ApiError.code 直接帶出(backend api-spec §11 錯誤碼),UI 以 `devices.remove.error.*`
|
||||
* 對應 i18n、找不到對應 key 時退化到 unknown 文案(對齊 model download 的 toast 慣例)。
|
||||
*/
|
||||
export type UnpairResult =
|
||||
| { ok: true }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
interface DeviceState {
|
||||
devices: DeviceSummary[];
|
||||
selectedDevice: Device | null;
|
||||
@ -128,8 +116,6 @@ interface DeviceState {
|
||||
/** 連線中的裝置 id(UI 顯示 button spinner);不使用就是 null */
|
||||
connectingId: string | null;
|
||||
disconnectingId: string | null;
|
||||
/** 移除(unpair)中的裝置 id(UI 顯示 button spinner / disable 確認鈕);不使用就是 null */
|
||||
unpairingId: string | null;
|
||||
error: string | null;
|
||||
|
||||
/** 呼叫 `GET /api/devices` */
|
||||
@ -140,8 +126,6 @@ interface DeviceState {
|
||||
connectDevice: (id: string) => Promise<boolean>;
|
||||
/** 呼叫 `POST /api/devices/:id/disconnect` */
|
||||
disconnectDevice: (id: string) => Promise<boolean>;
|
||||
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token) */
|
||||
unpairDevice: (id: string) => Promise<UnpairResult>;
|
||||
/** 測試 / 雛形用:直接塞 list */
|
||||
_setDevices: (devices: DeviceSummary[]) => void;
|
||||
/** 測試 / 雛形用:直接塞 selected */
|
||||
@ -154,7 +138,6 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
|
||||
isLoading: false,
|
||||
connectingId: null,
|
||||
disconnectingId: null,
|
||||
unpairingId: null,
|
||||
error: null,
|
||||
|
||||
fetchDevices: async () => {
|
||||
@ -216,30 +199,6 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
|
||||
}
|
||||
},
|
||||
|
||||
unpairDevice: async (id) => {
|
||||
set({ unpairingId: id, error: null });
|
||||
try {
|
||||
// backend 回 envelope { success, data: { id, unpaired: true } };api.post 已 unwrap data。
|
||||
// 這裡不需用回傳值(成功與否由有無 throw 判定),故不取 response。
|
||||
await api.post(`/api/devices/${encodeURIComponent(id)}/unpair`);
|
||||
// 成功後從本地 list 移除該裝置(避免 refetch 延遲);若 selected 是它也清掉。
|
||||
set((state) => ({
|
||||
devices: state.devices.filter((d) => d.id !== id),
|
||||
selectedDevice:
|
||||
state.selectedDevice?.id === id ? null : state.selectedDevice,
|
||||
unpairingId: null,
|
||||
}));
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
// ApiError 帶 backend error code(FORBIDDEN / NOT_FOUND / …)給 UI 分流 toast;
|
||||
// 其他例外(網路層)退化成 unknown。
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const code = err instanceof ApiError ? err.code : "unknown";
|
||||
set({ unpairingId: null, error: message });
|
||||
return { ok: false, code, message };
|
||||
}
|
||||
},
|
||||
|
||||
_setDevices: (devices) => set({ devices }),
|
||||
_setSelected: (selectedDevice) => set({ selectedDevice }),
|
||||
}));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user