/** * 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 { WorkspaceClient } from "./workspace-client"; 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"); }); });