/** * 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(), })); // ADR-019 WP-4:mock 影片 localhost 直連編排(單元隔離;local-media 自身有 local-media.test.ts)。 // 保留真實 LocalMediaError 供錯誤分支測試。 const uploadVideoViaLocalAgent = vi.fn(); vi.mock("@/lib/local-media", async () => { const actual = await vi.importActual("@/lib/local-media"); return { ...actual, uploadVideoViaLocalAgent: (...a: unknown[]) => uploadVideoViaLocalAgent(...a), }; }); // 可控 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 { LocalMediaError } from "@/lib/local-media"; 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, // WP-C(ADR-018):camera/media/WS 操作識別值 = serialNumber serialNumber: "KN00123456", } as ReturnType["selectedDevice"], isLoading: false, }); } /** WP-C:seed 一台線上但「未回報序號」的裝置(舊資料 / 假序號 → null)。 */ function seedOnlineDeviceWithoutSerial() { useDeviceStore.setState({ selectedDevice: { id: "dev-1", name: "KL520", remoteStatus: "online", lastSeenAt: null, serialNumber: null, } as ReturnType["selectedDevice"], isLoading: false, }); } function renderClient() { return render( , ); } beforeEach(() => { post.mockReset(); uploadVideoViaLocalAgent.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) // WP-C(ADR-018):body 的 deviceId 欄位帶 serialNumber(非雲端 UUID) await waitFor(() => { expect(post).toHaveBeenCalledWith("/api/camera/stop", { deviceId: "KN00123456", }); }); // 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("開始推論按鈕只在 camera tab 顯示,切到 image/video/batch 隱藏", async () => { renderClient(); // camera tab(預設):按鈕在 expect(screen.getByText("開始推論")).toBeInTheDocument(); // 切到影片:按鈕隱藏(上傳即自動推論,不需此按鈕) await switchTab("影片"); expect(screen.queryByText("開始推論")).not.toBeInTheDocument(); // 切回 camera:按鈕重新出現 await switchTab("Camera"); expect(screen.getByText("開始推論")).toBeInTheDocument(); }); 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); }); }); describe("WorkspaceClient — serial 路由(WP-C / ADR-018)", () => { it("camera start 的 body deviceId 帶 serialNumber、camera WS 識別值也是 serialNumber", async () => { post.mockResolvedValue({ streamUrl: "/api/camera/stream", sourceType: "camera" }); renderClient(); fireEvent.click(screen.getByText("開始推論")); await waitFor(() => { expect(post).toHaveBeenCalledWith("/api/camera/start", { deviceId: "KN00123456", }); }); // camera WS 的第一個參數(識別值)= serial,且 enabled=true const wsCall = vi.mocked(useInferenceStream).mock.calls.at(-1); expect(wsCall?.[0]).toBe("KN00123456"); expect(wsCall?.[1]).toBe(true); }); it("serial 為空:開始推論 disabled + 顯示 no-serial banner、不打 camera start", () => { seedOnlineDeviceWithoutSerial(); renderClient(); const startBtn = screen.getByText("開始推論").closest("button"); expect(startBtn).toBeDisabled(); expect(screen.getByTestId("workspace-no-serial-banner")).toBeInTheDocument(); fireEvent.click(screen.getByText("開始推論")); expect(post).not.toHaveBeenCalled(); }); it("serial 為空:media tab 顯示 no-serial 提示、不出現上傳區", async () => { seedOnlineDeviceWithoutSerial(); renderClient(); await switchTab("圖片"); expect(screen.getByTestId("media-no-serial")).toBeInTheDocument(); expect(screen.queryByTestId("media-uploader-image")).not.toBeInTheDocument(); }); it("serial 有值:media tab 正常顯示上傳區", async () => { renderClient(); await switchTab("圖片"); expect(screen.getByTestId("media-uploader-image")).toBeInTheDocument(); expect(screen.queryByTestId("media-no-serial")).not.toBeInTheDocument(); }); }); /** * ADR-019 WP-4:影片分頁走 localhost 直連 local-agent(非 tunnel)。 * 這裡驗證接線點——影片上傳呼叫 uploadVideoViaLocalAgent(帶 serial)、 * tunnel 離線時停用上傳、錯誤碼轉 friendly i18n。 */ describe("WorkspaceClient — 影片 localhost 直連(WP-4)", () => { /** 在影片分頁的 dropzone 選檔(觸發上傳流程)。 */ function selectVideo(file: File) { const input = document.querySelector( '[data-testid="media-uploader-video"] input[type=file]', ) as HTMLInputElement; Object.defineProperty(input, "files", { value: [file], configurable: true }); fireEvent.change(input); } it("影片上傳走 uploadVideoViaLocalAgent(帶 serial),不走雲端 uploadVideo", async () => { uploadVideoViaLocalAgent.mockResolvedValue({ streamUrl: "/api/camera/stream", sourceType: "video", totalFrames: 100, }); renderClient(); await switchTab("影片"); // 線上 + 有 serial → 顯示 uploader(非離線提示) expect(screen.getByTestId("media-uploader-video")).toBeInTheDocument(); selectVideo(new File([new Blob(["x"])], "clip.mp4")); await waitFor(() => { expect(uploadVideoViaLocalAgent).toHaveBeenCalled(); }); // 第一個參數是 serial、第二個是 File const [serialArg, fileArg] = uploadVideoViaLocalAgent.mock.calls[0]!; expect(serialArg).toBe("KN00123456"); expect(fileArg).toBeInstanceOf(File); }); it("tunnel 離線(remoteStatus != online)→ 影片分頁停用上傳 + 顯示離線提示", async () => { useDeviceStore.setState({ selectedDevice: { id: "dev-1", name: "KL520", remoteStatus: "offline", lastSeenAt: null, serialNumber: "KN00123456", } as ReturnType["selectedDevice"], isLoading: false, }); renderClient(); // 離線時整頁會有 offline 遮罩,但分頁內容仍 render;切到影片分頁驗證停用提示 await switchTab("影片"); expect( screen.getByTestId("media-upload-disabled-video"), ).toBeInTheDocument(); // 不出現 uploader expect(screen.queryByTestId("media-uploader-video")).not.toBeInTheDocument(); }); it("NOT_FOUND → 顯示「需同機操作」friendly 訊息(非 raw code)", async () => { uploadVideoViaLocalAgent.mockRejectedValue( new LocalMediaError("LOCAL_AGENT_NOT_FOUND"), ); renderClient(); await switchTab("影片"); selectVideo(new File([new Blob(["x"])], "clip.mp4")); await waitFor(() => { const err = screen.getByTestId("media-uploader-video-error"); expect(err.textContent).toContain("同一台電腦"); // 不可洩漏 raw code expect(err.textContent).not.toContain("LOCAL_AGENT_NOT_FOUND"); }); }); it("MISMATCH → 顯示「Agent 與裝置不符」friendly 訊息", async () => { uploadVideoViaLocalAgent.mockRejectedValue( new LocalMediaError("LOCAL_AGENT_MISMATCH"), ); renderClient(); await switchTab("影片"); selectVideo(new File([new Blob(["x"])], "clip.mp4")); await waitFor(() => { expect( screen.getByTestId("media-uploader-video-error").textContent, ).toContain("不符"); }); }); });