/** * WorkspaceClient 測試(塊 2-review M1:overlay 未就緒不繪製) * * 聚焦 M1:CameraFeed `` 是 height:auto(實際高依 MJPEG 比例),overlay 必須等 * ResizeObserver 回報**真實顯示尺寸**後才繪製;在那之前不可用寫死猜測換算 → 否則 bbox 錯位。 * * 策略: * - 用真實 device-store(setState 灌一台 online 裝置)+ 真實 inference-store * - mock `@/lib/api`(start 回 streamUrl)、mock `@/hooks/use-inference-stream`(no-op) * - 用可控 ResizeObserver:預設「不」觸發 callback(模擬尺寸尚未回報); * 另一測試手動觸發 callback 模擬尺寸就緒 */ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { LocaleProvider } from "@/lib/i18n/context"; import { useDeviceStore } from "@/stores/device-store"; import { useInferenceStore } from "@/stores/inference-store"; // mock api:start 回 streamUrl;stop 直接 resolve const post = vi.fn(); vi.mock("@/lib/api", async () => { const actual = await vi.importActual("@/lib/api"); return { ...actual, api: { ...actual.api, post: (...a: unknown[]) => post(...a) } }; }); // mock WS hook(避免真連線) vi.mock("@/hooks/use-inference-stream", () => ({ useInferenceStream: vi.fn(), })); // 可控 ResizeObserver:把 callback 存起來,測試決定何時觸發 let roCallbacks: ResizeObserverCallback[] = []; class ControllableRO { cb: ResizeObserverCallback; constructor(cb: ResizeObserverCallback) { this.cb = cb; roCallbacks.push(cb); } observe() {} unobserve() {} disconnect() {} } import { useInferenceStream } from "@/hooks/use-inference-stream"; import { WorkspaceClient } from "./workspace-client"; /** 取 useInferenceStream 最後一次呼叫的 enabled 參數(第 2 個引數)。 */ function lastCameraWsEnabled(): boolean | undefined { const mock = vi.mocked(useInferenceStream); const call = mock.mock.calls.at(-1); return call?.[1] as boolean | undefined; } function seedOnlineDevice() { useDeviceStore.setState({ selectedDevice: { id: "dev-1", name: "KL520", remoteStatus: "online", lastSeenAt: null, } as ReturnType["selectedDevice"], isLoading: false, }); } function renderClient() { return render( , ); } beforeEach(() => { post.mockReset(); roCallbacks = []; (globalThis as { ResizeObserver?: unknown }).ResizeObserver = ControllableRO; useInferenceStore.setState({ result: null, results: [], fps: 0, avgLatency: 0, batchResults: {}, confidenceThreshold: 0.5, }); seedOnlineDevice(); }); afterEach(() => { vi.clearAllMocks(); }); describe("WorkspaceClient — overlay 尺寸就緒閘門(M1)", () => { it("start 後、尺寸尚未回報前不繪製 overlay", async () => { post.mockResolvedValue({ streamUrl: "/api/camera/stream", sourceType: "camera" }); // 先放一筆偵測結果(模擬第一筆結果早於 ResizeObserver 回報) useInferenceStore.setState({ result: { taskType: "detection", timestamp: Date.now(), latencyMs: 10, detections: [ { label: "x", confidence: 0.9, bbox: { x: 0, y: 0, width: 0.5, height: 0.5 } }, ], }, }); renderClient(); fireEvent.click(screen.getByText("開始推論")); // CameraFeed 應已顯示(有 img),但 overlay 尚未繪製(feedSize 為 null) await waitFor(() => { expect(screen.getByTestId("camera-feed-img")).toBeInTheDocument(); }); expect(screen.queryByTestId("camera-overlay")).not.toBeInTheDocument(); }); it("ResizeObserver 回報真實尺寸後才繪製 overlay", async () => { post.mockResolvedValue({ streamUrl: "/api/camera/stream", sourceType: "camera" }); renderClient(); fireEvent.click(screen.getByText("開始推論")); await waitFor(() => { expect(screen.getByTestId("camera-feed-img")).toBeInTheDocument(); }); // 尚未回報 → 無 overlay expect(screen.queryByTestId("camera-overlay")).not.toBeInTheDocument(); // 模擬 ResizeObserver 回報真實顯示尺寸(非 4:3,例如 640×360) act(() => { const img = screen.getByTestId("camera-feed-img"); roCallbacks.forEach((cb) => cb( [ { target: img, contentRect: { width: 640, height: 360 } } as unknown as ResizeObserverEntry, ], {} as ResizeObserver, ), ); }); await waitFor(() => { expect(screen.getByTestId("camera-overlay")).toBeInTheDocument(); }); // canvas 尺寸用回報的真實值(360,非寫死 480) const canvas = screen.getByTestId("camera-overlay") as HTMLCanvasElement; expect(canvas.getAttribute("height")).toBe("360"); }); }); /** * 切換 Radix Tab。Radix Tabs 預設 activationMode="automatic"(focus 即切換), * jsdom 下單純 click 不一定觸發 onValueChange,故同時 fire focus + click 確保切換。 */ async function switchTab(name: string) { const trigger = screen.getByRole("tab", { name }); await act(async () => { fireEvent.focus(trigger); fireEvent.click(trigger); }); } describe("WorkspaceClient — 切 tab 時 camera 串流 / WS 正確關閉(S-1,塊3 最高風險路徑)", () => { it("推論中切離 camera tab → 呼叫 /api/camera/stop + camera WS enabled 變 false", async () => { post.mockResolvedValue({ streamUrl: "/api/camera/stream", sourceType: "camera" }); renderClient(); // 開始 camera 推論 fireEvent.click(screen.getByText("開始推論")); await waitFor(() => { expect(screen.getByTestId("camera-feed-img")).toBeInTheDocument(); }); // camera WS 此時應為 enabled=true(camera tab + 推論中 + 線上) expect(lastCameraWsEnabled()).toBe(true); // 尚未呼叫 stop expect(post).not.toHaveBeenCalledWith("/api/camera/stop", expect.anything()); // 切到 image tab await switchTab("圖片"); // handleTabChange 應自動停掉 camera(POST /api/camera/stop) await waitFor(() => { expect(post).toHaveBeenCalledWith("/api/camera/stop", { deviceId: "dev-1" }); }); // camera 串流已清(isRunning=false)→ camera 的 useInferenceStream enabled 變 false(不殘留連線) await waitFor(() => { expect(lastCameraWsEnabled()).toBe(false); }); // camera feed 已不在畫面(切到 image tab,且串流已清) expect(screen.queryByTestId("camera-feed-img")).not.toBeInTheDocument(); }); it("未推論時切 tab → 不呼叫 stop(無殘留可清)", async () => { renderClient(); // 沒按開始,直接切到 video tab await switchTab("影片"); expect(post).not.toHaveBeenCalledWith("/api/camera/stop", expect.anything()); // camera WS 一直是 disabled expect(lastCameraWsEnabled()).toBe(false); }); it("切到 image tab 後 camera WS 保持 false(媒體 tab 有自己的 WS,兩者互斥)", async () => { post.mockResolvedValue({ streamUrl: "/api/camera/stream", sourceType: "camera" }); renderClient(); fireEvent.click(screen.getByText("開始推論")); await waitFor(() => expect(screen.getByTestId("camera-feed-img")).toBeInTheDocument()); await switchTab("圖片"); await waitFor(() => expect(lastCameraWsEnabled()).toBe(false)); // 停留在 image tab,camera WS 不應又被打開 expect(lastCameraWsEnabled()).toBe(false); }); });