diff --git a/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx b/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx new file mode 100644 index 0000000..7c261f6 --- /dev/null +++ b/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx @@ -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( + + + , + ); +} + +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("移除中"); + }); +}); diff --git a/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx b/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx index d042b2e..7e2ba15 100644 --- a/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx +++ b/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx @@ -19,13 +19,27 @@ import { useEffect } from "react"; 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 { + 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, @@ -40,14 +54,32 @@ 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 (
@@ -128,7 +160,7 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) { errorMessage={selectedDevice.errorMessage ?? null} />
-
+
{isOnline && selectedDevice.flashedModel && ( @@ -146,6 +178,58 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) { )} + + {/* 移除裝置(破壞性操作):destructive 樣式 + 二次確認對話框。 + 對齊 model 詳細頁刪除 UX(AlertDialog / loading 態 / 成功導頁 / 失敗 toast)。 + 不論裝置在線與否都可移除——移除是帳號層級的解除配對、與即時連線無關。 */} + + + + + + + + {t("devices.remove.confirm.title")} + + + {t("devices.remove.confirm.description").replace( + "{name}", + displayName, + )} + + + + + {t("common.cancel")} + + + {t("devices.remove.confirm.action")} + + + +
diff --git a/visionA-frontend/src/stores/device-store.test.ts b/visionA-frontend/src/stores/device-store.test.ts index 4af72fd..4496b7d 100644 --- a/visionA-frontend/src/stores/device-store.test.ts +++ b/visionA-frontend/src/stores/device-store.test.ts @@ -22,6 +22,7 @@ beforeEach(() => { isLoading: false, connectingId: null, disconnectingId: null, + unpairingId: null, error: null, }); // OF2:api.ts 不再需要 token getter(cookie session 由瀏覽器自動帶) @@ -163,3 +164,97 @@ 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(); + }); +}); diff --git a/visionA-frontend/src/stores/device-store.ts b/visionA-frontend/src/stores/device-store.ts index 841fa54..5d2c380 100644 --- a/visionA-frontend/src/stores/device-store.ts +++ b/visionA-frontend/src/stores/device-store.ts @@ -109,6 +109,18 @@ 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; @@ -116,6 +128,8 @@ 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` */ @@ -126,6 +140,8 @@ interface DeviceState { connectDevice: (id: string) => Promise; /** 呼叫 `POST /api/devices/:id/disconnect` */ disconnectDevice: (id: string) => Promise; + /** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token) */ + unpairDevice: (id: string) => Promise; /** 測試 / 雛形用:直接塞 list */ _setDevices: (devices: DeviceSummary[]) => void; /** 測試 / 雛形用:直接塞 selected */ @@ -138,6 +154,7 @@ export const useDeviceStore = create()((set) => ({ isLoading: false, connectingId: null, disconnectingId: null, + unpairingId: null, error: null, fetchDevices: async () => { @@ -199,6 +216,30 @@ export const useDeviceStore = create()((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 }), }));