visionA/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx
jim800121chen 0010cc35c3 feat(workspace): canvas overlay 疊 bbox + WS 即時推論面板(前端塊2)
推論工作區前端塊2:canvas overlay 疊在 MJPEG <img> 上、接 WS
inference:<deviceId> 推的 raw InferenceResult、即時繪 bbox+label+信心度;
右側 Inference panel 從 Phase 1 佔位換成 WS 即時結果清單 + FPS/延遲。
照 edge-ai-platform POC 移植(唯讀參考)。

- camera-overlay.tsx:normalized bbox→canvas 像素換算、label 框
- inference-panel.tsx:指標 + 清單 + aria-live 無障礙
- inference-store.ts:fps/avgLatency、MAX_RESULTS=100 上限
- use-inference-stream.ts:用既有 useWebSocket(same-origin cookie、無 token URL)
- workspace-client:overlay 塞 CameraFeed slot、訂閱 WS(僅 isRunning && isOnline)

canvas 顏色讀設計 token(--chart-*/--background,getComputedStyle 快取、
主題切換才重讀)、跟隨深淺主題、不裸色值。

Reviewer 2 輪通過(0C/0M)。修正:
- M1 overlay 座標錯位:feedSize 初值 null、未拿真實顯示尺寸不繪(MJPEG 非
  4:3 不錯位)
- M2 效能 jank:palette 快取 useState + MutationObserver 主題切換才重讀
  (不再每 frame getComputedStyle)
- m2 label fallback 色改實際色(canvas 不認 currentColor)
- m3 isValidBBox guard:壞 payload 略過不 crash

+6 新測試(M1 640×360→height=360、M2 呼叫數不增、m3 NaN/undefined 略過)。
tsc/eslint clean、塊2 範圍 37 test 綠。(全套 11 failed 全屬 conversion-store
既有 time-based flaky、未觸碰)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 05:03:35 +08:00

143 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* WorkspaceClient 測試(塊 2-review M1overlay 未就緒不繪製)
*
* 聚焦 M1CameraFeed `<img>` 是 height:auto實際高依 MJPEG 比例overlay 必須等
* ResizeObserver 回報**真實顯示尺寸**後才繪製;在那之前不可用寫死猜測換算 → 否則 bbox 錯位。
*
* 策略:
* - 用真實 device-storesetState 灌一台 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 apistart 回 streamUrlstop 直接 resolve
const post = vi.fn();
vi.mock("@/lib/api", async () => {
const actual = await vi.importActual<typeof import("@/lib/api")>("@/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<typeof useDeviceStore.getState>["selectedDevice"],
isLoading: false,
});
}
function renderClient() {
return render(
<LocaleProvider>
<WorkspaceClient deviceId="dev-1" />
</LocaleProvider>,
);
}
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");
});
});