visionA/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx
jim800121chen 12f5bf6c47 feat(frontend): WP-C serial 路由落地(ADR-018 FE-A 混合模型)
- DeviceSummary 加 serialNumber(serial_number/serialNumber 雙 key 容錯、
  缺欄/空字串 → null)
- 五個 proxy 操作識別值換 serial:connect/disconnect、flash POST+進度 WS
  (單一派生點保 room key 一致)、inference WS、camera start/stop+WS、
  media 三 upload
- serial 為空全面 disable + 提示(workspace banner/media 占位/flash-dialog/
  device-card tooltip/device-detail 兩鈕+序號 InfoRow/選擇頁);i18n 兩語系
- UUID 組維持:fetchDevices/fetchDevice/unpair/詳情頁路由
- C5 偏差(路由段維持 UUID):與 FE-A 定案一致、reviewer 獨立驗證成立
  (API 層無 GetBySerial、帶 serial deep-link 必 404);文件回填另派
- evidence:tsc 0 / eslint 0 / next build 15 route / 觸及 11 測試檔 99 passed
- review:通過 0C/0M/3Mi/2Sug(.autoflow/05-implementation/review/
  wp-c-frontend-serial-routing-review.md)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:58:52 +08:00

283 lines
9.9 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 { useInferenceStream } from "@/hooks/use-inference-stream";
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-CADR-018camera/media/WS 操作識別值 = serialNumber
serialNumber: "KN00123456",
} as ReturnType<typeof useDeviceStore.getState>["selectedDevice"],
isLoading: false,
});
}
/** WP-Cseed 一台線上但「未回報序號」的裝置(舊資料 / 假序號 → null。 */
function seedOnlineDeviceWithoutSerial() {
useDeviceStore.setState({
selectedDevice: {
id: "dev-1",
name: "KL520",
remoteStatus: "online",
lastSeenAt: null,
serialNumber: 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");
});
});
/**
* 切換 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=truecamera tab + 推論中 + 線上)
expect(lastCameraWsEnabled()).toBe(true);
// 尚未呼叫 stop
expect(post).not.toHaveBeenCalledWith("/api/camera/stop", expect.anything());
// 切到 image tab
await switchTab("圖片");
// handleTabChange 應自動停掉 cameraPOST /api/camera/stop
// WP-CADR-018body 的 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("切到 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 tabcamera 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();
});
});