「開始/停止推論」按鈕是 Camera 即時攝影機專用(打 /api/camera/start), 但原本渲染在所有分頁共用頂部。影片/圖片/批次是「上傳即自動推論」 (media-tab useInferenceStream=uploaded),不需此按鈕,顯示會誤導使用者 以為要先按開始推論。 用 activeTab === "camera" 條件渲染,非 camera 分頁隱藏。純顯示層改動, handleStart/handleStop/isRunning/disabled 邏輯完全未動;與 handleTabChange 「切走即停」語意一致(切回 camera 時 isRunning 已 false、無殘留)。 reviewer 通過(0C/0M/0m)。tsc/eslint/build/14 test 綠 + 新增 1 測試驗 「按鈕只在 camera 顯示、切走隱藏、切回重現」。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
401 lines
14 KiB
TypeScript
401 lines
14 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(),
|
||
}));
|
||
|
||
// ADR-019 WP-4:mock 影片 localhost 直連編排(單元隔離;local-media 自身有 local-media.test.ts)。
|
||
// 保留真實 LocalMediaError 供錯誤分支測試。
|
||
const uploadVideoViaLocalAgent = vi.fn();
|
||
vi.mock("@/lib/local-media", async () => {
|
||
const actual =
|
||
await vi.importActual<typeof import("@/lib/local-media")>("@/lib/local-media");
|
||
return {
|
||
...actual,
|
||
uploadVideoViaLocalAgent: (...a: unknown[]) => uploadVideoViaLocalAgent(...a),
|
||
};
|
||
});
|
||
|
||
// 可控 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 { LocalMediaError } from "@/lib/local-media";
|
||
|
||
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-C(ADR-018):camera/media/WS 操作識別值 = serialNumber
|
||
serialNumber: "KN00123456",
|
||
} as ReturnType<typeof useDeviceStore.getState>["selectedDevice"],
|
||
isLoading: false,
|
||
});
|
||
}
|
||
|
||
/** WP-C:seed 一台線上但「未回報序號」的裝置(舊資料 / 假序號 → 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();
|
||
uploadVideoViaLocalAgent.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=true(camera tab + 推論中 + 線上)
|
||
expect(lastCameraWsEnabled()).toBe(true);
|
||
// 尚未呼叫 stop
|
||
expect(post).not.toHaveBeenCalledWith("/api/camera/stop", expect.anything());
|
||
|
||
// 切到 image tab
|
||
await switchTab("圖片");
|
||
|
||
// handleTabChange 應自動停掉 camera(POST /api/camera/stop)
|
||
// WP-C(ADR-018):body 的 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("開始推論按鈕只在 camera tab 顯示,切到 image/video/batch 隱藏", async () => {
|
||
renderClient();
|
||
// camera tab(預設):按鈕在
|
||
expect(screen.getByText("開始推論")).toBeInTheDocument();
|
||
|
||
// 切到影片:按鈕隱藏(上傳即自動推論,不需此按鈕)
|
||
await switchTab("影片");
|
||
expect(screen.queryByText("開始推論")).not.toBeInTheDocument();
|
||
|
||
// 切回 camera:按鈕重新出現
|
||
await switchTab("Camera");
|
||
expect(screen.getByText("開始推論")).toBeInTheDocument();
|
||
});
|
||
|
||
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 tab,camera 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();
|
||
});
|
||
});
|
||
|
||
/**
|
||
* ADR-019 WP-4:影片分頁走 localhost 直連 local-agent(非 tunnel)。
|
||
* 這裡驗證接線點——影片上傳呼叫 uploadVideoViaLocalAgent(帶 serial)、
|
||
* tunnel 離線時停用上傳、錯誤碼轉 friendly i18n。
|
||
*/
|
||
describe("WorkspaceClient — 影片 localhost 直連(WP-4)", () => {
|
||
/** 在影片分頁的 dropzone 選檔(觸發上傳流程)。 */
|
||
function selectVideo(file: File) {
|
||
const input = document.querySelector(
|
||
'[data-testid="media-uploader-video"] input[type=file]',
|
||
) as HTMLInputElement;
|
||
Object.defineProperty(input, "files", { value: [file], configurable: true });
|
||
fireEvent.change(input);
|
||
}
|
||
|
||
it("影片上傳走 uploadVideoViaLocalAgent(帶 serial),不走雲端 uploadVideo", async () => {
|
||
uploadVideoViaLocalAgent.mockResolvedValue({
|
||
streamUrl: "/api/camera/stream",
|
||
sourceType: "video",
|
||
totalFrames: 100,
|
||
});
|
||
renderClient();
|
||
await switchTab("影片");
|
||
// 線上 + 有 serial → 顯示 uploader(非離線提示)
|
||
expect(screen.getByTestId("media-uploader-video")).toBeInTheDocument();
|
||
|
||
selectVideo(new File([new Blob(["x"])], "clip.mp4"));
|
||
|
||
await waitFor(() => {
|
||
expect(uploadVideoViaLocalAgent).toHaveBeenCalled();
|
||
});
|
||
// 第一個參數是 serial、第二個是 File
|
||
const [serialArg, fileArg] = uploadVideoViaLocalAgent.mock.calls[0]!;
|
||
expect(serialArg).toBe("KN00123456");
|
||
expect(fileArg).toBeInstanceOf(File);
|
||
});
|
||
|
||
it("tunnel 離線(remoteStatus != online)→ 影片分頁停用上傳 + 顯示離線提示", async () => {
|
||
useDeviceStore.setState({
|
||
selectedDevice: {
|
||
id: "dev-1",
|
||
name: "KL520",
|
||
remoteStatus: "offline",
|
||
lastSeenAt: null,
|
||
serialNumber: "KN00123456",
|
||
} as ReturnType<typeof useDeviceStore.getState>["selectedDevice"],
|
||
isLoading: false,
|
||
});
|
||
renderClient();
|
||
// 離線時整頁會有 offline 遮罩,但分頁內容仍 render;切到影片分頁驗證停用提示
|
||
await switchTab("影片");
|
||
expect(
|
||
screen.getByTestId("media-upload-disabled-video"),
|
||
).toBeInTheDocument();
|
||
// 不出現 uploader
|
||
expect(screen.queryByTestId("media-uploader-video")).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("NOT_FOUND → 顯示「需同機操作」friendly 訊息(非 raw code)", async () => {
|
||
uploadVideoViaLocalAgent.mockRejectedValue(
|
||
new LocalMediaError("LOCAL_AGENT_NOT_FOUND"),
|
||
);
|
||
renderClient();
|
||
await switchTab("影片");
|
||
selectVideo(new File([new Blob(["x"])], "clip.mp4"));
|
||
|
||
await waitFor(() => {
|
||
const err = screen.getByTestId("media-uploader-video-error");
|
||
expect(err.textContent).toContain("同一台電腦");
|
||
// 不可洩漏 raw code
|
||
expect(err.textContent).not.toContain("LOCAL_AGENT_NOT_FOUND");
|
||
});
|
||
});
|
||
|
||
it("MISMATCH → 顯示「Agent 與裝置不符」friendly 訊息", async () => {
|
||
uploadVideoViaLocalAgent.mockRejectedValue(
|
||
new LocalMediaError("LOCAL_AGENT_MISMATCH"),
|
||
);
|
||
renderClient();
|
||
await switchTab("影片");
|
||
selectVideo(new File([new Blob(["x"])], "clip.mp4"));
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
screen.getByTestId("media-uploader-video-error").textContent,
|
||
).toContain("不符");
|
||
});
|
||
});
|
||
});
|