diff --git a/visionA-frontend/src/components/dashboard/activity-timeline.test.tsx b/visionA-frontend/src/components/dashboard/activity-timeline.test.tsx
new file mode 100644
index 0000000..53a527c
--- /dev/null
+++ b/visionA-frontend/src/components/dashboard/activity-timeline.test.tsx
@@ -0,0 +1,75 @@
+/**
+ * ActivityTimeline 測試
+ *
+ * 回歸重點:
+ * F7/F8 接 WS 事件後,後端可能推入 activityIcons/activityColors map 尚未涵蓋的
+ * 未知 type → lookup 回 undefined → 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(
+
+
+ ,
+ );
+}
+
+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();
+ });
+});
diff --git a/visionA-frontend/src/components/dashboard/activity-timeline.tsx b/visionA-frontend/src/components/dashboard/activity-timeline.tsx
index 9ef7e95..35e9bc8 100644
--- a/visionA-frontend/src/components/dashboard/activity-timeline.tsx
+++ b/visionA-frontend/src/components/dashboard/activity-timeline.tsx
@@ -21,6 +21,7 @@ import { useEffect, useState } from "react";
import {
AlertTriangle,
CheckCircle,
+ Circle,
Link2,
RefreshCw,
Trash2,
@@ -104,8 +105,11 @@ export function ActivityTimeline() {
) : (
{activities.map((activity) => {
- const Icon = activityIcons[activity.type];
- const color = activityColors[activity.type];
+ // F7/F8 接 WS 事件後,後端可能推入 activityIcons/activityColors 尚未
+ // 涵蓋的未知 type → map lookup 回 undefined。fallback 到中性 icon/色,
+ // 避免 render undefined 而 crash。
+ const Icon = activityIcons[activity.type] ?? Circle;
+ const color = activityColors[activity.type] ?? "text-muted-foreground";
return (
-
內部需要)
+ */
+
+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 {
+ return {
+ id: "d1",
+ name: "Device 1",
+ type: "kneron",
+ status: "connected",
+ remoteStatus: "online",
+ lastSeenAt: null,
+ ...over,
+ };
+}
+
+function renderList() {
+ return render(
+
+
+ ,
+ );
+}
+
+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();
+ });
+});
diff --git a/visionA-frontend/src/components/dashboard/connected-devices-list.tsx b/visionA-frontend/src/components/dashboard/connected-devices-list.tsx
index 7748fbb..a7984fc 100644
--- a/visionA-frontend/src/components/dashboard/connected-devices-list.tsx
+++ b/visionA-frontend/src/components/dashboard/connected-devices-list.tsx
@@ -26,10 +26,16 @@ import { useDeviceStore } from "@/stores/device-store";
export function ConnectedDevicesList() {
const t = useT();
- // 雲端版語意:列「線上」裝置(remoteStatus=online),不是 USB 連接
- const devices = useDeviceStore((s) =>
- s.devices.filter((d) => d.remoteStatus === "online"),
- );
+ // 雲端版語意:列「線上」裝置(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");
return (