diff --git a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx new file mode 100644 index 0000000..cfe167f --- /dev/null +++ b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx @@ -0,0 +1,142 @@ +/** + * WorkspaceClient 測試(塊 2-review M1:overlay 未就緒不繪製) + * + * 聚焦 M1:CameraFeed `` 是 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("@/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["selectedDevice"], + isLoading: false, + }); +} + +function renderClient() { + return render( + + + , + ); +} + +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"); + }); +}); diff --git a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx index 1555df6..d14eb62 100644 --- a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx +++ b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx @@ -28,14 +28,18 @@ import { ArrowLeft, WifiOff } from "lucide-react"; import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge"; import { CameraFeed } from "@/components/workspace/camera-feed"; +import { CameraOverlay } from "@/components/workspace/camera-overlay"; +import { InferencePanel } from "@/components/workspace/inference-panel"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useInferenceStream } from "@/hooks/use-inference-stream"; import { api, ApiError } from "@/lib/api"; import { buildStreamUrl } from "@/lib/camera"; import { useT } from "@/lib/i18n/context"; import { useDeviceStore } from "@/stores/device-store"; +import { useInferenceStore } from "@/stores/inference-store"; import type { MediaUploadResponse } from "@/types/camera"; import { toast } from "sonner"; @@ -52,6 +56,12 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { const [busy, setBusy] = useState(false); // 塊 1:Camera MJPEG 串流 URL(start 成功後由後端回傳的 streamUrl 組出) const [streamUrl, setStreamUrl] = useState(""); + // 塊 2:CameraFeed `` 實際 render 尺寸(供 overlay 換算 normalized bbox → px)。 + // review M1:初值為 null(尚未從 ResizeObserver 拿到真實顯示尺寸)。overlay 在拿到 + // 真實尺寸前不繪製,避免用寫死猜測(640×480)換算 → MJPEG 非 4:3 時 bbox 框錯位。 + const [feedSize, setFeedSize] = useState<{ w: number; h: number } | null>(null); + + const resetInference = useInferenceStore((s) => s.reset); useEffect(() => { if (deviceId) void fetchDevice(deviceId); @@ -61,8 +71,24 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { const clearStream = useCallback(() => { setStreamUrl(""); setIsRunning(false); + setFeedSize(null); // 重置尺寸,下次 start 重新等 ResizeObserver 回報真實值 + resetInference(); + }, [resetInference]); + + const handleDimensionsChange = useCallback((w: number, h: number) => { + setFeedSize({ w, h }); }, []); + // 塊 2:裝置線上性(hooks 必須無條件呼叫,故在此處先算,早退前) + const isOnline = selectedDevice?.remoteStatus === "online"; + + // 塊 2:訂閱 WS `inference:`(只在推論中且線上時連線) + useInferenceStream(deviceId, isRunning && isOnline); + + // 塊 2:最新結果的偵測框(供 overlay 繪製) + const liveResult = useInferenceStore((s) => s.result); + const confidenceThreshold = useInferenceStore((s) => s.confidenceThreshold); + async function handleStart() { setBusy(true); try { @@ -112,7 +138,6 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { // 裝置尚未載入(雛形:selectedDevice 可能為 null)— 簡易占位 const device = selectedDevice; - const isOnline = device?.remoteStatus === "online"; // 裝置掉線時不顯示串流(雲端版對離線極敏感;避免 卡在已死的 MJPEG 連線)。 // 用 render 期衍生而非 effect + setState,避免 cascading render。 const effectiveStreamUrl = isOnline ? streamUrl : ""; @@ -173,6 +198,19 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { streamUrl={effectiveStreamUrl} sourceType="camera" width={640} + onDimensionsChange={handleDimensionsChange} + overlay={ + // M1:feedSize 未就緒(ResizeObserver 尚未回報真實顯示尺寸)前不繪製 overlay, + // 避免用寫死猜測換算 bbox → 框錯位。 + feedSize ? ( + + ) : undefined + } /> ) : (
@@ -190,10 +228,8 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { -

- {/* 塊 2 補:接 WS `inference:` → ClassificationResult + PerformanceMetrics + overlay */} - {t("workspace.inference.panelPending")} -

