visionA/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx
jim800121chen 051994ed54 feat(workspace): 圖片/影片/批次推論 tab + 載入模型到裝置 FlashDialog(前端)
推論工作區前端塔3 + flash 前端(i18n/device-detail 交纏、一批 commit)。

塔3(圖片/影片/批次 tab、複用塊1/2 顯示管線 0 重造):
- media.ts 上傳 helper(XHR image/video/batch)+ media-uploader + media-tab
- workspace-client 3 tab 打開 + activeTab 互斥 camera/media WS
- video seek 用後端 durationSeconds 換算(脫離 fps 耦合、Reviewer M-1 修正)
- 補 tab 切換 WS reset 測試(S-1)
- Reviewer 2 輪通過

flash 前端(載入模型到裝置、補「進工作區前置」缺口,照 POC 移植):
- flash-dialog + flash-progress(選 model + 相容性檢查 + WS 進度)
- flash-store(throw-based ApiError)
- use-flash-progress(onOpen 才 POST 防 race + hasStartedRef 防重連重複)
- hardware-compat(targetChip 單值、寧鬆勿嚴、UX 警示非安全閘)
- device-detail-client 掛 FlashDialog(gate 未改、flash 完 fetchDevice 解鎖)
- Reviewer 0C/0M

契約:same-origin cookie、WS/stream/upload URL 相對路徑無 token、i18n 雙語對稱、
設計 token 不裸色值。全前端 tsc/eslint clean、塔3 30 test + flash 45 test 綠。
(全套 11 failed 全屬 conversion-store 既有 flaky、未觸碰)

follow-up:flash WS 逾時提示(Mi)、--success token(design)、conversion-store flaky 釐清。

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

217 lines
7.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 { 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,
} 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
await waitFor(() => {
expect(post).toHaveBeenCalledWith("/api/camera/stop", { deviceId: "dev-1" });
});
// 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);
});
});