推論工作區前端塊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>
143 lines
4.6 KiB
TypeScript
143 lines
4.6 KiB
TypeScript
/**
|
||
* WorkspaceClient 測試(塊 2-review M1:overlay 未就緒不繪製)
|
||
*
|
||
* 聚焦 M1:CameraFeed `<img>` 是 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<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");
|
||
});
|
||
});
|