diff --git a/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx b/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx index 7e2ba15..2820380 100644 --- a/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx +++ b/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx @@ -11,8 +11,11 @@ * - 裝置資訊 + 模型狀態 兩欄 Card * - 離線降級:燒錄 / 工作區按鈕 disabled * - * F6 不做(保留 stub 或隱藏): - * - FlashDialog(雲端版 flash 走 tunnel forward;F8 補完整流程) + * flash(載入模型到裝置):FlashDialog 已接上(見 flash-model-load-mapping.md)。 + * 選 model → 相容性檢查 → 觸發 flash(POST /api/devices/:id/flash)→ WS 進度 + * → 完成後 fetchDevice 刷新 → gate(開啟工作區)自動出現。裝置離線時 disable。 + * + * 尚未做(保留 stub 或隱藏): * - DeviceHealthCard / DeviceConnectionLog(Phase 1 完整化) * - DeviceSettingsCard(alias / notes — Phase 1 接後端) */ @@ -24,6 +27,7 @@ import { AlertTriangle, ArrowLeft, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge"; +import { FlashDialog } from "@/components/devices/flash-dialog"; import { AlertDialog, AlertDialogAction, @@ -161,6 +165,11 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) { />
+ {/* flash(載入模型):裝置在線才可 flash;離線時 disable 觸發鈕。 + flash 完成後 dialog 內部呼叫 fetchDevice → flashedModel 有值 → 下方 + 「開啟工作區」按鈕自動出現(gate 解鎖,無需改 gate 邏輯)。 */} + + {isOnline && selectedDevice.flashedModel && ( diff --git a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx index cfe167f..6cec548 100644 --- a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx +++ b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx @@ -42,8 +42,17 @@ class ControllableRO { 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: { @@ -140,3 +149,68 @@ describe("WorkspaceClient — overlay 尺寸就緒閘門(M1)", () => { 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) + 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 tab,camera WS 不應又被打開 + expect(lastCameraWsEnabled()).toBe(false); + }); +}); diff --git a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx index d14eb62..c71a53f 100644 --- a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx +++ b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx @@ -22,7 +22,7 @@ * 任何時刻若收到 remoteStatus != online 都要立刻顯示 offline 遮罩並停止串流。 */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { ArrowLeft, WifiOff } from "lucide-react"; @@ -30,6 +30,7 @@ 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 { MediaTab } from "@/components/workspace/media-tab"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -37,12 +38,25 @@ 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 { + IMAGE_ACCEPT, + VIDEO_ACCEPT, + uploadBatchImages, + uploadImage, + uploadVideo, + validateBatchFiles, + validateImageFile, + validateVideoFile, +} from "@/lib/media"; 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"; +/** 影片上傳 timeout 拉長(大檔經 tunnel,評估 R-M2);0 = 不限。 */ +const VIDEO_UPLOAD_TIMEOUT_MS = 0; + interface WorkspaceClientProps { deviceId: string; } @@ -54,6 +68,9 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { const fetchDevice = useDeviceStore((s) => s.fetchDevice); const [isRunning, setIsRunning] = useState(false); const [busy, setBusy] = useState(false); + // 目前選中的 tab(camera / image / video / batch)。 + // 用於:離開 camera tab 時停掉 camera 串流 + WS,避免與 media tab 的 WS 訂閱同時寫入同一個 store。 + const [activeTab, setActiveTab] = useState("camera"); // 塊 1:Camera MJPEG 串流 URL(start 成功後由後端回傳的 streamUrl 組出) const [streamUrl, setStreamUrl] = useState(""); // 塊 2:CameraFeed `` 實際 render 尺寸(供 overlay 換算 normalized bbox → px)。 @@ -62,6 +79,8 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { const [feedSize, setFeedSize] = useState<{ w: number; h: number } | null>(null); const resetInference = useInferenceStore((s) => s.reset); + // handleStop 最新引用(handleTabChange 用;見下方 handleStop 定義後賦值) + const handleStopRef = useRef<() => Promise>(async () => {}); useEffect(() => { if (deviceId) void fetchDevice(deviceId); @@ -82,8 +101,20 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { // 塊 2:裝置線上性(hooks 必須無條件呼叫,故在此處先算,早退前) const isOnline = selectedDevice?.remoteStatus === "online"; - // 塊 2:訂閱 WS `inference:`(只在推論中且線上時連線) - useInferenceStream(deviceId, isRunning && isOnline); + // 塊 3:切換 tab。離開 camera tab 時若正在推論 → 停掉(避免 camera 與 media 兩套 WS 同時灌 store)。 + const handleTabChange = useCallback( + (value: string) => { + setActiveTab(value); + if (value !== "camera" && isRunning) { + void handleStopRef.current(); + } + }, + [isRunning], + ); + + // 塊 2/3:訂閱 camera WS `inference:`(只在 camera tab + 推論中 + 線上時連線)。 + // media tab 有自己的 useInferenceStream(在 MediaTab 內),兩者靠 activeTab 互斥。 + useInferenceStream(deviceId, activeTab === "camera" && isRunning && isOnline); // 塊 2:最新結果的偵測框(供 overlay 繪製) const liveResult = useInferenceStore((s) => s.result); @@ -126,6 +157,12 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { setBusy(false); } } + // handleTabChange 需要在切離 camera tab 時呼叫 handleStop;用 ref 保持最新引用, + // 避免 handleTabChange 的 deps 依賴每次 render 重建的 handleStop。 + // 於 effect 內同步 ref(不在 render 期寫 ref,符合 react-hooks/refs 規則)。 + useEffect(() => { + handleStopRef.current = handleStop; + }); if (isLoading && !selectedDevice) { return ( @@ -176,18 +213,12 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
- + {t("workspace.tabs.camera")} - - {t("workspace.tabs.image")} - - - {t("workspace.tabs.video")} - - - {t("workspace.tabs.batch")} - + {t("workspace.tabs.image")} + {t("workspace.tabs.video")} + {t("workspace.tabs.batch")}
@@ -234,6 +265,61 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
+ + {/* 塊 3:圖片 / 影片 / 批次 — 共用 MediaTab(上傳 → CameraFeed + overlay + panel) */} + + + files[0] ? validateImageFile(files[0]) : { code: "EMPTY" } + } + upload={async (files, ctx) => { + return uploadImage(deviceId, files[0]!, { + onProgress: ctx.onProgress, + signal: ctx.signal, + }); + }} + /> + + + + + files[0] ? validateVideoFile(files[0]) : { code: "EMPTY" } + } + upload={async (files, ctx) => { + return uploadVideo(deviceId, files[0]!, { + onProgress: ctx.onProgress, + signal: ctx.signal, + timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS, + }); + }} + /> + + + + { + return uploadBatchImages(deviceId, files, { + onProgress: ctx.onProgress, + signal: ctx.signal, + }); + }} + /> +
{/* 裝置掉線遮罩(flow-offline-handling §6.2) */} diff --git a/visionA-frontend/src/components/devices/flash-dialog.test.tsx b/visionA-frontend/src/components/devices/flash-dialog.test.tsx new file mode 100644 index 0000000..5211bdf --- /dev/null +++ b/visionA-frontend/src/components/devices/flash-dialog.test.tsx @@ -0,0 +1,125 @@ +/** + * FlashDialog 互動測試(flash UI) + * + * 覆蓋: + * - 觸發鈕:disabled prop 生效(離線) + * - 開 dialog → fetchModels 被呼叫、reset flash 態 + * - flash 進行中(isFlashing)→ 顯示進度區、不允許關閉 + * - flash 完成(percent>=100)→ 顯示「完成」按鈕;點完成 → fetchDevice(gate 解鎖)+ reset + * - flash 失敗(error)→ 顯示錯誤 + 「關閉」按鈕;點關閉 → 不 fetchDevice + * + * Mock: + * - useFlashProgress → 攔截 beginFlash / stop(不真的連 WS) + * - model-store fetchModels / device-store fetchDevice → vi.fn 觀察呼叫 + * - Radix Select 的 model 選取在 jsdom 難穩定驅動 → 相容性 / 選取邏輯由 hardware-compat.test + * 與 flash-store.test 覆蓋;本檔聚焦 dialog 狀態機與 gate 接線。 + */ + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { LocaleProvider } from "@/lib/i18n/context"; +import { useDeviceStore, type Device } from "@/stores/device-store"; +import { useFlashStore } from "@/stores/flash-store"; +import { useModelStore } from "@/stores/model-store"; + +const mockBeginFlash = vi.fn(); +const mockStop = vi.fn(); +vi.mock("@/hooks/use-flash-progress", () => ({ + useFlashProgress: () => ({ + beginFlash: mockBeginFlash, + stop: mockStop, + isActive: false, + }), +})); + +import { FlashDialog } from "./flash-dialog"; + +const device: Device = { + id: "dev-1", + name: "Cam A", + type: "kneron_kl520", + status: "connected", + remoteStatus: "online", + flashedModel: null, +}; + +function renderDialog(props?: { disabled?: boolean }) { + return render( + + + , + ); +} + +function resetStores() { + useFlashStore.setState({ isFlashing: false, progress: null, error: null, lastFlashParams: null }); + useModelStore.setState({ models: [], selectedModel: null, isLoading: false, downloadingId: null }); + useDeviceStore.setState({ selectedDevice: device }); +} + +describe("FlashDialog", () => { + beforeEach(() => { + resetStores(); + mockBeginFlash.mockReset(); + mockStop.mockReset(); + }); + afterEach(() => vi.clearAllMocks()); + + it("觸發鈕 disabled prop 生效(離線)", () => { + renderDialog({ disabled: true }); + expect(screen.getByTestId("flash-model-trigger")).toBeDisabled(); + }); + + it("開 dialog → fetchModels 被呼叫", () => { + const fetchModels = vi.fn(); + useModelStore.setState({ fetchModels }); + renderDialog(); + fireEvent.click(screen.getByTestId("flash-model-trigger")); + expect(fetchModels).toHaveBeenCalled(); + }); + + it("flash 進行中 → 顯示進度區", () => { + renderDialog(); + fireEvent.click(screen.getByTestId("flash-model-trigger")); + // 直接把 store 推到 flashing + 有進度(act 包裹讓 React flush) + act(() => { + useFlashStore.setState({ isFlashing: true, progress: { percent: 42, stage: "loading" } }); + }); + expect(screen.getByTestId("flash-progress")).toBeInTheDocument(); + expect(screen.getByText("42%")).toBeInTheDocument(); + }); + + it("flash 完成 → 顯示完成按鈕,點完成 fetchDevice(gate 解鎖)+ stop", () => { + const fetchDevice = vi.fn(); + useDeviceStore.setState({ fetchDevice }); + renderDialog(); + fireEvent.click(screen.getByTestId("flash-model-trigger")); + act(() => { + useFlashStore.setState({ isFlashing: false, progress: { percent: 100, stage: "done" } }); + }); + + const doneBtn = screen.getByTestId("flash-done-btn"); + expect(doneBtn).toBeInTheDocument(); + fireEvent.click(doneBtn); + expect(fetchDevice).toHaveBeenCalledWith("dev-1"); + expect(mockStop).toHaveBeenCalled(); + }); + + it("flash 失敗 → 顯示錯誤 + 關閉按鈕;點關閉不 fetchDevice", () => { + const fetchDevice = vi.fn(); + useDeviceStore.setState({ fetchDevice }); + renderDialog(); + fireEvent.click(screen.getByTestId("flash-model-trigger")); + act(() => { + useFlashStore.setState({ isFlashing: false, error: "device busy" }); + }); + + expect(screen.getByTestId("flash-error")).toBeInTheDocument(); + expect(screen.getByText("device busy")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("flash-done-btn")); + // error 態關閉不刷 device(避免覆蓋錯誤狀態) + expect(fetchDevice).not.toHaveBeenCalled(); + expect(mockStop).toHaveBeenCalled(); + }); +}); diff --git a/visionA-frontend/src/components/devices/flash-dialog.tsx b/visionA-frontend/src/components/devices/flash-dialog.tsx new file mode 100644 index 0000000..f6891f1 --- /dev/null +++ b/visionA-frontend/src/components/devices/flash-dialog.tsx @@ -0,0 +1,215 @@ +"use client"; + +/** + * FlashDialog — 選模型 + 相容性檢查 + 觸發 flash + 進度顯示 + gate 解鎖 + * + * 移植自 POC edge-ai-platform/frontend/src/components/devices/flash-dialog.tsx + * (唯讀參考、非搬 code,見 flash-model-load-mapping.md §2.1 F-5)。 + * + * 三個必須對齊的契約差異(§2.3): + * A. api client throw-based:由 flash-store startFlash try/catch 處理(本檔不直接碰 api)。 + * B. WS 順序保證(最關鍵):改用 useFlashProgress 的 `beginFlash(modelId)`—— + * 它設 enabled=true,並在 WS `onOpen` 才 POST flash(先開 WS 再 POST,無 race)。 + * POC 的 `await connectAndWait(); startFlash()` 在此拆成單一 `beginFlash()`。 + * C. 相容性:visionA model 是 `targetChip` 單值,用 hardware-compat 新簽章 + * `isModelCompatible(model.targetChip, device.type)`(寧鬆勿嚴,權威在 agent)。 + * + * gate 解鎖(§2.4):flash 完成後「完成」按鈕呼叫 `fetchDevice(deviceId)` 刷新 selectedDevice + * → local agent 已設 FlashedModel → device.flashedModel 有值 → device-detail line 164 + * 的「開啟工作區」按鈕自動出現。**無需改 gate 邏輯本身**。 + * + * model 來源(§2.1):`useModelStore.models`(`GET /api/models`);開 dialog 時 fetchModels。 + * 模型庫的 model 欄位是 ModelSummary(含 targetChip / name / id)。 + */ + +import { useMemo, useState } from "react"; +import { TriangleAlert } from "lucide-react"; + +import { FlashProgress } from "@/components/devices/flash-progress"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useFlashProgress } from "@/hooks/use-flash-progress"; +import { getHardwareLabel, isModelCompatible } from "@/lib/hardware-compat"; +import { useT } from "@/lib/i18n/context"; +import { useDeviceStore } from "@/stores/device-store"; +import { useFlashStore } from "@/stores/flash-store"; +import { useModelStore } from "@/stores/model-store"; + +interface FlashDialogProps { + deviceId: string; + /** 裝置離線時不可 flash,disable 觸發鈕(呼叫端傳入)。 */ + disabled?: boolean; +} + +export function FlashDialog({ deviceId, disabled }: FlashDialogProps) { + const t = useT(); + const [open, setOpen] = useState(false); + const [selectedModelId, setSelectedModelId] = useState(""); + + const models = useModelStore((s) => s.models); + const fetchModels = useModelStore((s) => s.fetchModels); + + const isFlashing = useFlashStore((s) => s.isFlashing); + const progress = useFlashStore((s) => s.progress); + const error = useFlashStore((s) => s.error); + const retryFlash = useFlashStore((s) => s.retryFlash); + const reset = useFlashStore((s) => s.reset); + + const selectedDevice = useDeviceStore((s) => s.selectedDevice); + const fetchDevice = useDeviceStore((s) => s.fetchDevice); + + const { beginFlash, stop } = useFlashProgress(deviceId); + + // device 從 selectedDevice 取(detail 頁已 fetch);防呆用 id 比對。 + const device = selectedDevice?.id === deviceId ? selectedDevice : null; + const selectedModel = models.find((m) => m.id === selectedModelId); + + const compatible = useMemo(() => { + if (!selectedModel || !device) return true; + return isModelCompatible(selectedModel.targetChip, device.type); + }, [selectedModel, device]); + + // 開 / 關 dialog 的副作用集中在 onOpenChange 事件處理(非 effect),避免 effect 內 + // 同步 setState 觸發 cascading render(react-hooks/set-state-in-effect)。 + const handleOpenChange = (v: boolean) => { + // flash 進行中(未完成、未錯誤)不允許關閉,避免中斷 WS 漏進度。 + if (!v && isFlashing && !error) return; + if (v) { + void fetchModels(); + reset(); + setSelectedModelId(""); + } else { + stop(); + } + setOpen(v); + }; + + const started = isFlashing || progress !== null || error !== null; + const done = (progress && progress.percent >= 100) || error !== null; + + const handleFlash = () => { + if (!selectedModelId || !compatible) return; + // 契約差異 B:只呼叫 beginFlash(設 enabled + onOpen 觸發 POST),不在此直接 POST。 + beginFlash(selectedModelId); + }; + + const handleClose = () => { + // flash 成功(非 error)→ 刷新 device 讓 gate(開啟工作區)出現(§2.4)。 + if (!error) void fetchDevice(deviceId); + stop(); + reset(); + setOpen(false); + }; + + return ( + + + + + + + {t("devices.flash.flashToDevice")} + {t("devices.flash.dialogDesc")} + + +
+ {!started ? ( + <> + + + {selectedModelId && !compatible && ( +
+
+
+
+ )} + + + + ) : ( + + )} + + {done && ( + + )} +
+
+
+ ); +} diff --git a/visionA-frontend/src/components/devices/flash-progress.tsx b/visionA-frontend/src/components/devices/flash-progress.tsx new file mode 100644 index 0000000..b87d4d9 --- /dev/null +++ b/visionA-frontend/src/components/devices/flash-progress.tsx @@ -0,0 +1,93 @@ +"use client"; + +/** + * FlashProgress — flash 進度顯示(進行中 / 完成 / 錯誤) + * + * 移植自 POC edge-ai-platform/frontend/src/components/devices/flash-progress.tsx + * (近乎純移植,見 flash-model-load-mapping.md §2.2)。 + * + * 與 POC 的差異(visionA 慣例): + * - i18n:`useT()`(單一 key、無插值),非 POC 的 `useTranslation()`。 + * - 設計 token:不用裸色(bg-red-50 / text-yellow-600 …),改用 visionA design tokens + * (--destructive / text-primary / text-muted-foreground),對齊 device-detail-client 慣例。 + * - 型別:`FlashProgress` 從 flash-store import(非 POC 的 types/device)。 + * + * 無障礙:進度區 `role="status"` + `aria-live="polite"`,讓螢幕閱讀器播報進度變化; + * 錯誤區 `role="alert"` 立即播報。Progress 元件本身(Radix)已帶 progressbar role。 + */ + +import { CheckCircle2, XCircle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { useT } from "@/lib/i18n/context"; +import type { FlashProgress as FlashProgressType } from "@/stores/flash-store"; + +interface FlashProgressProps { + progress: FlashProgressType | null; + error?: string | null; + onRetry?: () => void; +} + +export function FlashProgress({ progress, error, onRetry }: FlashProgressProps) { + const t = useT(); + + if (error) { + return ( +
+
+
+
+
+ {onRetry && ( + + )} +
+ ); + } + + if (!progress) { + return ( +
+
+ {t("devices.flash.preparingFlash")} +
+ +
+ ); + } + + const isComplete = progress.percent >= 100; + + return ( +
+
+ {progress.stage} + {progress.percent}% +
+ + {progress.message && ( +

{progress.message}

+ )} + {isComplete && ( +

+

+ )} +
+ ); +} diff --git a/visionA-frontend/src/components/workspace/media-tab.test.tsx b/visionA-frontend/src/components/workspace/media-tab.test.tsx new file mode 100644 index 0000000..f8dcf8c --- /dev/null +++ b/visionA-frontend/src/components/workspace/media-tab.test.tsx @@ -0,0 +1,208 @@ +/** + * MediaTab 單元測試(塊 3) + * + * 驗證: + * - 初始顯示 uploader(未上傳) + * - 上傳成功 → 切到 CameraFeed(img)+ 面板 + * - 離線(isOnline=false)→ 不顯示串流,顯示 uploader + * - video:store 有 frameIndex/totalFrames → 顯示進度 + seek slider + * - batch:store 有 imageIndex/totalImages → 顯示批次進度 + * - feedSize 未就緒不繪 overlay(沿用塊 2 M1 教訓;overlay canvas 需 ResizeObserver 回報後才掛) + * + * 策略:mock useInferenceStream(避免真連 WS)、直接操作 inference-store 模擬 WS 結果。 + */ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { LocaleProvider } from "@/lib/i18n/context"; +import { useInferenceStore } from "@/stores/inference-store"; +import type { MediaUploadResponse } from "@/types/camera"; + +vi.mock("@/hooks/use-inference-stream", () => ({ + useInferenceStream: vi.fn(), +})); +const seekVideoMock = vi.fn().mockResolvedValue({ seekTo: 0, frameOffset: 0 }); +vi.mock("@/lib/media", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, seekVideo: (...args: unknown[]) => seekVideoMock(...args) }; +}); + +import { MediaTab } from "./media-tab"; + +beforeAll(() => { + if (!("ResizeObserver" in globalThis)) { + class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} + } + (globalThis as { ResizeObserver?: unknown }).ResizeObserver = ResizeObserverMock; + } +}); + +function resetStore() { + useInferenceStore.setState({ + result: null, + results: [], + fps: 0, + avgLatency: 0, + batchResults: {}, + confidenceThreshold: 0.5, + }); +} + +function renderTab( + props: Partial> = {}, +) { + const upload = (props.upload ?? + vi.fn().mockResolvedValue({ + streamUrl: "/api/camera/stream", + sourceType: "image", + } as MediaUploadResponse)) as React.ComponentProps["upload"]; + render( + + null} + upload={upload} + {...props} + /> + , + ); + return { upload }; +} + +function selectFiles(files: File[]) { + const input = document.querySelector("input[type=file]") as HTMLInputElement; + Object.defineProperty(input, "files", { value: files, configurable: true }); + fireEvent.change(input); +} + +describe("", () => { + beforeEach(() => { + resetStore(); + seekVideoMock.mockClear(); + }); + + it("初始顯示 uploader", () => { + renderTab(); + expect(screen.getByTestId("media-uploader-image")).toBeInTheDocument(); + expect(screen.queryByTestId("camera-feed-img")).not.toBeInTheDocument(); + }); + + it("上傳成功 → 顯示 CameraFeed", async () => { + renderTab(); + selectFiles([new File([new Blob(["x"])], "a.jpg")]); + await waitFor(() => + expect(screen.getByTestId("camera-feed-img")).toBeInTheDocument(), + ); + // feedSize 未就緒(ResizeObserver mock 不回報尺寸)→ 不繪 overlay canvas + expect(document.querySelector("canvas")).toBeNull(); + }); + + it("離線時不顯示串流(顯示 uploader)", async () => { + const { rerender } = render( + + null} + upload={vi.fn().mockResolvedValue({ + streamUrl: "/api/camera/stream", + sourceType: "image", + })} + /> + , + ); + selectFiles([new File([new Blob(["x"])], "a.jpg")]); + await waitFor(() => + expect(screen.getByTestId("camera-feed-img")).toBeInTheDocument(), + ); + // 切離線 → effectiveStreamUrl="" → 回到 uploader + rerender( + + null} + upload={vi.fn()} + /> + , + ); + expect(screen.queryByTestId("camera-feed-img")).not.toBeInTheDocument(); + expect(screen.getByTestId("media-uploader-image")).toBeInTheDocument(); + }); + + it("video:有 frameIndex/totalFrames → 顯示進度 + seek slider", async () => { + renderTab({ + sourceType: "video", + accept: ".mp4", + upload: vi.fn().mockResolvedValue({ + streamUrl: "/api/camera/stream", + sourceType: "video", + totalFrames: 100, + }) as never, + }); + selectFiles([new File([new Blob(["x"])], "v.mp4")]); + await waitFor(() => + expect(screen.getByTestId("camera-feed-img")).toBeInTheDocument(), + ); + + // 模擬 WS 推來一筆 video 結果(含 frame 進度) + useInferenceStore.setState({ + result: { + taskType: "detection", + timestamp: Date.now(), + latencyMs: 10, + frameIndex: 30, + totalFrames: 100, + }, + }); + + await waitFor(() => + expect(screen.getByTestId("media-video-progress")).toBeInTheDocument(), + ); + expect(screen.getByText("31 / 100")).toBeInTheDocument(); + expect(screen.getByRole("slider")).toBeInTheDocument(); + }); + + it("batch:有 imageIndex/totalImages → 顯示批次進度", async () => { + renderTab({ + sourceType: "batch_image", + accept: ".jpg,.png", + multiple: true, + upload: vi.fn().mockResolvedValue({ + streamUrl: "/api/camera/stream", + sourceType: "batch_image", + totalImages: 5, + }) as never, + }); + selectFiles([new File([new Blob(["x"])], "a.jpg")]); + await waitFor(() => + expect(screen.getByTestId("camera-feed-img")).toBeInTheDocument(), + ); + + useInferenceStore.setState({ + result: { + taskType: "classification", + timestamp: Date.now(), + latencyMs: 10, + imageIndex: 2, + totalImages: 5, + filename: "third.jpg", + }, + }); + + await waitFor(() => + expect(screen.getByTestId("media-batch-progress")).toBeInTheDocument(), + ); + expect(screen.getByText("第 3 / 5 張")).toBeInTheDocument(); + }); +}); diff --git a/visionA-frontend/src/components/workspace/media-tab.tsx b/visionA-frontend/src/components/workspace/media-tab.tsx new file mode 100644 index 0000000..e5e5f27 --- /dev/null +++ b/visionA-frontend/src/components/workspace/media-tab.tsx @@ -0,0 +1,263 @@ +"use client"; + +/** + * MediaTab — 圖片 / 影片 / 批次 推論 tab 的共用骨架(塊 3) + * + * 三種 media 來源共用同一套顯示管線(見評估 §0.1): + * 上傳 → 後端回 streamUrl → CameraFeed(MJPEG ``)+ CameraOverlay(canvas bbox) + * + InferencePanel(WS 結果)。與 camera(塊 1、2)完全複用,不重造。 + * + * 差異由 props / sourceType 控制: + * - image:上傳單張 → 顯示結果,最單純 + * - video:上傳影片 → 逐 frame 播放;額外顯示進度(frameIndex/totalFrames)+ seek bar + * - batch:多檔上傳 → 逐張結果瀏覽;額外顯示縮圖導覽(imageIndex/totalImages) + * + * WS 訂閱 / overlay / feedSize 管理沿用塊 2 教訓: + * - feedSize 初值 null,未就緒不繪 overlay(避免 bbox 框錯位,塊 2 review M1) + * - overlay 只在拿到真實顯示尺寸後才掛載 + * - 切離 tab / 重新上傳 / unmount → reset store + feedSize + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { CameraFeed } from "@/components/workspace/camera-feed"; +import { CameraOverlay } from "@/components/workspace/camera-overlay"; +import { InferencePanel } from "@/components/workspace/inference-panel"; +import { MediaUploader } from "@/components/workspace/media-uploader"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Slider } from "@/components/ui/slider"; +import { useInferenceStream } from "@/hooks/use-inference-stream"; +import { buildStreamUrl } from "@/lib/camera"; +import { frameToSeekSeconds, seekVideo } from "@/lib/media"; +import { useT } from "@/lib/i18n/context"; +import { useInferenceStore } from "@/stores/inference-store"; +import type { + FileValidationError, +} from "@/lib/media"; +import type { MediaUploadResponse, SourceType } from "@/types/camera"; +import { toast } from "sonner"; + +export interface MediaTabProps { + deviceId: string; + /** 裝置是否線上(離線時停止串流 + 禁止上傳) */ + isOnline: boolean; + /** 本 tab 的來源類型 */ + sourceType: Extract; + /** 上傳 accept 字串 */ + accept: string; + /** 多檔(batch) */ + multiple?: boolean; + /** 前端驗證 */ + validate: (files: File[]) => FileValidationError | null; + /** 上傳動作(回 MediaUploadResponse,含 streamUrl 等) */ + upload: ( + files: File[], + ctx: { onProgress: (p: number) => void; signal: AbortSignal }, + ) => Promise; +} + +export function MediaTab({ + deviceId, + isOnline, + sourceType, + accept, + multiple = false, + validate, + upload, +}: MediaTabProps) { + const t = useT(); + const [streamUrl, setStreamUrl] = useState(""); + const [uploaded, setUploaded] = useState(false); + const [feedSize, setFeedSize] = useState<{ w: number; h: number } | null>(null); + // video seek bar 拖曳中的暫存 frame(放開才送 seek) + const [seeking, setSeeking] = useState(null); + // 影片總長秒數(upload response 回;用於 frame→秒換算,避免依賴寫死 fps) + const [durationSeconds, setDurationSeconds] = useState(undefined); + + const resetInference = useInferenceStore((s) => s.reset); + const liveResult = useInferenceStore((s) => s.result); + const confidenceThreshold = useInferenceStore((s) => s.confidenceThreshold); + + // 上傳成功後訂閱 WS(uploaded && online) + useInferenceStream(deviceId, uploaded && isOnline); + + /** 清理串流狀態(切離 / 重新上傳 / unmount)。 */ + const clearStream = useCallback(() => { + setStreamUrl(""); + setUploaded(false); + setFeedSize(null); + setSeeking(null); + setDurationSeconds(undefined); + resetInference(); + }, [resetInference]); + + // unmount(切離 tab)時清理,避免殘留 WS + 死掉的 MJPEG 連線。 + // 用 ref 保存最新的 clearStream,effect deps 保持空陣列(只在 unmount 跑一次)。 + const clearRef = useRef(clearStream); + useEffect(() => { + clearRef.current = clearStream; + }, [clearStream]); + useEffect(() => { + return () => clearRef.current(); + }, []); + + const handleDimensionsChange = useCallback((w: number, h: number) => { + setFeedSize({ w, h }); + }, []); + + // 裝置掉線時不顯示串流(雲端版對離線敏感;用 render 期衍生而非 effect+setState,避免 cascading render)。 + // WS 訂閱本身由 `uploaded && isOnline` gate → 掉線時 useInferenceStream 收 enabled=false 自動關閉並 reset store。 + const effectiveStreamUrl = isOnline ? streamUrl : ""; + + const handleUpload = useCallback( + async ( + files: File[], + ctx: { onProgress: (p: number) => void; signal: AbortSignal }, + ) => { + const data = await upload(files, ctx); + const url = data?.streamUrl ?? "/api/camera/stream"; + setStreamUrl(buildStreamUrl(url, `${deviceId}-${sourceType}-${Date.now()}`)); + // 影片:記住總長秒數(seek frame→秒換算用;缺值時 frameToSeekSeconds 退用 fps 常數) + setDurationSeconds(data?.durationSeconds); + setUploaded(true); + }, + [upload, deviceId, sourceType], + ); + + // ── Video seek ────────────────────────────────────────────────────────── + const frameIndex = liveResult?.frameIndex; + const totalFrames = liveResult?.totalFrames; + const hasVideoProgress = + sourceType === "video" && + typeof frameIndex === "number" && + typeof totalFrames === "number" && + totalFrames > 0; + + const handleSeekCommit = useCallback( + async (frame: number) => { + setSeeking(null); + if (!totalFrames || totalFrames <= 0) return; + // 後端 seek 以秒數為單位(SeekVideo)。frame→秒優先用後端回的 durationSeconds 換算, + // 無 duration 時才退用具名 fps 常數(見 media.ts frameToSeekSeconds / VIDEO_FALLBACK_FPS)。 + const timeSeconds = frameToSeekSeconds(frame, totalFrames, durationSeconds); + try { + await seekVideo(timeSeconds); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }, + [totalFrames, durationSeconds], + ); + + // ── Batch navigation ──────────────────────────────────────────────────── + const imageIndex = liveResult?.imageIndex; + const totalImages = liveResult?.totalImages; + const hasBatchProgress = + sourceType === "batch_image" && + typeof imageIndex === "number" && + typeof totalImages === "number" && + totalImages > 0; + + return ( +
+ + + {!effectiveStreamUrl ? ( + + ) : ( +
+ + ) : undefined + } + /> + + {/* Video:進度 + seek bar */} + {hasVideoProgress && ( +
+
+ {t("workspace.media.video.frame")} + + {(seeking ?? frameIndex) + 1} / {totalFrames} + +
+ setSeeking(v[0] ?? 0)} + onValueCommit={(v) => void handleSeekCommit(v[0] ?? 0)} + /> +
+ )} + + {/* Batch:逐張導覽 */} + {hasBatchProgress && ( +
+ + {liveResult?.filename ?? ""} + + + {t("workspace.media.batch.progress") + .replace("{current}", String((imageIndex ?? 0) + 1)) + .replace("{total}", String(totalImages))} + +
+ )} + +
+ +
+
+ )} +
+
+ + + + + {t("workspace.inference.panelTitle")} + + + + + + +
+ ); +} + +/** sourceType → i18n key 前綴(batch_image 對應 batch)。 */ +function uploaderKey( + sourceType: MediaTabProps["sourceType"], +): "image" | "video" | "batch" { + return sourceType === "batch_image" ? "batch" : sourceType; +} diff --git a/visionA-frontend/src/components/workspace/media-uploader.test.tsx b/visionA-frontend/src/components/workspace/media-uploader.test.tsx new file mode 100644 index 0000000..c0ee556 --- /dev/null +++ b/visionA-frontend/src/components/workspace/media-uploader.test.tsx @@ -0,0 +1,113 @@ +/** + * MediaUploader 單元測試(塊 3) + * + * 驗證: + * - 初始顯示 dropzone + * - 前端驗證失敗(型別)→ 顯示 role=alert 錯誤,不呼叫 onUpload + * - 驗證通過 → 呼叫 onUpload;上傳中顯示進度 + 取消 + * - 取消按鈕 → abort signal 觸發 + * - 上傳失敗(onUpload reject)→ 顯示錯誤訊息 + */ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { LocaleProvider } from "@/lib/i18n/context"; +import { validateImageFile } from "@/lib/media"; +import type { FileValidationError } from "@/lib/media"; + +import { MediaUploader } from "./media-uploader"; + +function makeFile(name: string): File { + return new File([new Blob(["x"])], name); +} + +/** 陣列版驗證(取第一張圖驗)—— 對齊 MediaUploader.validate 的 File[] 簽章。 */ +function validateFirstImage(files: File[]): FileValidationError | null { + return files[0] ? validateImageFile(files[0]) : { code: "EMPTY" }; +} + +function renderUploader( + props: Partial> = {}, +) { + const onUpload = props.onUpload ?? vi.fn().mockResolvedValue(undefined); + render( + + + , + ); + return { onUpload }; +} + +/** 直接觸發 dropzone 內的 file input change(模擬選檔)。 */ +function selectFiles(files: File[]) { + const input = document.querySelector("input[type=file]") as HTMLInputElement; + Object.defineProperty(input, "files", { + value: files, + configurable: true, + }); + fireEvent.change(input); +} + +describe("", () => { + it("初始顯示 dropzone", () => { + renderUploader(); + expect(screen.getByTestId("media-uploader")).toBeInTheDocument(); + }); + + it("型別驗證失敗 → 顯示錯誤 alert,不呼叫 onUpload", () => { + const onUpload = vi.fn().mockResolvedValue(undefined); + renderUploader({ onUpload }); + selectFiles([makeFile("bad.gif")]); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(onUpload).not.toHaveBeenCalled(); + }); + + it("驗證通過 → 呼叫 onUpload 並顯示上傳中 UI", async () => { + let resolveUpload: () => void = () => {}; + const onUpload = vi.fn( + () => new Promise((r) => (resolveUpload = r)), + ); + renderUploader({ onUpload }); + + selectFiles([makeFile("ok.jpg")]); + expect(onUpload).toHaveBeenCalledTimes(1); + await waitFor(() => + expect(screen.getByTestId("media-uploader-uploading")).toBeInTheDocument(), + ); + resolveUpload(); + }); + + it("上傳失敗(reject)→ 顯示錯誤訊息", async () => { + const onUpload = vi.fn().mockRejectedValue(new Error("boom")); + renderUploader({ onUpload }); + selectFiles([makeFile("ok.jpg")]); + await waitFor(() => + expect(screen.getByTestId("media-uploader-error")).toHaveTextContent("boom"), + ); + }); + + it("取消按鈕 → 觸發 abort", async () => { + let capturedSignal: AbortSignal | null = null; + const onUpload = vi.fn( + (_files: File[], ctx: { signal: AbortSignal }) => { + capturedSignal = ctx.signal; + return new Promise(() => {}); // 永不 resolve,停在上傳中 + }, + ); + renderUploader({ onUpload }); + selectFiles([makeFile("ok.jpg")]); + await waitFor(() => + expect(screen.getByTestId("media-uploader-uploading")).toBeInTheDocument(), + ); + fireEvent.click(screen.getByText("取消")); + expect(capturedSignal!.aborted).toBe(true); + }); +}); diff --git a/visionA-frontend/src/components/workspace/media-uploader.tsx b/visionA-frontend/src/components/workspace/media-uploader.tsx new file mode 100644 index 0000000..543933a --- /dev/null +++ b/visionA-frontend/src/components/workspace/media-uploader.tsx @@ -0,0 +1,185 @@ +"use client"; + +/** + * MediaUploader — 通用媒體上傳面板(塊 3:圖片 / 影片 / 批次共用) + * + * 職責: + * - 用 FileDropzone 選檔(單檔 / 多檔) + * - 前端驗證(型別 / 大小 / 數量)→ 錯誤時顯示可讀訊息(不信任副檔名,也擋大小) + * - 呼叫 caller 傳入的 `onUpload(files)`(各 tab 依 sourceType 決定打哪支 API) + * - 上傳中顯示進度條 + 取消按鈕;上傳失敗顯示錯誤 + 重試 + * + * 顯示管線由 caller(MediaTab)負責:上傳成功拿到 streamUrl 後接 CameraFeed。 + * 本元件只負責「把檔案交出去 + 呈現上傳狀態」,不碰串流。 + * + * a11y:dropzone 本身鍵盤可用(見 FileDropzone);進度用 語意; + * 錯誤訊息 role="alert"、與 dropzone 以 aria-describedby 關聯。 + */ + +import { useCallback, useId, useRef, useState } from "react"; +import { Loader2, X } from "lucide-react"; + +import { FileDropzone } from "@/app/conversion/components/FileDropzone"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { useT } from "@/lib/i18n/context"; +import { type FileValidationError } from "@/lib/media"; + +export interface MediaUploaderProps { + /** ``(IMAGE_ACCEPT / VIDEO_ACCEPT) */ + accept: string; + /** 是否多檔(batch=true) */ + multiple?: boolean; + /** 主要提示文字 */ + primaryLabel: string; + /** 「選擇檔案」按鈕文字 */ + browseLabel: string; + /** 大小 / 格式限制 hint */ + hint: string; + /** 前端驗證:回傳 null 表通過,否則回錯誤(型別 / 大小 / 數量) */ + validate: (files: File[]) => FileValidationError | null; + /** 上傳動作(caller 依 sourceType 決定 API);回報進度 + 支援取消 */ + onUpload: ( + files: File[], + ctx: { onProgress: (p: number) => void; signal: AbortSignal }, + ) => Promise; + /** 測試用 testid */ + "data-testid"?: string; +} + +type Phase = "idle" | "uploading" | "error"; + +export function MediaUploader({ + accept, + multiple = false, + primaryLabel, + browseLabel, + hint, + validate, + onUpload, + "data-testid": dataTestId = "media-uploader", +}: MediaUploaderProps) { + const t = useT(); + const errorId = useId(); + const [phase, setPhase] = useState("idle"); + const [percent, setPercent] = useState(0); + const [errorMsg, setErrorMsg] = useState(null); + const abortRef = useRef(null); + + /** 把 FileValidationError 轉成可讀訊息(i18n)。 */ + const validationMessage = useCallback( + (err: FileValidationError): string => { + switch (err.code) { + case "TYPE": + return t("workspace.media.errorType").replace( + "{filename}", + err.filename ?? "", + ); + case "SIZE": + return t("workspace.media.errorSize").replace( + "{filename}", + err.filename ?? "", + ); + case "COUNT": + return t("workspace.media.errorCount"); + case "EMPTY": + return t("workspace.media.errorEmpty"); + default: + return t("workspace.media.errorGeneric"); + } + }, + [t], + ); + + const handleSelect = useCallback( + (files: File[]) => { + const err = validate(files); + if (err) { + setErrorMsg(validationMessage(err)); + setPhase("error"); + return; + } + + setErrorMsg(null); + setPercent(0); + setPhase("uploading"); + const controller = new AbortController(); + abortRef.current = controller; + + void onUpload(files, { + onProgress: (p) => setPercent(p), + signal: controller.signal, + }) + .then(() => { + // 成功後 caller 會切到顯示畫面(unmount 本元件);保底回 idle + setPhase("idle"); + setPercent(0); + }) + .catch((e: unknown) => { + // 使用者主動取消 → 回 idle 不顯示錯誤 + if (controller.signal.aborted) { + setPhase("idle"); + setPercent(0); + return; + } + setErrorMsg(e instanceof Error ? e.message : String(e)); + setPhase("error"); + }) + .finally(() => { + abortRef.current = null; + }); + }, + [onUpload, validate, validationMessage], + ); + + const handleCancel = useCallback(() => { + abortRef.current?.abort(); + }, []); + + if (phase === "uploading") { + return ( +
+
+
+ +
+ {percent}% + +
+
+ ); + } + + return ( +
+ + {errorMsg && ( + + )} +
+ ); +} diff --git a/visionA-frontend/src/hooks/use-flash-progress.test.tsx b/visionA-frontend/src/hooks/use-flash-progress.test.tsx new file mode 100644 index 0000000..a369989 --- /dev/null +++ b/visionA-frontend/src/hooks/use-flash-progress.test.tsx @@ -0,0 +1,132 @@ +/** + * useFlashProgress 單元測試(flash UI 契約層,最關鍵的 WS 順序保證) + * + * 策略:mock `@/hooks/use-websocket`,攔截 (path, options); + * - 手動觸發 options.onOpen 模擬 WS open → 斷言此時才 POST(順序保證) + * - 手動觸發 options.onMessage 模擬進度推送 → 斷言進 flash-store + * - 重連(onOpen 再次觸發)→ 斷言不重複 POST(hasStartedRef) + * + * 驗證重點(契約差異 B / 選項 2): + * - beginFlash 只設 enabled=true,POST 延到 onOpen 才送(先開 WS 再 POST) + * - 重連時的 onOpen 不重複 POST + * - path 帶 encode 過的 deviceId + */ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// 攔截 useWebSocket:存下最後一次的 (path, options),測試手動觸發 onOpen/onMessage。 +let lastCall: { + path: string; + options: { + enabled?: boolean; + onOpen?: () => void; + onMessage: (d: unknown) => void; + }; +} | null = null; + +vi.mock("@/hooks/use-websocket", () => ({ + useWebSocket: ( + path: string, + options: { + enabled?: boolean; + onOpen?: () => void; + onMessage: (d: unknown) => void; + }, + ) => { + lastCall = { path, options }; + return { send: vi.fn(), close: vi.fn() }; + }, +})); + +// mock flash-store 的 action,斷言呼叫;updateProgress / startFlash 都攔截。 +const mockStartFlash = vi.fn(); +const mockUpdateProgress = vi.fn(); +vi.mock("@/stores/flash-store", () => ({ + useFlashStore: (selector: (s: unknown) => unknown) => + selector({ + startFlash: mockStartFlash, + updateProgress: mockUpdateProgress, + }), +})); + +import { useFlashProgress } from "./use-flash-progress"; + +describe("useFlashProgress", () => { + beforeEach(() => { + lastCall = null; + mockStartFlash.mockReset(); + mockUpdateProgress.mockReset(); + }); + afterEach(() => vi.clearAllMocks()); + + it("path 帶 encode 過的 deviceId;初始 enabled=false", () => { + renderHook(() => useFlashProgress("a/b")); + expect(lastCall?.path).toBe("/ws/devices/a%2Fb/flash-progress"); + expect(lastCall?.options.enabled).toBe(false); + }); + + it("契約差異 B:beginFlash 只 enable WS,POST 延到 onOpen 才送(順序保證)", () => { + const { result, rerender } = renderHook(() => useFlashProgress("dev-1")); + + // beginFlash 當下不應 POST(WS 還沒 open) + act(() => result.current.beginFlash("model-7")); + expect(mockStartFlash).not.toHaveBeenCalled(); + + rerender(); + expect(lastCall?.options.enabled).toBe(true); + + // 模擬 WS open → 此時才 POST + act(() => lastCall!.options.onOpen?.()); + expect(mockStartFlash).toHaveBeenCalledTimes(1); + expect(mockStartFlash).toHaveBeenCalledWith("dev-1", "model-7"); + }); + + it("重連防重複 POST:onOpen 再次觸發不重複 POST(hasStartedRef)", () => { + const { result } = renderHook(() => useFlashProgress("dev-1")); + act(() => result.current.beginFlash("model-7")); + act(() => lastCall!.options.onOpen?.()); + // 模擬斷線重連 → onOpen 再次觸發 + act(() => lastCall!.options.onOpen?.()); + expect(mockStartFlash).toHaveBeenCalledTimes(1); + }); + + it("onMessage 進度推送 → 正規化後進 store", () => { + renderHook(() => useFlashProgress("dev-1")); + act(() => + lastCall!.options.onMessage({ percent: 60, stage: "loading", message: "半途" }), + ); + expect(mockUpdateProgress).toHaveBeenCalledWith({ + percent: 60, + stage: "loading", + message: "半途", + error: undefined, + }); + }); + + it("onMessage 只帶 error(無 percent)→ 仍進 store", () => { + renderHook(() => useFlashProgress("dev-1")); + act(() => lastCall!.options.onMessage({ error: "boom" })); + expect(mockUpdateProgress).toHaveBeenCalledWith({ + percent: 0, + stage: "", + message: undefined, + error: "boom", + }); + }); + + it("onMessage 非法形狀(無 percent 無 error)→ 忽略", () => { + renderHook(() => useFlashProgress("dev-1")); + act(() => lastCall!.options.onMessage({ foo: "bar" })); + expect(mockUpdateProgress).not.toHaveBeenCalled(); + }); + + it("stop 後 enabled=false", () => { + const { result, rerender } = renderHook(() => useFlashProgress("dev-1")); + act(() => result.current.beginFlash("m")); + rerender(); + expect(lastCall?.options.enabled).toBe(true); + act(() => result.current.stop()); + rerender(); + expect(lastCall?.options.enabled).toBe(false); + }); +}); diff --git a/visionA-frontend/src/hooks/use-flash-progress.ts b/visionA-frontend/src/hooks/use-flash-progress.ts new file mode 100644 index 0000000..640e871 --- /dev/null +++ b/visionA-frontend/src/hooks/use-flash-progress.ts @@ -0,0 +1,102 @@ +/** + * useFlashProgress — flash 進度 WS 訂閱 + 順序保證的 POST 觸發(塊:flash UI) + * + * 移植自 POC edge-ai-platform/frontend/src/hooks/use-flash-progress.ts(唯讀參考、非搬 code)。 + * + * ⚠️ 最關鍵的契約差異(見 flash-model-load-mapping.md §2.3-B,採「選項 2」): + * - POC `useFlashProgress` 提供 imperative `connectAndWait(): Promise`, + * dialog 先 `await connectAndWait()`(開 WS 並等 open)**再** `startFlash()`(POST)。 + * - visionA `useWebSocket(path, { enabled, onMessage, onOpen })` 是 **declarative** + * (靠 `enabled` false→true 觸發連線,無法回傳「已 open」的 promise)。 + * 若 `setEnabled(true)` 後立刻 POST → race(POST 已送、WS 還沒 open,漏早期 progress)。 + * + * **本 hook 的解法(選項 2,複用最多、無 race)**: + * 用 `useWebSocket` 的 `onOpen` callback 觸發 POST。呼叫端 `beginFlash(modelId)` 只設 + * `enabled=true` + 記下 modelId;WS 真正 open 後才在 `onOpen` 內 `startFlash(deviceId, modelId)`, + * 天然保證「先 open WS 再 POST」的順序(flash 很快就送 progress,順序錯會漏早期 percent)。 + * + * **重連防重複 POST(`hasStartedRef`)**: + * `onOpen` 在**每次連線成功**(含斷線重連)都會觸發。若不防護,重連會重複 POST flash。 + * 故用 `hasStartedRef` 記錄「本次 flash 已 POST 過」,重連時的 onOpen 不再 POST,只續收 progress。 + * + * 認證:same-origin cookie(visiona_session HttpOnly)自動帶,**不放 token 到 URL** + * (對齊 use-websocket.ts 安全決策 + security 對 token-in-URL 的 Critical 否決)。 + */ + +"use client"; + +import { useCallback, useRef, useState } from "react"; + +import { useWebSocket } from "@/hooks/use-websocket"; +import { type FlashProgress, useFlashStore } from "@/stores/flash-store"; + +interface UseFlashProgress { + /** + * 開始 flash:啟用 WS(`enabled=true`),WS open 後才 POST flash(順序保證)。 + * @param modelId 要 flash 的模型 id + */ + beginFlash: (modelId: string) => void; + /** 停止:關閉 WS、重置狀態(不再重連、不再 POST)。 */ + stop: () => void; + /** 目前是否已啟用 WS(供除錯 / 測試觀察)。 */ + isActive: boolean; +} + +export function useFlashProgress(deviceId: string): UseFlashProgress { + const updateProgress = useFlashStore((s) => s.updateProgress); + const startFlash = useFlashStore((s) => s.startFlash); + + // enabled 用 state 讓 useWebSocket 收到 false→true 變化 → 觸發連線。 + const [enabled, setEnabled] = useState(false); + + // 本次 flash 要送的 modelId(beginFlash 設定、onOpen 讀取)。 + const modelIdRef = useRef(null); + // 防重連重複 POST:本次 flash 已 POST 過就不再送。 + const hasStartedRef = useRef(false); + + const handleOpen = useCallback(() => { + // 只在「本次 flash 尚未 POST 過」時觸發 POST(重連時的 onOpen 會跳過)。 + if (hasStartedRef.current) return; + const modelId = modelIdRef.current; + if (!modelId) return; + hasStartedRef.current = true; + void startFlash(deviceId, modelId); + }, [deviceId, startFlash]); + + const handleMessage = useCallback( + (data: unknown) => { + const p = data as Partial; + // 防呆:非預期形狀(percent 非數字且無 error)直接忽略,不污染 store。 + if (typeof p?.percent !== "number" && typeof p?.error !== "string") { + return; + } + updateProgress({ + percent: typeof p.percent === "number" ? p.percent : 0, + stage: typeof p.stage === "string" ? p.stage : "", + message: typeof p.message === "string" ? p.message : undefined, + error: typeof p.error === "string" ? p.error : undefined, + }); + }, + [updateProgress], + ); + + useWebSocket(`/ws/devices/${encodeURIComponent(deviceId)}/flash-progress`, { + enabled, + onOpen: handleOpen, + onMessage: handleMessage, + }); + + const beginFlash = useCallback((modelId: string) => { + modelIdRef.current = modelId; + hasStartedRef.current = false; // 新一輪 flash,重置「已 POST」旗標 + setEnabled(true); + }, [setEnabled]); + + const stop = useCallback(() => { + modelIdRef.current = null; + hasStartedRef.current = false; + setEnabled(false); + }, [setEnabled]); + + return { beginFlash, stop, isActive: enabled }; +} diff --git a/visionA-frontend/src/lib/hardware-compat.test.ts b/visionA-frontend/src/lib/hardware-compat.test.ts new file mode 100644 index 0000000..6818749 --- /dev/null +++ b/visionA-frontend/src/lib/hardware-compat.test.ts @@ -0,0 +1,69 @@ +/** + * hardware-compat 單元測試(flash UI 契約層) + * + * 驗證重點(契約差異 C:targetChip 單值 + 寧鬆勿嚴): + * - device.type 各種變體正規化成小寫晶片代號 + * - 相容比對:相同晶片 → true、不同晶片 → false + * - 寧鬆勿嚴:targetChip=unknown / device 無法辨識 → true(不擋,交 agent 把關) + */ +import { describe, expect, it } from "vitest"; + +import { + getChipFromDeviceType, + getHardwareLabel, + isModelCompatible, +} from "./hardware-compat"; + +describe("getChipFromDeviceType", () => { + it.each([ + ["kneron_kl520", "kl520"], + ["kneron_kl720", "kl720"], + ["KL520", "kl520"], + ["kl630", "kl630"], + ["KL-730", "kl730"], + ["kl_520", "kl520"], + ])("正規化 %s → %s", (input, expected) => { + expect(getChipFromDeviceType(input)).toBe(expected); + }); + + it("空字串回空字串", () => { + expect(getChipFromDeviceType("")).toBe(""); + }); + + it("無法辨識時回小寫原字串", () => { + expect(getChipFromDeviceType("SomeUnknownDevice")).toBe("someunknowndevice"); + }); +}); + +describe("isModelCompatible", () => { + it("相同晶片 → 相容", () => { + expect(isModelCompatible("kl520", "kneron_kl520")).toBe(true); + expect(isModelCompatible("kl720", "KL720")).toBe(true); + }); + + it("不同晶片 → 不相容", () => { + expect(isModelCompatible("kl520", "kneron_kl720")).toBe(false); + expect(isModelCompatible("kl730", "KL520")).toBe(false); + }); + + it("寧鬆勿嚴:targetChip=unknown → 相容(不擋)", () => { + expect(isModelCompatible("unknown", "kneron_kl520")).toBe(true); + }); + + it("寧鬆勿嚴:device 無法辨識晶片 → 相容(不擋,交 agent 把關)", () => { + expect(isModelCompatible("kl520", "some-weird-device")).toBe(true); + expect(isModelCompatible("kl520", "")).toBe(true); + }); +}); + +describe("getHardwareLabel", () => { + it("辨識到晶片 → 回大寫代號", () => { + expect(getHardwareLabel("kneron_kl520")).toBe("KL520"); + expect(getHardwareLabel("kl720")).toBe("KL720"); + }); + + it("無法辨識 → 回原字串", () => { + expect(getHardwareLabel("weird")).toBe("weird"); + expect(getHardwareLabel("")).toBe(""); + }); +}); diff --git a/visionA-frontend/src/lib/hardware-compat.ts b/visionA-frontend/src/lib/hardware-compat.ts new file mode 100644 index 0000000..2ef270b --- /dev/null +++ b/visionA-frontend/src/lib/hardware-compat.ts @@ -0,0 +1,61 @@ +/** + * 硬體相容性檢查 — visionA Cloud(flash 前的 UX 早警示) + * + * 移植自 POC edge-ai-platform/frontend/src/lib/hardware-compat.ts(唯讀參考、非搬 code)。 + * + * ⚠️ 與 POC 的關鍵契約差異(見 flash-model-load-mapping.md §2.3-C): + * - POC model 用 `supportedHardware: string[]`(多值陣列),比對「device 晶片 ∈ 陣列」。 + * - visionA model 只有 `targetChip: TargetChip`(單值,見 model-store.ts:33),故改為單值比對。 + * + * 定位(重要):**這只是前端 UX 早警示**,避免使用者選了不相容 model 才 POST 被拒。 + * 真正的權威把關在 local agent(`flash/service.go isCompatible`)——即使前端誤放行, + * agent 仍會擋。因此前端邏輯**寧鬆勿嚴**(無法判定時預設相容),避免誤擋合法組合。 + * + * device.type 來源多樣(local agent 回報值 / 後端正規化值),可能是: + * - `kneron_kl520` / `kneron_kl720` …(driver type 前綴) + * - `KL520` / `kl520`(大小寫混用) + * 這裡統一正規化到小寫晶片代號(`kl520`…),與 `TargetChip` 對齊後比對。 + */ + +import type { TargetChip } from "@/stores/model-store"; + +/** + * 把 device.type 正規化成小寫晶片代號(如 `kl520`)。 + * 無法辨識時回傳原字串的小寫,交由呼叫端寬鬆處理(寧鬆勿嚴)。 + */ +export function getChipFromDeviceType(deviceType: string): string { + if (!deviceType) return ""; + const lower = deviceType.toLowerCase(); + // 抓出 kl 後接 3 碼數字的晶片代號(涵蓋 `kneron_kl520` / `KL520` / `kl-520` 等變體)。 + const match = lower.match(/kl\s*[-_]?\s*(\d{3})/); + if (match) return `kl${match[1]}`; + return lower; +} + +/** + * 判斷 model(單一 targetChip)是否與 device 相容。 + * + * 寧鬆勿嚴規則(前端只做早警示、權威在 agent): + * - model.targetChip 為 `unknown` → 視為相容(無法判定,不擋)。 + * - device.type 無法辨識出晶片代號 → 視為相容(無法判定,不擋)。 + * - 兩者都可辨識 → 嚴格比對晶片代號是否相同。 + */ +export function isModelCompatible( + targetChip: TargetChip, + deviceType: string, +): boolean { + if (targetChip === "unknown") return true; + const deviceChip = getChipFromDeviceType(deviceType); + // device 端無法辨識晶片 → 不擋(交給 agent 權威把關)。 + if (!deviceChip.startsWith("kl")) return true; + return deviceChip === targetChip; +} + +/** + * 給 UI 顯示用的裝置晶片代號(大寫,如 `KL520`);無法辨識時回傳原字串。 + */ +export function getHardwareLabel(deviceType: string): string { + const chip = getChipFromDeviceType(deviceType); + if (chip.startsWith("kl")) return chip.toUpperCase(); + return deviceType || ""; +} diff --git a/visionA-frontend/src/lib/i18n/dictionaries/en.ts b/visionA-frontend/src/lib/i18n/dictionaries/en.ts index d044696..d3221b0 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/en.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/en.ts @@ -18,6 +18,7 @@ export const en: Dictionary = { "common.confirm": "Confirm", "common.save": "Save", "common.close": "Close", + "common.done": "Done", "common.retry": "Retry", "common.view": "View", "common.manage": "Manage", @@ -172,6 +173,23 @@ export const en: Dictionary = { "devices.remove.error.NOT_FOUND": "This device no longer exists.", "devices.remove.error.unknown": "Something went wrong. Please try again.", + // ── Devices: flash (load model to device) ── + "devices.flash.flashModel": "Load model", + "devices.flash.flashToDevice": "Load a model to this device", + "devices.flash.dialogDesc": + "Pick a model from your library and load it onto the device. The device must have a model loaded before it can run inference.", + "devices.flash.selectModel": "Select a model", + "devices.flash.noModels": "No models available. Add one to your library first.", + "devices.flash.startFlash": "Load to device", + "devices.flash.hardwareIncompatible": "Model may not be compatible", + "devices.flash.incompatibleDesc": + "This model targets a different chip than {device}. You can still try, but the device may reject it.", + "devices.flash.incompatibleCannotFlash": "Incompatible model", + "devices.flash.thisDevice": "this device", + "devices.flash.preparingFlash": "Preparing to load…", + "devices.flash.flashComplete": "Model loaded successfully", + "devices.flash.flashFailed": "Failed to load model", + // ── Remote Device Badge ── "remote.status.online": "Online", "remote.status.offline": "Offline", @@ -300,9 +318,29 @@ export const en: Dictionary = { "The connection to {deviceName} was lost; inference has been stopped.", "workspace.offline.backToList": "Back to devices", "workspace.tabs.camera": "Camera", - "workspace.tabs.image": "Image (Phase 1)", - "workspace.tabs.video": "Video (Phase 1)", - "workspace.tabs.batch": "Batch (Phase 1)", + "workspace.tabs.image": "Image", + "workspace.tabs.video": "Video", + "workspace.tabs.batch": "Batch", + + // ── Workspace media upload (Block 3) ── + "workspace.media.browse": "Choose file", + "workspace.media.uploading": "Uploading…", + "workspace.media.cancel": "Cancel", + "workspace.media.uploadAnother": "Upload another", + "workspace.media.errorType": "Unsupported file: {filename}", + "workspace.media.errorSize": "File too large: {filename}", + "workspace.media.errorCount": "You can upload at most 50 images at once.", + "workspace.media.errorEmpty": "Please choose at least one file.", + "workspace.media.errorGeneric": "Upload failed. Please try again.", + "workspace.media.image.primary": "Drag an image here to run inference", + "workspace.media.image.hint": "JPG or PNG · up to 20 MB", + "workspace.media.video.primary": "Drag a video here to run inference", + "workspace.media.video.hint": "MP4 / AVI / MOV / MPEG · up to 200 MB", + "workspace.media.video.frame": "Frame", + "workspace.media.video.seek": "Seek video", + "workspace.media.batch.primary": "Drag images here (up to 50) for batch inference", + "workspace.media.batch.hint": "JPG or PNG · up to 50 files · 20 MB each", + "workspace.media.batch.progress": "Image {current} / {total}", // ── Settings ── "settings.title": "Settings", diff --git a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts index 92716a3..15705e5 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts @@ -21,6 +21,7 @@ export const zhHant: Dictionary = { "common.confirm": "確認", "common.save": "儲存", "common.close": "關閉", + "common.done": "完成", "common.retry": "重試", "common.view": "檢視", "common.manage": "管理", @@ -173,6 +174,23 @@ export const zhHant: Dictionary = { "devices.remove.error.NOT_FOUND": "此裝置已不存在", "devices.remove.error.unknown": "發生錯誤,請稍後再試", + // ── Devices: flash(載入模型到裝置) ── + "devices.flash.flashModel": "載入模型", + "devices.flash.flashToDevice": "載入模型到此裝置", + "devices.flash.dialogDesc": + "從模型庫挑一個模型載入到裝置。裝置必須先載入模型,才能開始推論。", + "devices.flash.selectModel": "選擇模型", + "devices.flash.noModels": "沒有可用的模型,請先在模型庫新增。", + "devices.flash.startFlash": "載入到裝置", + "devices.flash.hardwareIncompatible": "模型可能不相容", + "devices.flash.incompatibleDesc": + "此模型的目標晶片與 {device} 不同。仍可嘗試,但裝置可能拒絕載入。", + "devices.flash.incompatibleCannotFlash": "模型不相容", + "devices.flash.thisDevice": "此裝置", + "devices.flash.preparingFlash": "準備載入中…", + "devices.flash.flashComplete": "模型載入成功", + "devices.flash.flashFailed": "模型載入失敗", + // ── Remote Device Badge(雲端 tunnel 狀態) ── "remote.status.online": "在線", "remote.status.offline": "離線", @@ -289,9 +307,29 @@ export const zhHant: Dictionary = { "workspace.offline.description": "與 {deviceName} 的連線中斷,推論已自動停止", "workspace.offline.backToList": "返回裝置列表", "workspace.tabs.camera": "Camera", - "workspace.tabs.image": "圖片(Phase 1)", - "workspace.tabs.video": "影片(Phase 1)", - "workspace.tabs.batch": "批次(Phase 1)", + "workspace.tabs.image": "圖片", + "workspace.tabs.video": "影片", + "workspace.tabs.batch": "批次", + + // ── 推論工作區媒體上傳(塊 3) ── + "workspace.media.browse": "選擇檔案", + "workspace.media.uploading": "上傳中…", + "workspace.media.cancel": "取消", + "workspace.media.uploadAnother": "重新上傳", + "workspace.media.errorType": "不支援的檔案:{filename}", + "workspace.media.errorSize": "檔案過大:{filename}", + "workspace.media.errorCount": "一次最多上傳 50 張圖片。", + "workspace.media.errorEmpty": "請至少選擇一個檔案。", + "workspace.media.errorGeneric": "上傳失敗,請再試一次。", + "workspace.media.image.primary": "將圖片拖曳到此處開始推論", + "workspace.media.image.hint": "JPG 或 PNG · 上限 20 MB", + "workspace.media.video.primary": "將影片拖曳到此處開始推論", + "workspace.media.video.hint": "MP4 / AVI / MOV / MPEG · 上限 200 MB", + "workspace.media.video.frame": "影格", + "workspace.media.video.seek": "影片跳轉", + "workspace.media.batch.primary": "將圖片拖曳到此處(最多 50 張)進行批次推論", + "workspace.media.batch.hint": "JPG 或 PNG · 最多 50 張 · 每張上限 20 MB", + "workspace.media.batch.progress": "第 {current} / {total} 張", // ── Settings ── "settings.title": "設定", diff --git a/visionA-frontend/src/lib/media.test.ts b/visionA-frontend/src/lib/media.test.ts new file mode 100644 index 0000000..840c495 --- /dev/null +++ b/visionA-frontend/src/lib/media.test.ts @@ -0,0 +1,246 @@ +/** + * media.ts 單元測試(塊 3) + * + * 驗證: + * - validateImageFile / validateVideoFile / validateBatchFiles 各種通過 / 失敗路徑 + * - buildBatchImageUrl 組路徑(含 / 不含 cacheBust) + * - uploadImage / uploadBatchImages / seekVideo 打對的路徑與 FormData 欄位 + * (mock XMLHttpRequest + api.post) + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + MAX_BATCH_IMAGES, + VIDEO_FALLBACK_FPS, + buildBatchImageUrl, + frameToSeekSeconds, + seekVideo, + uploadBatchImages, + uploadImage, + validateBatchFiles, + validateImageFile, + validateVideoFile, +} from "./media"; + +function makeFile(name: string, sizeBytes = 1024): File { + const blob = new Blob([new Uint8Array(sizeBytes)], { type: "application/octet-stream" }); + return new File([blob], name); +} + +describe("media validation", () => { + it("圖片:JPG/PNG 通過,其他型別擋 TYPE", () => { + expect(validateImageFile(makeFile("a.jpg"))).toBeNull(); + expect(validateImageFile(makeFile("a.jpeg"))).toBeNull(); + expect(validateImageFile(makeFile("a.PNG"))).toBeNull(); + expect(validateImageFile(makeFile("a.gif"))?.code).toBe("TYPE"); + expect(validateImageFile(makeFile("a.mp4"))?.code).toBe("TYPE"); + }); + + it("圖片:超過 20 MB 擋 SIZE", () => { + const big = makeFile("a.jpg", 21 * 1024 * 1024); + expect(validateImageFile(big)?.code).toBe("SIZE"); + }); + + it("影片:MP4/AVI/MOV/MPEG 通過,其他擋 TYPE", () => { + expect(validateVideoFile(makeFile("v.mp4"))).toBeNull(); + expect(validateVideoFile(makeFile("v.mov"))).toBeNull(); + expect(validateVideoFile(makeFile("v.mpeg"))).toBeNull(); + expect(validateVideoFile(makeFile("v.jpg"))?.code).toBe("TYPE"); + }); + + it("批次:空陣列 → EMPTY,超過 50 → COUNT,含非圖 → TYPE", () => { + expect(validateBatchFiles([])?.code).toBe("EMPTY"); + + const tooMany = Array.from({ length: MAX_BATCH_IMAGES + 1 }, (_, i) => + makeFile(`img${i}.jpg`), + ); + expect(validateBatchFiles(tooMany)?.code).toBe("COUNT"); + + expect( + validateBatchFiles([makeFile("ok.jpg"), makeFile("bad.txt")])?.code, + ).toBe("TYPE"); + + expect(validateBatchFiles([makeFile("a.jpg"), makeFile("b.png")])).toBeNull(); + }); +}); + +describe("frameToSeekSeconds", () => { + it("有 durationSeconds + totalFrames → 用 duration 換算(不依賴 fps)", () => { + // 100 frames / 10 秒 → frame 50 = 5 秒 + expect(frameToSeekSeconds(50, 100, 10)).toBe(5); + // frame 0 = 0 秒 + expect(frameToSeekSeconds(0, 100, 10)).toBe(0); + // 換算與 fps 常數無關:即使 fps 常數是 15,這裡仍用 duration + expect(frameToSeekSeconds(30, 60, 12)).toBe(6); + }); + + it("缺 durationSeconds → 退用具名 fps 常數(frame / VIDEO_FALLBACK_FPS)", () => { + expect(frameToSeekSeconds(30, 100, undefined)).toBe(30 / VIDEO_FALLBACK_FPS); + // duration <= 0 也視為缺 + expect(frameToSeekSeconds(30, 100, 0)).toBe(30 / VIDEO_FALLBACK_FPS); + }); + + it("缺 totalFrames → 退用 fps 常數", () => { + expect(frameToSeekSeconds(45, undefined, 10)).toBe(45 / VIDEO_FALLBACK_FPS); + }); + + it("負 frame → clamp 為 0", () => { + expect(frameToSeekSeconds(-5, 100, 10)).toBe(0); + expect(frameToSeekSeconds(-5, undefined, undefined)).toBe(0); + }); +}); + +describe("buildBatchImageUrl", () => { + // jsdom 有 window 且未設 NEXT_PUBLIC_API_BASE → getApiBaseUrl 回 ""(同 origin 相對路徑) + it("組出 /api/media/batch-images/:index(無 cacheBust)", () => { + expect(buildBatchImageUrl(3)).toBe("/api/media/batch-images/3"); + }); + + it("帶 cacheBust 加上 _t query", () => { + expect(buildBatchImageUrl(0, "abc")).toBe("/api/media/batch-images/0?_t=abc"); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Upload(mock XMLHttpRequest) */ +/* -------------------------------------------------------------------------- */ + +interface FakeXHR { + method?: string; + url?: string; + withCredentials?: boolean; + timeout?: number; + sent?: FormData; + status: number; + responseText: string; + upload: { onprogress: ((ev: ProgressEvent) => void) | null }; + onload: (() => void) | null; + onerror: (() => void) | null; + ontimeout: (() => void) | null; + open(method: string, url: string): void; + send(body: FormData): void; + abort(): void; +} + +let lastXhr: FakeXHR | null = null; + +function installXhrMock(responder: (xhr: FakeXHR) => void) { + // 以工廠回傳 plain object(closure over `xhr`),避免 class + `this` 別名(no-this-alias)。 + function XHRMock(this: unknown) { + const xhr: FakeXHR = { + withCredentials: false, + timeout: 0, + status: 200, + responseText: "", + upload: { onprogress: null }, + onload: null, + onerror: null, + ontimeout: null, + open(method: string, url: string) { + xhr.method = method; + xhr.url = url; + }, + send(body: FormData) { + xhr.sent = body; + queueMicrotask(() => responder(xhr)); + }, + abort() {}, + }; + lastXhr = xhr; + return xhr; + } + (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = + XHRMock as unknown as typeof XMLHttpRequest; +} + +describe("uploadImage / uploadBatchImages(XHR mock)", () => { + const origXHR = globalThis.XMLHttpRequest; + beforeEach(() => { + lastXhr = null; + }); + afterEach(() => { + (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = origXHR; + }); + + it("uploadImage:POST /api/media/upload/image、帶 deviceId+file、回傳 data", async () => { + installXhrMock((xhr) => { + xhr.status = 200; + xhr.responseText = JSON.stringify({ + success: true, + data: { streamUrl: "/api/camera/stream", sourceType: "image", filename: "a.jpg" }, + }); + xhr.onload?.(); + }); + + const res = await uploadImage("dev-1", makeFile("a.jpg")); + expect(res.sourceType).toBe("image"); + expect(res.streamUrl).toBe("/api/camera/stream"); + expect(lastXhr?.method).toBe("POST"); + expect(lastXhr?.url).toContain("/api/media/upload/image"); + expect(lastXhr?.withCredentials).toBe(true); + expect(lastXhr?.sent?.get("deviceId")).toBe("dev-1"); + expect(lastXhr?.sent?.get("file")).toBeInstanceOf(File); + }); + + it("uploadBatchImages:多檔以 files 欄位附加", async () => { + installXhrMock((xhr) => { + xhr.status = 200; + xhr.responseText = JSON.stringify({ + success: true, + data: { streamUrl: "/api/camera/stream", sourceType: "batch_image", totalImages: 2 }, + }); + xhr.onload?.(); + }); + + const res = await uploadBatchImages("dev-1", [makeFile("a.jpg"), makeFile("b.png")]); + expect(res.totalImages).toBe(2); + expect(lastXhr?.url).toContain("/api/media/upload/batch-images"); + expect(lastXhr?.sent?.getAll("files")).toHaveLength(2); + }); + + it("非 2xx 且有 error envelope → 拋 ApiError(保留 code)", async () => { + installXhrMock((xhr) => { + xhr.status = 400; + xhr.responseText = JSON.stringify({ + success: false, + error: { code: "BAD_REQUEST", message: "only JPG/PNG files are supported" }, + }); + xhr.onload?.(); + }); + + await expect(uploadImage("dev-1", makeFile("a.jpg"))).rejects.toMatchObject({ + code: "BAD_REQUEST", + status: 400, + }); + }); + + it("回報上傳進度", async () => { + installXhrMock((xhr) => { + xhr.upload.onprogress?.({ lengthComputable: true, loaded: 50, total: 100 } as ProgressEvent); + xhr.status = 200; + xhr.responseText = JSON.stringify({ + success: true, + data: { streamUrl: "/s", sourceType: "image" }, + }); + xhr.onload?.(); + }); + + const onProgress = vi.fn(); + await uploadImage("dev-1", makeFile("a.jpg"), { onProgress }); + expect(onProgress).toHaveBeenCalledWith(50); + }); +}); + +describe("seekVideo", () => { + it("POST /api/media/seek,body 帶 timeSeconds", async () => { + const apiModule = await import("@/lib/api"); + const spy = vi + .spyOn(apiModule.api, "post") + .mockResolvedValue({ seekTo: 3, frameOffset: 45 } as never); + + const res = await seekVideo(3); + expect(spy).toHaveBeenCalledWith("/api/media/seek", { timeSeconds: 3 }); + expect(res).toEqual({ seekTo: 3, frameOffset: 45 }); + spy.mockRestore(); + }); +}); diff --git a/visionA-frontend/src/lib/media.ts b/visionA-frontend/src/lib/media.ts new file mode 100644 index 0000000..a26e4d9 --- /dev/null +++ b/visionA-frontend/src/lib/media.ts @@ -0,0 +1,301 @@ +/** + * Media 上傳 / 推論工具 — visionA Cloud 前端(塊 3:圖片 / 影片 / 批次) + * + * 職責: + * - `uploadMedia()`:以 multipart/form-data 上傳單檔(圖片 / 影片)到 + * `/api/media/upload/image` | `/api/media/upload/video`,回傳解開 envelope 的 + * `MediaUploadResponse`;支援上傳進度 + AbortSignal(大檔影片可取消)。 + * - `uploadBatchImages()`:多檔(≤ MAX_BATCH_IMAGES)上傳到 + * `/api/media/upload/batch-images`(欄位名 `files`,對齊 camera_handler.go:354)。 + * - `seekVideo()`:`POST /api/media/seek`,body `{ timeSeconds }`(對齊 SeekVideo:541-547)。 + * - `buildBatchImageUrl()`:組 `GET /api/media/batch-images/:index` 的完整 URL(回單張 jpeg)。 + * + * 為什麼不直接用 `api.upload`: + * `api.upload` 的 body 是單一 Blob(用於 presigned PUT 直送 storage), + * media 端需要 multipart form 帶額外欄位(`deviceId` + `file` / `files`), + * 故這裡自寫 XHR + FormData。認證與 `api.upload` 一致:same-origin cookie + * (`withCredentials = true`),不放 token 到 URL(對齊 lib/camera.ts / use-websocket §10)。 + * + * 顯示管線:上傳成功後拿 `streamUrl` → 走 CameraFeed(MJPEG ``)+ overlay + WS 面板, + * 與 camera / 塊 1、2 完全共用(見 .autoflow/04-architecture/camera-e2e-effort-estimate.md §0.1)。 + */ + +import { + AbortError, + ApiError, + NetworkError, + TimeoutError, + api, + getApiBaseUrl, +} from "@/lib/api"; +import type { ApiErrorShape } from "@/types/api"; +import type { MediaUploadResponse, SeekResponse } from "@/types/camera"; + +/** 單檔上傳(圖片 / 影片)的後端路徑。 */ +export const MEDIA_UPLOAD_IMAGE_PATH = "/api/media/upload/image"; +export const MEDIA_UPLOAD_VIDEO_PATH = "/api/media/upload/video"; +export const MEDIA_UPLOAD_BATCH_PATH = "/api/media/upload/batch-images"; +export const MEDIA_SEEK_PATH = "/api/media/seek"; +export const MEDIA_BATCH_FRAME_PATH = "/api/media/batch-images"; + +/** 批次最多張數(對齊 camera_handler.go:359 的 50 上限)。 */ +export const MAX_BATCH_IMAGES = 50; + +/** + * 影片播放取樣 fps 的 fallback 值。 + * + * ⚠️ 契約備註:seek API(POST /api/media/seek)body 是 `{ timeSeconds }`(秒數), + * 但 WS 進度只給 frameIndex/totalFrames(無秒數)。把 frame → 秒需要 fps 或 duration。 + * 後端 upload video response 有回 `durationSeconds`(camera_handler.go:333), + * **優先用 duration 換算**(frame/totalFrames × durationSeconds),完全不依賴 fps。 + * + * 只有當後端沒回 durationSeconds(舊版 / 探測失敗)時,才退用此 fps 常數。 + * 此值必須與後端 `camera_handler.go` 的 `h.videoFPS = 15`(line 303)同步; + * 後端改 fps 時這裡也要跟著改(後端 response 目前不帶 fps,故無法自動同步)。 + */ +export const VIDEO_FALLBACK_FPS = 15; + +/** + * 把 seek bar 的 frame 位置換算成後端 seek 需要的秒數。 + * + * @param frame 目標 frame index(0-based) + * @param totalFrames 影片總 frame 數(WS 進度回報) + * @param durationSeconds 影片總長秒數(upload response 回報;可能為 undefined) + * @returns seek 目標秒數(≥ 0) + * + * 換算優先序: + * 1. 有 durationSeconds + totalFrames → `frame / totalFrames × durationSeconds` + * (只用後端回的欄位,無 fps 隱性耦合) + * 2. 否則退用 `frame / VIDEO_FALLBACK_FPS`(具名常數,需與後端 fps 同步) + */ +export function frameToSeekSeconds( + frame: number, + totalFrames?: number, + durationSeconds?: number, +): number { + if ( + typeof durationSeconds === "number" && + durationSeconds > 0 && + typeof totalFrames === "number" && + totalFrames > 0 + ) { + return Math.max(0, (frame / totalFrames) * durationSeconds); + } + return Math.max(0, frame / VIDEO_FALLBACK_FPS); +} + +/** 圖片副檔名白名單(對齊 UploadImage:155 / UploadBatchImages:369)。 */ +export const IMAGE_ACCEPT = ".jpg,.jpeg,.png"; +/** 影片副檔名白名單(對齊 UploadVideo:249)。 */ +export const VIDEO_ACCEPT = ".mp4,.avi,.mov,.mpeg,.mpg"; + +/** 前端上傳大小上限(防呆;影片經 tunnel 有 timeout 考量,見評估 R-M2)。 */ +export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB +export const MAX_VIDEO_BYTES = 200 * 1024 * 1024; // 200 MB + +export interface UploadMediaOptions { + /** 上傳進度 callback(0~100) */ + onProgress?: (percent: number) => void; + /** 取消訊號(使用者按取消 / 切 tab / unmount) */ + signal?: AbortSignal; + /** 覆寫 timeout(毫秒);預設影片較長,見 caller。傳 0 關閉 timeout。 */ + timeoutMs?: number; +} + +/** + * 以 XHR + FormData 上傳 multipart,解析後端統一 envelope。 + * + * @param path 後端相對路徑 + * @param form 已組好的 FormData(含 deviceId + file/files) + * @param options 進度 / 取消 / timeout + * @returns 解開 envelope 的 `data`(MediaUploadResponse) + * @throws ApiError | NetworkError | TimeoutError | AbortError(與 api.ts 一致的錯誤體系) + */ +function postMultipart( + path: string, + form: FormData, + options: UploadMediaOptions = {}, +): Promise { + const url = `${getApiBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`; + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", url, true); + // same-origin cookie(BFF session);不設 Content-Type,讓瀏覽器帶 multipart boundary + xhr.withCredentials = true; + + if (options.timeoutMs && options.timeoutMs > 0) { + xhr.timeout = options.timeoutMs; + } + + if (options.onProgress) { + xhr.upload.onprogress = (ev) => { + if (ev.lengthComputable) { + options.onProgress!(Math.min(100, Math.round((ev.loaded / ev.total) * 100))); + } + }; + } + + xhr.onload = () => { + let parsed: unknown = null; + try { + parsed = xhr.responseText ? JSON.parse(xhr.responseText) : null; + } catch { + // 非 JSON body + } + + if (xhr.status >= 200 && xhr.status < 300) { + if ( + parsed && + typeof parsed === "object" && + "success" in parsed && + (parsed as { success: boolean }).success === true && + "data" in parsed + ) { + resolve((parsed as { data: MediaUploadResponse }).data); + return; + } + reject( + new ApiError(xhr.status, { + code: "PARSE_ERROR", + message: "Unexpected upload response shape", + }), + ); + return; + } + + // non-2xx:盡量取 envelope 的 error + let errShape: ApiErrorShape = { + code: xhr.status === 401 ? "UNAUTHORIZED" : "INTERNAL_ERROR", + message: `Upload failed: HTTP ${xhr.status}`, + }; + if ( + parsed && + typeof parsed === "object" && + "error" in parsed && + (parsed as { error?: unknown }).error + ) { + errShape = (parsed as { error: ApiErrorShape }).error; + } + reject(new ApiError(xhr.status, errShape)); + }; + + xhr.onerror = () => reject(new NetworkError(`Upload to ${url} failed`)); + xhr.ontimeout = () => reject(new TimeoutError(`Upload to ${url} timed out`)); + + if (options.signal) { + if (options.signal.aborted) { + reject(new AbortError()); + return; + } + options.signal.addEventListener( + "abort", + () => { + xhr.abort(); + reject(new AbortError()); + }, + { once: true }, + ); + } + + xhr.send(form); + }); +} + +/** 上傳單張圖片 → 開始推論;回傳含 streamUrl 的 response。 */ +export function uploadImage( + deviceId: string, + file: File, + options?: UploadMediaOptions, +): Promise { + const form = new FormData(); + form.append("deviceId", deviceId); + form.append("file", file); + return postMultipart(MEDIA_UPLOAD_IMAGE_PATH, form, options); +} + +/** 上傳單支影片 → 開始逐 frame 推論;回傳含 streamUrl / totalFrames / durationSeconds。 */ +export function uploadVideo( + deviceId: string, + file: File, + options?: UploadMediaOptions, +): Promise { + const form = new FormData(); + form.append("deviceId", deviceId); + form.append("file", file); + return postMultipart(MEDIA_UPLOAD_VIDEO_PATH, form, options); +} + +/** 上傳多張圖(batch)→ 逐張推論;回傳含 batchId / totalImages / images[]。 */ +export function uploadBatchImages( + deviceId: string, + files: File[], + options?: UploadMediaOptions, +): Promise { + const form = new FormData(); + form.append("deviceId", deviceId); + for (const f of files) { + form.append("files", f); + } + return postMultipart(MEDIA_UPLOAD_BATCH_PATH, form, options); +} + +/** + * 影片 seek — 跳到指定秒數並從該處重啟推論。 + * body `{ timeSeconds }`(對齊 SeekVideo:541-547;後端以秒數 clamp,非 frameIndex)。 + */ +export function seekVideo(timeSeconds: number): Promise { + return api.post(MEDIA_SEEK_PATH, { timeSeconds }); +} + +/** + * 組批次單張結果圖 URL(`GET /api/media/batch-images/:index` 回單張 jpeg)。 + * + * @param index 批次內索引(0-based) + * @param cacheBust 可選 cache-busting(切換到同 index 但結果已更新時避免瀏覽器用舊圖) + */ +export function buildBatchImageUrl(index: number, cacheBust?: string): string { + const base = getApiBaseUrl(); + const full = `${base}${MEDIA_BATCH_FRAME_PATH}/${index}`; + if (!cacheBust) return full; + return `${full}?_t=${encodeURIComponent(cacheBust)}`; +} + +/** 前端檔案驗證結果(給 UI 顯示錯誤用;不信任副檔名,也擋大小)。 */ +export interface FileValidationError { + code: "TYPE" | "SIZE" | "COUNT" | "EMPTY"; + filename?: string; +} + +/** 依副檔名 + 大小驗證單張圖片。回傳 null 表通過。 */ +export function validateImageFile(file: File): FileValidationError | null { + if (!/\.(jpe?g|png)$/i.test(file.name)) { + return { code: "TYPE", filename: file.name }; + } + if (file.size > MAX_IMAGE_BYTES) { + return { code: "SIZE", filename: file.name }; + } + return null; +} + +/** 依副檔名 + 大小驗證影片。回傳 null 表通過。 */ +export function validateVideoFile(file: File): FileValidationError | null { + if (!/\.(mp4|avi|mov|mpe?g)$/i.test(file.name)) { + return { code: "TYPE", filename: file.name }; + } + if (file.size > MAX_VIDEO_BYTES) { + return { code: "SIZE", filename: file.name }; + } + return null; +} + +/** 驗證整批圖片(數量 + 每張型別 / 大小)。回傳 null 表通過。 */ +export function validateBatchFiles(files: File[]): FileValidationError | null { + if (files.length === 0) return { code: "EMPTY" }; + if (files.length > MAX_BATCH_IMAGES) return { code: "COUNT" }; + for (const f of files) { + const err = validateImageFile(f); + if (err) return err; + } + return null; +} diff --git a/visionA-frontend/src/stores/flash-store.test.ts b/visionA-frontend/src/stores/flash-store.test.ts new file mode 100644 index 0000000..0fde7ea --- /dev/null +++ b/visionA-frontend/src/stores/flash-store.test.ts @@ -0,0 +1,137 @@ +/** + * flash-store 單元測試(flash UI 契約層) + * + * 驗證重點: + * - 契約差異 A(throw-based):startFlash 成功 / api throw ApiError 時 setError + * - updateProgress:error → 失敗態、percent>=100 → 完成、其餘 → 更新 + * - retryFlash 用 lastFlashParams + * - reset + * - activity-store 副作用(flash_start / flash_complete / flash_error) + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useActivityStore } from "@/stores/activity-store"; + +// mock HTTP client(startFlash 依賴 api.post);保留 ApiError 讓 catch 分支可用。 +vi.mock("@/lib/api", async () => { + const actual = await vi.importActual("@/lib/api"); + return { + ...actual, + api: { ...actual.api, post: vi.fn() }, + }; +}); + +import { ApiError, api } from "@/lib/api"; +import { useFlashStore } from "./flash-store"; + +const mockPost = vi.mocked(api.post); + +function reset() { + useFlashStore.setState({ + isFlashing: false, + progress: null, + error: null, + lastFlashParams: null, + }); + useActivityStore.setState({ activities: [] }); +} + +describe("flash-store", () => { + beforeEach(reset); + afterEach(() => vi.clearAllMocks()); + + it("初始態", () => { + const s = useFlashStore.getState(); + expect(s.isFlashing).toBe(false); + expect(s.progress).toBeNull(); + expect(s.error).toBeNull(); + }); + + it("startFlash 成功:POST 帶 modelId、記 lastFlashParams、加 flash_start activity", async () => { + mockPost.mockResolvedValueOnce({ taskId: "t-1" }); + await useFlashStore.getState().startFlash("dev-1", "model-9"); + + expect(mockPost).toHaveBeenCalledWith("/api/devices/dev-1/flash", { + modelId: "model-9", + }); + const s = useFlashStore.getState(); + expect(s.isFlashing).toBe(true); // 觸發成功,等 WS 推完成 + expect(s.error).toBeNull(); + expect(s.lastFlashParams).toEqual({ deviceId: "dev-1", modelId: "model-9" }); + expect(useActivityStore.getState().activities[0]?.type).toBe("flash_start"); + }); + + it("契約差異 A:api throw ApiError → setError + flash_error activity", async () => { + mockPost.mockRejectedValueOnce( + new ApiError(400, { code: "FLASH_FAILED", message: "device busy" }), + ); + await useFlashStore.getState().startFlash("dev-1", "model-9"); + + const s = useFlashStore.getState(); + expect(s.isFlashing).toBe(false); + expect(s.error).toBe("device busy"); + expect(useActivityStore.getState().activities[0]?.type).toBe("flash_error"); + }); + + it("startFlash: deviceId 特殊字元 encode", async () => { + mockPost.mockResolvedValueOnce({}); + await useFlashStore.getState().startFlash("a/b", "m1"); + expect(mockPost).toHaveBeenCalledWith("/api/devices/a%2Fb/flash", { + modelId: "m1", + }); + }); + + it("updateProgress: error 有值 → 失敗態", () => { + useFlashStore.setState({ isFlashing: true, lastFlashParams: { deviceId: "d", modelId: "m" } }); + useFlashStore.getState().updateProgress({ percent: 40, stage: "loading", error: "boom" }); + const s = useFlashStore.getState(); + expect(s.isFlashing).toBe(false); + expect(s.error).toBe("boom"); + expect(useActivityStore.getState().activities[0]?.type).toBe("flash_error"); + }); + + it("updateProgress: percent<100 → 更新 progress、仍 flashing", () => { + useFlashStore.setState({ isFlashing: true }); + useFlashStore.getState().updateProgress({ percent: 55, stage: "loading", message: "半途" }); + const s = useFlashStore.getState(); + expect(s.progress?.percent).toBe(55); + expect(s.isFlashing).toBe(true); + }); + + it("updateProgress: percent>=100 → 完成、加 flash_complete activity", () => { + useFlashStore.setState({ isFlashing: true, lastFlashParams: { deviceId: "d", modelId: "m" } }); + useFlashStore.getState().updateProgress({ percent: 100, stage: "done" }); + const s = useFlashStore.getState(); + expect(s.isFlashing).toBe(false); + expect(useActivityStore.getState().activities[0]?.type).toBe("flash_complete"); + }); + + it("retryFlash 用 lastFlashParams 再 POST 一次", async () => { + mockPost.mockRejectedValueOnce( + new ApiError(400, { code: "FLASH_FAILED", message: "x" }), + ); + await useFlashStore.getState().startFlash("dev-2", "model-2"); + mockPost.mockResolvedValueOnce({}); + await useFlashStore.getState().retryFlash(); + expect(mockPost).toHaveBeenLastCalledWith("/api/devices/dev-2/flash", { + modelId: "model-2", + }); + }); + + it("retryFlash: 無 lastFlashParams → no-op", async () => { + await useFlashStore.getState().retryFlash(); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("reset 回初始態", () => { + useFlashStore.setState({ + isFlashing: true, + progress: { percent: 50, stage: "x" }, + error: "e", + lastFlashParams: { deviceId: "d", modelId: "m" }, + }); + useFlashStore.getState().reset(); + const s = useFlashStore.getState(); + expect(s).toMatchObject({ isFlashing: false, progress: null, error: null, lastFlashParams: null }); + }); +}); diff --git a/visionA-frontend/src/stores/flash-store.ts b/visionA-frontend/src/stores/flash-store.ts new file mode 100644 index 0000000..8484df0 --- /dev/null +++ b/visionA-frontend/src/stores/flash-store.ts @@ -0,0 +1,150 @@ +/** + * Flash Store — visionA Cloud(載入模型到裝置 / flash) + * + * 移植自 POC edge-ai-platform/frontend/src/stores/flash-store.ts(唯讀參考、非搬 code)。 + * + * 對齊: + * - 契約:flash-model-load-mapping.md §3.2(REST + WS 契約,由 local agent 既有實作定死) + * - api client:api.ts(throw-based,見下方契約差異 A) + * + * ⚠️ 與 POC 的契約差異(見 flash-model-load-mapping.md §2.3-A): + * POC 用 `const res = await api.post(...); if (!res.success) {...}`(result-based)。 + * visionA `api.post()` 成功回 data、失敗 throw `ApiError`(api.ts:477-481)。 + * → `startFlash` 改用 try/catch:catch `ApiError` 時 `setError(e.message)`。 + * + * flash 完成 / 失敗判定(消費 WS progress,見 §3.2 前端消費契約): + * - `error` 有值 → 失敗態、`isFlashing=false`。 + * - `percent >= 100` 且無 error → 完成、`isFlashing=false`(UI 端負責 fetchDevice 刷 gate)。 + * - 其餘 → 更新 progress。 + */ + +"use client"; + +import { create } from "zustand"; + +import { ApiError, api } from "@/lib/api"; +import { useActivityStore } from "@/stores/activity-store"; + +/* -------------------------------------------------------------------------- */ +/* Types — 對齊 §3.2 WS payload(raw JSON,api-server 透明轉發、不含 envelope) */ +/* -------------------------------------------------------------------------- */ + +/** + * flash 進度(driver.FlashProgress 的前端鏡像,見 interface.go:42-47)。 + * - percent:0-100 + * - stage:當前階段(如 "connecting" / "loading" / "verifying") + * - message:選填,人類可讀描述 + * - error:選填,有值即失敗 + */ +export interface FlashProgress { + percent: number; + stage: string; + message?: string; + error?: string; +} + +/** `POST /api/devices/:id/flash` 回傳(envelope 已由 api.post 解開,剩 data)。 */ +interface FlashResponse { + taskId?: string; +} + +interface FlashState { + isFlashing: boolean; + progress: FlashProgress | null; + error: string | null; + lastFlashParams: { deviceId: string; modelId: string } | null; + + /** 觸發 flash:`POST /api/devices/:id/flash { modelId }`(throw-based)。 */ + startFlash: (deviceId: string, modelId: string) => Promise; + /** 消費 WS 推來的進度(error / 完成 / 更新)。 */ + updateProgress: (progress: FlashProgress) => void; + /** 直接設定錯誤(例如 WS 斷線 / 前端層錯誤)。 */ + setError: (error: string) => void; + /** 用上一次參數重試。 */ + retryFlash: () => Promise; + /** 重置到初始態(開 dialog / 關 dialog 時呼叫)。 */ + reset: () => void; +} + +export const useFlashStore = create()((set, get) => ({ + isFlashing: false, + progress: null, + error: null, + lastFlashParams: null, + + startFlash: async (deviceId, modelId) => { + set({ + isFlashing: true, + progress: null, + error: null, + lastFlashParams: { deviceId, modelId }, + }); + try { + await api.post( + `/api/devices/${encodeURIComponent(deviceId)}/flash`, + { modelId }, + ); + // POST 成功只代表「觸發成功」,實際進度 / 完成由 WS 推送。 + useActivityStore.getState().addActivity({ + type: "flash_start", + message: "Flash started", + deviceId, + modelId, + }); + } catch (err) { + // api client 為 throw-based:ApiError 帶 backend code/message;其他退化成 Error。 + const message = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : String(err); + set({ isFlashing: false, error: message }); + useActivityStore.getState().addActivity({ + type: "flash_error", + message: `Flash failed: ${message}`, + deviceId, + modelId, + }); + } + }, + + updateProgress: (progress) => { + if (progress.error) { + set({ isFlashing: false, error: progress.error, progress }); + const { lastFlashParams } = get(); + useActivityStore.getState().addActivity({ + type: "flash_error", + message: `Flash failed: ${progress.error}`, + deviceId: lastFlashParams?.deviceId, + modelId: lastFlashParams?.modelId, + }); + return; + } + set({ progress }); + if (progress.percent >= 100) { + set({ isFlashing: false }); + const { lastFlashParams } = get(); + useActivityStore.getState().addActivity({ + type: "flash_complete", + message: "Flash completed", + deviceId: lastFlashParams?.deviceId, + modelId: lastFlashParams?.modelId, + }); + } + }, + + setError: (error) => { + set({ error, isFlashing: false }); + }, + + retryFlash: async () => { + const { lastFlashParams } = get(); + if (!lastFlashParams) return; + await get().startFlash(lastFlashParams.deviceId, lastFlashParams.modelId); + }, + + reset: () => { + set({ isFlashing: false, progress: null, error: null, lastFlashParams: null }); + }, +})); diff --git a/visionA-frontend/src/types/camera.ts b/visionA-frontend/src/types/camera.ts index 9972aaf..66bd003 100644 --- a/visionA-frontend/src/types/camera.ts +++ b/visionA-frontend/src/types/camera.ts @@ -40,7 +40,10 @@ export interface StreamState { /** * Media 上傳 / Camera 啟動的共用 response data。 * - * 對齊 camera_handler.go 各 handler 的 `data` 欄位(streamUrl 必有,其餘依來源)。 + * 對齊 camera_handler.go 各 handler 的 `data` 欄位(streamUrl 必有,其餘依來源): + * - camera / image:streamUrl + sourceType (+ width/height/filename) + * - video(UploadVideo:326-335):另有 totalFrames / durationSeconds + * - batch(UploadBatchImages:469-478):另有 batchId / totalImages / images[] */ export interface MediaUploadResponse { streamUrl: string; @@ -48,4 +51,27 @@ export interface MediaUploadResponse { width?: number; height?: number; filename?: string; + + // Video 專屬(UploadVideo) + totalFrames?: number; + durationSeconds?: number; + + // Batch 專屬(UploadBatchImages) + batchId?: string; + totalImages?: number; + images?: BatchImageEntry[]; +} + +/** 批次上傳後回傳的每張圖 metadata(camera_handler.go:459-465)。 */ +export interface BatchImageEntry { + index: number; + filename: string; + width: number; + height: number; +} + +/** SeekVideo(POST /api/media/seek)回傳的 data(camera_handler.go:615-621)。 */ +export interface SeekResponse { + seekTo: number; + frameOffset: number; }