+ {/* 塊 2:接 WS `inference:` → 效能指標 + 結果清單 */} +
diff --git a/visionA-frontend/src/components/workspace/camera-overlay.test.tsx b/visionA-frontend/src/components/workspace/camera-overlay.test.tsx new file mode 100644 index 0000000..b39ce3e --- /dev/null +++ b/visionA-frontend/src/components/workspace/camera-overlay.test.tsx @@ -0,0 +1,198 @@ +/** + * CameraOverlay 單元測試(塊 2 + 塊 2-review 修正) + * + * jsdom 無 canvas 2D 實作 → mock getContext 回傳 spy ctx,斷言繪製呼叫。 + * (jsdom 的 getComputedStyle 對 CSS 自訂變數回空字串 → palette 走 fallback,測試不受影響。) + * + * 驗證: + * - render (aria-hidden,語意交給 InferencePanel) + * - 每個通過門檻的 detection 觸發 strokeRect + fillText + * - 低於 confidenceThreshold 的 detection 不繪製 + * - normalized bbox 依 width/height 換算 px + * - m2:label 文字 fillStyle 為實際色字串(非 "currentColor") + * - m3:缺 bbox / 非數字欄位的壞 detection 被略過(不 throw、不繪製) + * - M2:getComputedStyle 不隨 detections 更新而每次重讀(palette 快取) + */ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { DetectionResult } from "@/types/inference"; + +import { CameraOverlay } from "./camera-overlay"; + +interface CtxSpy { + clearRect: ReturnType; + strokeRect: ReturnType; + fillRect: ReturnType; + fillText: ReturnType; + measureText: ReturnType; + strokeStyle: string; + fillStyle: string; + lineWidth: number; + font: string; +} + +let ctx: CtxSpy; +/** 記錄每次 fillText 呼叫時當下的 fillStyle 值(驗證 m2 label 色)。 */ +let fillTextStyles: string[]; + +beforeEach(() => { + fillTextStyles = []; + let fillStyleValue = ""; + const base = { + clearRect: vi.fn(), + strokeRect: vi.fn(), + fillRect: vi.fn(), + measureText: vi.fn(() => ({ width: 40 })), + strokeStyle: "", + lineWidth: 0, + font: "", + }; + ctx = { + ...base, + fillText: vi.fn(() => { + fillTextStyles.push(fillStyleValue); + }), + get fillStyle() { + return fillStyleValue; + }, + set fillStyle(v: string) { + fillStyleValue = v; + }, + } as unknown as CtxSpy; + + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue( + ctx as unknown as CanvasRenderingContext2D, + ); +}); + +function det(overrides: Partial = {}): DetectionResult { + return { + label: "obj", + confidence: 0.9, + bbox: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, + ...overrides, + }; +} + +describe("", () => { + it("render aria-hidden canvas", () => { + render( + , + ); + const canvas = screen.getByTestId("camera-overlay"); + expect(canvas).toBeInTheDocument(); + expect(canvas).toHaveAttribute("aria-hidden", "true"); + }); + + it("每個通過門檻的 detection 觸發 strokeRect + fillText", () => { + render( + , + ); + expect(ctx.strokeRect).toHaveBeenCalledTimes(2); + expect(ctx.fillText).toHaveBeenCalledTimes(2); + }); + + it("低於門檻的 detection 不繪製", () => { + render( + , + ); + expect(ctx.strokeRect).not.toHaveBeenCalled(); + }); + + it("normalized bbox 依尺寸換算 px", () => { + render( + , + ); + // x*1000=100, y*500=100, w*1000=300, h*500=200 + expect(ctx.strokeRect).toHaveBeenCalledWith(100, 100, 300, 200); + }); + + it("繪製前先 clearRect 清畫布", () => { + render( + , + ); + expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 640, 480); + }); + + it("m2:label 文字 fillStyle 為實際色字串,非 currentColor", () => { + render( + , + ); + expect(fillTextStyles).toHaveLength(1); + expect(fillTextStyles[0]).not.toBe(""); + expect(fillTextStyles[0]).not.toBe("currentColor"); + }); + + it("m3:缺 bbox 的壞 detection 被略過、不 throw", () => { + const bad = { label: "bad", confidence: 0.9 } as unknown as DetectionResult; + expect(() => + render( + , + ), + ).not.toThrow(); + // 只有合法那筆被繪製 + expect(ctx.strokeRect).toHaveBeenCalledTimes(1); + }); + + it("m3:bbox 欄位非數字(NaN/undefined)被略過", () => { + const bad = det({ + bbox: { x: NaN, y: 0.2, width: 0.3, height: 0.4 }, + }); + const bad2 = det({ + bbox: { x: 0.1, y: 0.2, width: undefined as unknown as number, height: 0.4 }, + }); + render( + , + ); + expect(ctx.strokeRect).not.toHaveBeenCalled(); + }); + + it("M2:detections 更新時 getComputedStyle 不每次重讀(palette 已快取)", () => { + const spy = vi.spyOn(window, "getComputedStyle"); + const { rerender } = render( + , + ); + const afterMount = spy.mock.calls.length; + + // 模擬多筆 WS 結果進來(只換 detections) + for (let i = 0; i < 5; i++) { + rerender( + , + ); + } + // detections 更新不應再呼叫 getComputedStyle(palette 快取在 state,只掛載/主題切換時讀) + expect(spy.mock.calls.length).toBe(afterMount); + spy.mockRestore(); + }); +}); diff --git a/visionA-frontend/src/components/workspace/camera-overlay.tsx b/visionA-frontend/src/components/workspace/camera-overlay.tsx new file mode 100644 index 0000000..f42818a --- /dev/null +++ b/visionA-frontend/src/components/workspace/camera-overlay.tsx @@ -0,0 +1,162 @@ +"use client"; + +/** + * CameraOverlay — 疊在 MJPEG `` 上的偵測結果 canvas(塊 2) + * + * 移植自 POC edge-ai-platform/.../camera/camera-overlay.tsx(唯讀參考、非搬 code)。 + * + * 職責: + * - 依 detections(normalized bbox 0~1)換算成 render 尺寸的 px 繪製邊界框 + * - 每個框附 label + 信心度百分比 + * - 依 confidenceThreshold 過濾低信心度框 + * + * 設計 token(不裸色值): + * canvas 2D API 無法直接吃 CSS 變數,故用 getComputedStyle 讀取設計系統的 + * `--chart-1`..`--chart-5`(框色)與 `--background`(label 文字色)token。 + * ⚠️ 效能(塊 2 review M2):**不在每 frame 讀** getComputedStyle(forced reflow、30fps 下會 jank)。 + * 改為掛載時讀一次快取進 state,只在主題切換(`` 的 `.dark` class 變動,由 next-themes + * 切換,見 theme-provider.tsx)時透過 MutationObserver 重讀。仍符合「讀設計 token 不裸色值」。 + */ + +import { useEffect, useRef, useState } from "react"; + +import type { DetectionResult } from "@/types/inference"; + +interface CameraOverlayProps { + detections: DetectionResult[]; + /** `` 實際 render 寬(px,由 CameraFeed 的 ResizeObserver 回報) */ + width: number; + /** `` 實際 render 高(px) */ + height: number; + confidenceThreshold: number; +} + +/** 設計系統 chart token 名稱(globals.css)。canvas 繪圖時讀其 computed 值。 */ +const CHART_TOKENS = [ + "--chart-1", + "--chart-2", + "--chart-3", + "--chart-4", + "--chart-5", +] as const; + +/** + * canvas 認得的最終 fallback 色(m2):`--background` token 讀不到時用。 + * canvas 2D **不認 `currentColor`**(會沿用前一個 fillStyle → label 與框同色看不見), + * 故 fallback 必須是實際色字串。淺色主題背景近白,取 `#ffffff` 當保底(僅在 token 讀失敗才會用到)。 + */ +const LABEL_TEXT_FALLBACK = "#ffffff"; + +interface Palette { + /** 框色循環調色盤 */ + colors: string[]; + /** label 文字色(主題背景色) */ + labelText: string; +} + +/** 從設計 token 讀出調色盤(只在掛載 / 主題切換時呼叫,非每 frame)。 */ +function readPalette(): Palette { + if (typeof window === "undefined") { + return { colors: [LABEL_TEXT_FALLBACK], labelText: LABEL_TEXT_FALLBACK }; + } + const style = getComputedStyle(document.documentElement); + const colors = CHART_TOKENS.map((tok) => style.getPropertyValue(tok).trim()).filter( + Boolean, + ); + const bg = style.getPropertyValue("--background").trim(); + return { + colors: colors.length > 0 ? colors : [LABEL_TEXT_FALLBACK], + labelText: bg || LABEL_TEXT_FALLBACK, + }; +} + +export function CameraOverlay({ + detections, + width, + height, + confidenceThreshold, +}: CameraOverlayProps) { + const canvasRef = useRef(null); + // M2:palette 快取進 state,掛載讀一次;主題切換時由 MutationObserver 重讀。 + const [palette, setPalette] = useState(() => readPalette()); + + // 監聽 `` class 變動(next-themes 切換 `.dark`)→ 重讀 palette。 + // 初始 palette 由 useState lazy initializer(readPalette)於掛載時讀一次(client-only 元件, + // 只在 start 推論後才 render,故 initializer 已能拿到正確 computed 值,不需 effect 內再 setState)。 + useEffect(() => { + if (typeof window === "undefined") return; + const observer = new MutationObserver(() => setPalette(readPalette())); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.clearRect(0, 0, width, height); + + const { colors, labelText } = palette; + const filtered = detections.filter( + // m3:防禦壞 payload — 缺 bbox 或欄位非數字的 detection 直接略過,避免 det.bbox.x throw + (d) => d.confidence >= confidenceThreshold && isValidBBox(d), + ); + + filtered.forEach((det, i) => { + const color = colors[i % colors.length]; + // normalized (0~1) → px + const px = det.bbox.x * width; + const py = det.bbox.y * height; + const pw = det.bbox.width * width; + const ph = det.bbox.height * height; + + // 邊界框 + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.strokeRect(px, py, pw, ph); + + // label 背景 + 文字 + const label = `${det.label} ${(det.confidence * 100).toFixed(0)}%`; + ctx.font = "14px sans-serif"; + const textWidth = ctx.measureText(label).width; + const labelH = 20; + const labelY = py - labelH >= 0 ? py - labelH : py; // 避免框在最上緣時 label 出界 + ctx.fillStyle = color; + ctx.fillRect(px, labelY, textWidth + 8, labelH); + + // m2:文字色用實際色字串(快取的 --background),canvas 不認 currentColor + ctx.fillStyle = labelText; + ctx.fillText(label, px + 4, labelY + labelH - 5); + }); + }, [detections, width, height, confidenceThreshold, palette]); + + return ( +