/**
* 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");
});
});