Compare commits
3 Commits
838d10b084
...
7d8ad4857e
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d8ad4857e | |||
| 6de7c1b4a3 | |||
| 0901ffafda |
@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* 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,13 +19,27 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AlertTriangle, ArrowLeft } from "lucide-react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AlertTriangle, ArrowLeft, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@ -40,14 +54,32 @@ interface DeviceDetailClientProps {
|
|||||||
|
|
||||||
export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const router = useRouter();
|
||||||
const selectedDevice = useDeviceStore((s) => s.selectedDevice);
|
const selectedDevice = useDeviceStore((s) => s.selectedDevice);
|
||||||
const isLoading = useDeviceStore((s) => s.isLoading);
|
const isLoading = useDeviceStore((s) => s.isLoading);
|
||||||
const fetchDevice = useDeviceStore((s) => s.fetchDevice);
|
const fetchDevice = useDeviceStore((s) => s.fetchDevice);
|
||||||
|
const unpairDevice = useDeviceStore((s) => s.unpairDevice);
|
||||||
|
const isUnpairing = useDeviceStore((s) => s.unpairingId === id);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) void fetchDevice(id);
|
if (id) void fetchDevice(id);
|
||||||
}, [id, fetchDevice]);
|
}, [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) {
|
if (isLoading && !selectedDevice) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-4 px-6 py-8">
|
<div className="mx-auto max-w-5xl space-y-4 px-6 py-8">
|
||||||
@ -128,7 +160,7 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
|||||||
errorMessage={selectedDevice.errorMessage ?? null}
|
errorMessage={selectedDevice.errorMessage ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isOnline && selectedDevice.flashedModel && (
|
{isOnline && selectedDevice.flashedModel && (
|
||||||
<Link href={`/workspace/${selectedDevice.id}`}>
|
<Link href={`/workspace/${selectedDevice.id}`}>
|
||||||
<Button>{t("devices.openWorkspace")}</Button>
|
<Button>{t("devices.openWorkspace")}</Button>
|
||||||
@ -146,6 +178,58 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
214
visionA-frontend/src/app/models/page.test.tsx
Normal file
214
visionA-frontend/src/app/models/page.test.tsx
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* /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,6 +4,11 @@
|
|||||||
* 模型庫 — /models
|
* 模型庫 — /models
|
||||||
*
|
*
|
||||||
* 對齊 pages.md §8.1、flow-model-upload.md §4.1。
|
* 對齊 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";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@ -12,10 +17,45 @@ import {
|
|||||||
ModelFilters,
|
ModelFilters,
|
||||||
type ModelFilterValue,
|
type ModelFilterValue,
|
||||||
} from "@/components/models/model-filters";
|
} from "@/components/models/model-filters";
|
||||||
import { ModelGrid } from "@/components/models/model-grid";
|
import { ModelSection } from "@/components/models/model-section";
|
||||||
import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
|
import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
|
||||||
import { useT } from "@/lib/i18n/context";
|
import { useT } from "@/lib/i18n/context";
|
||||||
import { useModelStore } from "@/stores/model-store";
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ModelsPage() {
|
export default function ModelsPage() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
@ -24,21 +64,17 @@ export default function ModelsPage() {
|
|||||||
const fetchModels = useModelStore((s) => s.fetchModels);
|
const fetchModels = useModelStore((s) => s.fetchModels);
|
||||||
const [filter, setFilter] = useState<ModelFilterValue>({
|
const [filter, setFilter] = useState<ModelFilterValue>({
|
||||||
targetChip: "all",
|
targetChip: "all",
|
||||||
source: "all",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchModels();
|
void fetchModels();
|
||||||
}, [fetchModels]);
|
}, [fetchModels]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
// 先套 targetChip 篩選(跨三區作用),再依 source 分三組。
|
||||||
return models.filter((m) => {
|
const grouped = useMemo(
|
||||||
if (filter.targetChip !== "all" && m.targetChip !== filter.targetChip)
|
() => groupModelsBySource(models, filter.targetChip),
|
||||||
return false;
|
[models, filter],
|
||||||
if (filter.source !== "all" && m.source !== filter.source) return false;
|
);
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [models, filter]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8">
|
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8">
|
||||||
@ -50,7 +86,18 @@ export default function ModelsPage() {
|
|||||||
<ModelUploadDialog />
|
<ModelUploadDialog />
|
||||||
</div>
|
</div>
|
||||||
<ModelFilters value={filter} onChange={setFilter} />
|
<ModelFilters value={filter} onChange={setFilter} />
|
||||||
<ModelGrid models={filtered} loading={isLoading} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 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,6 +21,7 @@ import { useEffect, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Circle,
|
||||||
Link2,
|
Link2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Trash2,
|
Trash2,
|
||||||
@ -104,8 +105,11 @@ export function ActivityTimeline() {
|
|||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3" data-testid="activity-list">
|
<ul className="space-y-3" data-testid="activity-list">
|
||||||
{activities.map((activity) => {
|
{activities.map((activity) => {
|
||||||
const Icon = activityIcons[activity.type];
|
// F7/F8 接 WS 事件後,後端可能推入 activityIcons/activityColors 尚未
|
||||||
const color = activityColors[activity.type];
|
// 涵蓋的未知 type → map lookup 回 undefined。fallback 到中性 icon/色,
|
||||||
|
// 避免 <Icon /> render undefined 而 crash。
|
||||||
|
const Icon = activityIcons[activity.type] ?? Circle;
|
||||||
|
const color = activityColors[activity.type] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
<li key={activity.id} className="flex items-start gap-3">
|
<li key={activity.id} className="flex items-start gap-3">
|
||||||
<Icon
|
<Icon
|
||||||
|
|||||||
@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 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,10 +26,16 @@ import { useDeviceStore } from "@/stores/device-store";
|
|||||||
|
|
||||||
export function ConnectedDevicesList() {
|
export function ConnectedDevicesList() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
// 雲端版語意:列「線上」裝置(remoteStatus=online),不是 USB 連接
|
// 雲端版語意:列「線上」裝置(remoteStatus=online),不是 USB 連接。
|
||||||
const devices = useDeviceStore((s) =>
|
//
|
||||||
s.devices.filter((d) => d.remoteStatus === "online"),
|
// 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");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@ -161,3 +161,45 @@ describe("ModelCard 下載互動", () => {
|
|||||||
expect(btn).toHaveTextContent("下載中");
|
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,6 +28,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import {
|
import {
|
||||||
isModelDownloadable,
|
isModelDownloadable,
|
||||||
useModelStore,
|
useModelStore,
|
||||||
|
type ModelSource,
|
||||||
type ModelStatus,
|
type ModelStatus,
|
||||||
type ModelSummary,
|
type ModelSummary,
|
||||||
} from "@/stores/model-store";
|
} from "@/stores/model-store";
|
||||||
@ -44,6 +45,29 @@ const STATUS_VARIANT: Record<ModelStatus, { variant: "default" | "secondary" | "
|
|||||||
rejected: { variant: "destructive", key: "models.status.rejected" },
|
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 {
|
function formatFileSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
@ -54,6 +78,7 @@ function formatFileSize(bytes: number): string {
|
|||||||
export function ModelCard({ model }: ModelCardProps) {
|
export function ModelCard({ model }: ModelCardProps) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const statusMeta = STATUS_VARIANT[model.status];
|
const statusMeta = STATUS_VARIANT[model.status];
|
||||||
|
const sourceMeta = SOURCE_BADGE[model.source];
|
||||||
|
|
||||||
const downloadModel = useModelStore((s) => s.downloadModel);
|
const downloadModel = useModelStore((s) => s.downloadModel);
|
||||||
// per-card loading:只在「下載中的是這張卡」時顯示 spinner。
|
// per-card loading:只在「下載中的是這張卡」時顯示 spinner。
|
||||||
@ -106,14 +131,14 @@ export function ModelCard({ model }: ModelCardProps) {
|
|||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{model.targetChip.toUpperCase()}
|
{model.targetChip.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
{model.source === "preset" && (
|
{sourceMeta && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge
|
||||||
{t("models.source.preset")}
|
variant="outline"
|
||||||
</Badge>
|
className={cn("text-xs", sourceMeta.className)}
|
||||||
)}
|
data-testid="model-source-badge"
|
||||||
{model.source === "converted" && (
|
data-source={model.source}
|
||||||
<Badge variant="secondary" className="text-xs">
|
>
|
||||||
{t("models.source.converted")}
|
{t(sourceMeta.key)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{model.category && (
|
{model.category && (
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
/**
|
/**
|
||||||
* ModelFilters — 模型列表篩選器(雛形簡化版)
|
* ModelFilters — 模型列表篩選器(雛形簡化版)
|
||||||
*
|
*
|
||||||
* 對齊 api-spec §4 — 提供 targetChip + source 兩個常用篩選。
|
* B2 起 /models 頁改成「按來源分三區」,原本的 source 篩選與分區重複、已移除。
|
||||||
|
* 此處只保留 targetChip 篩選,且它會「跨三區作用」(選 KL520 時三區都只顯示 KL520)。
|
||||||
* Phase 1 會擴充成搜尋、分類、標籤等(design-review 缺失項:Search)。
|
* Phase 1 會擴充成搜尋、分類、標籤等(design-review 缺失項:Search)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -17,11 +18,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useT } from "@/lib/i18n/context";
|
import { useT } from "@/lib/i18n/context";
|
||||||
import type { ModelSource, TargetChip } from "@/stores/model-store";
|
import type { TargetChip } from "@/stores/model-store";
|
||||||
|
|
||||||
export interface ModelFilterValue {
|
export interface ModelFilterValue {
|
||||||
targetChip: TargetChip | "all";
|
targetChip: TargetChip | "all";
|
||||||
source: ModelSource | "all";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelFiltersProps {
|
interface ModelFiltersProps {
|
||||||
@ -60,22 +60,6 @@ export function ModelFilters({ value, onChange }: ModelFiltersProps) {
|
|||||||
<SelectItem value="kl730">KL730</SelectItem>
|
<SelectItem value="kl730">KL730</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,9 +23,19 @@ interface ModelGridProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
/** 空狀態按下 CTA 觸發(通常跳到上傳 dialog) */
|
/** 空狀態按下 CTA 觸發(通常跳到上傳 dialog) */
|
||||||
onUploadClick?: () => void;
|
onUploadClick?: () => void;
|
||||||
|
/**
|
||||||
|
* 區塊內空狀態文案(給「按來源分區」用)。提供時,models 為空只顯示一行精簡提示
|
||||||
|
* (配合區標題),不顯示整頁式的大型 EmptyState + CTA。未提供時維持原本大型 EmptyState。
|
||||||
|
*/
|
||||||
|
emptyText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelGrid({ models, loading, onUploadClick }: ModelGridProps) {
|
export function ModelGrid({
|
||||||
|
models,
|
||||||
|
loading,
|
||||||
|
onUploadClick,
|
||||||
|
emptyText,
|
||||||
|
}: ModelGridProps) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@ -42,6 +52,17 @@ export function ModelGrid({ models, loading, onUploadClick }: ModelGridProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (models.length === 0) {
|
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 (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Boxes}
|
icon={Boxes}
|
||||||
|
|||||||
@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
visionA-frontend/src/components/models/model-section.tsx
Normal file
65
visionA-frontend/src/components/models/model-section.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"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,6 +159,19 @@ export const en: Dictionary = {
|
|||||||
"devices.status.error": "Error",
|
"devices.status.error": "Error",
|
||||||
"devices.status.disconnected": "Disconnected",
|
"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 Device Badge ──
|
||||||
"remote.status.online": "Online",
|
"remote.status.online": "Online",
|
||||||
"remote.status.offline": "Offline",
|
"remote.status.offline": "Offline",
|
||||||
@ -184,12 +197,17 @@ export const en: Dictionary = {
|
|||||||
"models.source.converted": "Converted",
|
"models.source.converted": "Converted",
|
||||||
"models.filters.label": "Model filters",
|
"models.filters.label": "Model filters",
|
||||||
"models.filters.hardware": "Hardware",
|
"models.filters.hardware": "Hardware",
|
||||||
"models.filters.source": "Source",
|
|
||||||
"models.filters.all": "All",
|
"models.filters.all": "All",
|
||||||
"models.empty.title": "No models yet",
|
"models.empty.title": "No models yet",
|
||||||
"models.empty.description":
|
"models.empty.description":
|
||||||
"Upload your first .nef model to deploy it to any paired Kneron device.",
|
"Upload your first .nef model to deploy it to any paired Kneron device.",
|
||||||
"models.empty.action": "Upload your first model",
|
"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.description": "Description",
|
||||||
"models.detail.version": "Version",
|
"models.detail.version": "Version",
|
||||||
"models.detail.checksum": "Checksum",
|
"models.detail.checksum": "Checksum",
|
||||||
|
|||||||
@ -160,6 +160,19 @@ export const zhHant: Dictionary = {
|
|||||||
"devices.status.error": "錯誤",
|
"devices.status.error": "錯誤",
|
||||||
"devices.status.disconnected": "未連接",
|
"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 Device Badge(雲端 tunnel 狀態) ──
|
||||||
"remote.status.online": "在線",
|
"remote.status.online": "在線",
|
||||||
"remote.status.offline": "離線",
|
"remote.status.offline": "離線",
|
||||||
@ -185,12 +198,17 @@ export const zhHant: Dictionary = {
|
|||||||
"models.source.converted": "已轉檔",
|
"models.source.converted": "已轉檔",
|
||||||
"models.filters.label": "模型篩選",
|
"models.filters.label": "模型篩選",
|
||||||
"models.filters.hardware": "硬體",
|
"models.filters.hardware": "硬體",
|
||||||
"models.filters.source": "來源",
|
|
||||||
"models.filters.all": "全部",
|
"models.filters.all": "全部",
|
||||||
"models.empty.title": "還沒有任何模型",
|
"models.empty.title": "還沒有任何模型",
|
||||||
"models.empty.description":
|
"models.empty.description":
|
||||||
"上傳你的第一個 .nef 模型到雲端,就能部署到任何一台配對過的 Kneron 裝置",
|
"上傳你的第一個 .nef 模型到雲端,就能部署到任何一台配對過的 Kneron 裝置",
|
||||||
"models.empty.action": "上傳第一個模型",
|
"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.description": "說明",
|
||||||
"models.detail.version": "版本",
|
"models.detail.version": "版本",
|
||||||
"models.detail.checksum": "校驗碼",
|
"models.detail.checksum": "校驗碼",
|
||||||
|
|||||||
@ -22,6 +22,7 @@ beforeEach(() => {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
connectingId: null,
|
connectingId: null,
|
||||||
disconnectingId: null,
|
disconnectingId: null,
|
||||||
|
unpairingId: null,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
// OF2:api.ts 不再需要 token getter(cookie session 由瀏覽器自動帶)
|
// OF2:api.ts 不再需要 token getter(cookie session 由瀏覽器自動帶)
|
||||||
@ -163,3 +164,97 @@ describe("useDeviceStore", () => {
|
|||||||
expect(err.code).toBe("INTERNAL_ERROR");
|
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,6 +109,18 @@ function normalizeDevice(raw: unknown): Device {
|
|||||||
/* Store */
|
/* 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 {
|
interface DeviceState {
|
||||||
devices: DeviceSummary[];
|
devices: DeviceSummary[];
|
||||||
selectedDevice: Device | null;
|
selectedDevice: Device | null;
|
||||||
@ -116,6 +128,8 @@ interface DeviceState {
|
|||||||
/** 連線中的裝置 id(UI 顯示 button spinner);不使用就是 null */
|
/** 連線中的裝置 id(UI 顯示 button spinner);不使用就是 null */
|
||||||
connectingId: string | null;
|
connectingId: string | null;
|
||||||
disconnectingId: string | null;
|
disconnectingId: string | null;
|
||||||
|
/** 移除(unpair)中的裝置 id(UI 顯示 button spinner / disable 確認鈕);不使用就是 null */
|
||||||
|
unpairingId: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
/** 呼叫 `GET /api/devices` */
|
/** 呼叫 `GET /api/devices` */
|
||||||
@ -126,6 +140,8 @@ interface DeviceState {
|
|||||||
connectDevice: (id: string) => Promise<boolean>;
|
connectDevice: (id: string) => Promise<boolean>;
|
||||||
/** 呼叫 `POST /api/devices/:id/disconnect` */
|
/** 呼叫 `POST /api/devices/:id/disconnect` */
|
||||||
disconnectDevice: (id: string) => Promise<boolean>;
|
disconnectDevice: (id: string) => Promise<boolean>;
|
||||||
|
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token) */
|
||||||
|
unpairDevice: (id: string) => Promise<UnpairResult>;
|
||||||
/** 測試 / 雛形用:直接塞 list */
|
/** 測試 / 雛形用:直接塞 list */
|
||||||
_setDevices: (devices: DeviceSummary[]) => void;
|
_setDevices: (devices: DeviceSummary[]) => void;
|
||||||
/** 測試 / 雛形用:直接塞 selected */
|
/** 測試 / 雛形用:直接塞 selected */
|
||||||
@ -138,6 +154,7 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
connectingId: null,
|
connectingId: null,
|
||||||
disconnectingId: null,
|
disconnectingId: null,
|
||||||
|
unpairingId: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchDevices: async () => {
|
fetchDevices: async () => {
|
||||||
@ -199,6 +216,30 @@ 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 }),
|
_setDevices: (devices) => set({ devices }),
|
||||||
_setSelected: (selectedDevice) => set({ selectedDevice }),
|
_setSelected: (selectedDevice) => set({ selectedDevice }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user