diff --git a/local-tool/frontend/src/app/workspace/[deviceId]/workspace-client.tsx b/local-tool/frontend/src/app/workspace/[deviceId]/workspace-client.tsx index 610db03..c02ab51 100644 --- a/local-tool/frontend/src/app/workspace/[deviceId]/workspace-client.tsx +++ b/local-tool/frontend/src/app/workspace/[deviceId]/workspace-client.tsx @@ -8,6 +8,7 @@ import { InferencePanel } from '@/components/inference/inference-panel'; import { FlashDialog } from '@/components/devices/flash-dialog'; import { useDeviceStore } from '@/stores/device-store'; import { useInferenceStore } from '@/stores/inference-store'; +import { useInferenceOptionsStore } from '@/stores/inference-options-store'; import { useInferenceStream } from '@/hooks/use-inference-stream'; import { useCameraStore } from '@/stores/camera-store'; import { useResolvedParams } from '@/hooks/use-resolved-params'; @@ -17,6 +18,7 @@ export default function WorkspaceClient() { const { deviceId } = useResolvedParams(); const { selectedDevice, fetchDevice } = useDeviceStore(); const { isRunning, setRunning, reset } = useInferenceStore(); + const resetInferenceOptions = useInferenceOptionsStore((s) => s.reset); const { isStreaming, sourceType } = useCameraStore(); // For image/video mode, inference runs automatically as part of the pipeline @@ -41,8 +43,11 @@ export default function WorkspaceClient() { } return () => { reset(); + // Options are per-session and per-device; leaving them set would apply a + // label mapping uploaded for one device to the next one opened. + resetInferenceOptions(); }; - }, [deviceId, fetchDevice, fetchCameras, reset]); + }, [deviceId, fetchDevice, fetchCameras, reset, resetInferenceOptions]); const handleStartInference = async () => { await api.post(`/devices/${deviceId}/inference/start`); @@ -89,7 +94,7 @@ export default function WorkspaceClient() {
- +
diff --git a/local-tool/frontend/src/components/camera/camera-inference-view.tsx b/local-tool/frontend/src/components/camera/camera-inference-view.tsx index 8011ac0..449d8f2 100644 --- a/local-tool/frontend/src/components/camera/camera-inference-view.tsx +++ b/local-tool/frontend/src/components/camera/camera-inference-view.tsx @@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'; import { CameraFeed } from './camera-feed'; -import { CameraOverlay } from './camera-overlay'; +import { InferenceOverlay } from './inference-overlay'; import { SourceSelector } from './source-selector'; import { BatchImageThumbnails } from './batch-image-thumbnails'; import { useCameraStore } from '@/stores/camera-store'; @@ -25,11 +25,10 @@ export function CameraInferenceView({ deviceId }: CameraInferenceViewProps) { setRenderedSize((prev) => (prev && prev.w === w && prev.h === h ? prev : { w, h })); }, []); - // In batch mode, show the selected image's detections + // In batch mode, show the selected image's result const selectedResult = isBatchMode ? batchResults[batchSelectedIndex] : result; - const detections = selectedResult?.detections || []; // In batch mode, use static image endpoint for viewing selected image const batchImageUrl = isBatchMode @@ -48,8 +47,8 @@ export function CameraInferenceView({ deviceId }: CameraInferenceViewProps) { onDimensionsChange={handleDimensionsChange} overlay={ isStreaming && renderedSize ? ( - d.confidence >= confidenceThreshold); - if (typeof window !== 'undefined') { - // TEMP debug: 驗證 bbox coordinate space 對齊問題 - // eslint-disable-next-line no-console - console.log('[bbox-debug] canvas=%dx%d total=%d filtered=%d threshold=%s', width, height, detections.length, filtered.length, confidenceThreshold, filtered.map((d) => ({ - label: d.label, - bbox: d.bbox, - conf: d.confidence, - }))); - } - filtered.forEach((det, i) => { const color = COLORS[i % COLORS.length]; // Convert normalized coordinates (0-1) to pixel values @@ -68,6 +58,7 @@ export function CameraOverlay({ detections, width, height, confidenceThreshold } height={height} style={{ width, height }} className="absolute left-0 top-0 pointer-events-none" + data-testid="camera-overlay" /> ); } diff --git a/local-tool/frontend/src/components/camera/classification-overlay.tsx b/local-tool/frontend/src/components/camera/classification-overlay.tsx new file mode 100644 index 0000000..fb681d2 --- /dev/null +++ b/local-tool/frontend/src/components/camera/classification-overlay.tsx @@ -0,0 +1,67 @@ +'use client'; + +import type { ClassResult } from '@/types/inference'; +import { classResultLabel } from '@/lib/classification'; +import { useStableTopClass } from '@/hooks/use-stable-top-class'; +import { useTranslation } from '@/lib/i18n'; + +interface ClassificationOverlayProps { + classifications: ClassResult[]; + width: number; + height: number; + confidenceThreshold: number; +} + +/** + * Classification overlay — no bounding boxes. + * + * Classification models produce a whole-image verdict rather than localised + * objects, so we annotate the frame with a single top-1 chip instead of + * drawing rectangles. Rendered as absolutely positioned DOM (not canvas) so + * that CJK label text, rounded corners and theming come from CSS rather than + * hand-rolled `fillText` layout, and so screen readers can announce the result. + * + * The chip sits on the top-right, because CameraFeed already occupies the + * top-left with the source-type badge. + */ +export function ClassificationOverlay({ + classifications, + width, + height, + confidenceThreshold, +}: ClassificationOverlayProps) { + const { t } = useTranslation(); + const top = useStableTopClass(classifications, { confidenceThreshold }); + + return ( +
+
+ {top ? ( +
+ + {classResultLabel(top)} + + + {(top.confidence * 100).toFixed(1)}% + +
+ ) : ( + + {t('inference.unrecognized')} + + )} +
+
+ ); +} diff --git a/local-tool/frontend/src/components/camera/inference-overlay.tsx b/local-tool/frontend/src/components/camera/inference-overlay.tsx new file mode 100644 index 0000000..b8574d3 --- /dev/null +++ b/local-tool/frontend/src/components/camera/inference-overlay.tsx @@ -0,0 +1,53 @@ +'use client'; + +import type { InferenceResult } from '@/types/inference'; +import { isClassificationTask } from '@/lib/classification'; +import { CameraOverlay } from './camera-overlay'; +import { ClassificationOverlay } from './classification-overlay'; + +interface InferenceOverlayProps { + result: InferenceResult | null | undefined; + width: number; + height: number; + confidenceThreshold: number; +} + +/** + * Dispatches the frame overlay based on the task type reported by the + * inference result itself. + * + * `result.taskType` is preferred over the model metadata because it reflects + * what the Python post-processor actually did — metadata can disagree with the + * executed code path. + * + * Note the branch is written as "is this classification?" rather than "is this + * detection?" on purpose: the detection task type string has been inconsistent + * across the stack (`detection` vs `object_detection`, plan R-4), so detection + * is the fall-through default and stays correct under either spelling. + */ +export function InferenceOverlay({ + result, + width, + height, + confidenceThreshold, +}: InferenceOverlayProps) { + if (isClassificationTask(result?.taskType)) { + return ( + + ); + } + + return ( + + ); +} diff --git a/local-tool/frontend/src/components/devices/flash-dialog.tsx b/local-tool/frontend/src/components/devices/flash-dialog.tsx index 134649a..97bb064 100644 --- a/local-tool/frontend/src/components/devices/flash-dialog.tsx +++ b/local-tool/frontend/src/components/devices/flash-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Dialog, DialogContent, @@ -39,11 +39,16 @@ export function FlashDialog({ deviceId }: FlashDialogProps) { const device = devices.find((d) => d.id === deviceId); const selectedModel = models.find((m) => m.id === selectedModelId); - // S2: 資料載入前預設 compatible=true,避免在 model/device 還沒載入時就顯示不相容警告 - const compatible = useMemo(() => { - if (!selectedModel || !device) return true; - return isModelCompatible(selectedModel.supportedHardware, device.type); - }, [selectedModel, device]); + // 載入模型時不選推論種類:解析方式改由推論頁(InferenceOptions)即時切換, + // 那裡不需要重燒就能改,功能完全涵蓋燒錄時選一次的舊做法。 + + // S2: 資料載入前預設 compatible=true,避免在 model/device 還沒載入時就顯示不相容警告。 + // 這裡不再包 useMemo —— isModelCompatible 只是一次陣列比對,手動 memo 反而讓 + // React Compiler 整個元件跳過優化(react-hooks/preserve-manual-memoization)。 + const compatible = + !selectedModel || !device + ? true + : isModelCompatible(selectedModel.supportedHardware, device.type); useEffect(() => { if (open) { diff --git a/local-tool/frontend/src/components/inference/classification-result.tsx b/local-tool/frontend/src/components/inference/classification-result.tsx index 7540eb4..bf71389 100644 --- a/local-tool/frontend/src/components/inference/classification-result.tsx +++ b/local-tool/frontend/src/components/inference/classification-result.tsx @@ -2,6 +2,7 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Cell } from 'recharts'; import type { ClassResult } from '@/types/inference'; +import { classResultLabel } from '@/lib/classification'; import { useTranslation } from '@/lib/i18n'; interface ClassificationResultProps { @@ -18,8 +19,10 @@ export function ClassificationResult({ results, confidenceThreshold }: Classific .sort((a, b) => b.confidence - a.confidence) .slice(0, 8); + // Labels may be missing when the model ships without a label mapping — fall + // back to the raw class index (`class_`) rather than rendering blank bars. const data = filtered.map((r) => ({ - label: r.label, + label: classResultLabel(r), confidence: +(r.confidence * 100).toFixed(1), })); @@ -32,17 +35,19 @@ export function ClassificationResult({ results, confidenceThreshold }: Classific } return ( - - - - - - - {data.map((_, index) => ( - - ))} - - - +
+ + + + + + + {data.map((_, index) => ( + + ))} + + + +
); } diff --git a/local-tool/frontend/src/components/inference/detection-result.tsx b/local-tool/frontend/src/components/inference/detection-result.tsx new file mode 100644 index 0000000..d587b52 --- /dev/null +++ b/local-tool/frontend/src/components/inference/detection-result.tsx @@ -0,0 +1,54 @@ +'use client'; + +import type { DetectionResult } from '@/types/inference'; +import { useTranslation } from '@/lib/i18n'; + +interface DetectionResultListProps { + results: DetectionResult[]; + confidenceThreshold: number; +} + +/** + * Detected object list for the side panel. + * + * Detection results are spatial, so the primary read is the bounding boxes on + * the frame itself; this list is the secondary, textual read (what was found, + * how many, how confident) which the canvas cannot convey to assistive tech. + */ +export function DetectionResultList({ results, confidenceThreshold }: DetectionResultListProps) { + const { t } = useTranslation(); + + const filtered = results + .filter((d) => d.confidence >= confidenceThreshold) + .sort((a, b) => b.confidence - a.confidence); + + if (filtered.length === 0) { + return ( +
+ {t('inference.noResultsAboveThreshold')} +
+ ); + } + + return ( +
+
+ {t('inference.detectedCount')} + {filtered.length} +
+
    + {filtered.map((det, i) => ( +
  • + {det.label} + + {(det.confidence * 100).toFixed(1)}% + +
  • + ))} +
+
+ ); +} diff --git a/local-tool/frontend/src/components/inference/inference-options.tsx b/local-tool/frontend/src/components/inference/inference-options.tsx new file mode 100644 index 0000000..9f4fc2b --- /dev/null +++ b/local-tool/frontend/src/components/inference/inference-options.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; +import { + useInferenceOptionsStore, + validateLabelFile, + MAX_LABEL_FILE_BYTES, + ACCEPTED_LABEL_EXTENSIONS, +} from '@/stores/inference-options-store'; +import { FLASH_TASK_TYPES, normalizeTaskType, type FlashTaskType } from '@/lib/task-type'; +import { useTranslation } from '@/lib/i18n'; + +interface InferenceOptionsProps { + deviceId: string; + /** + * Task type reported by the most recent inference result. Used as the + * selector's value until the user overrides it, so the control opens showing + * what the pipeline is actually doing rather than a guess. + */ + resultTaskType: string | undefined; +} + +/** Local-only errors that never reach the server (size / extension). */ +type LocalError = 'too-large' | 'wrong-type' | null; + +/** Preview of the first few label names, so the user can spot a wrong file. */ +const LABEL_PREVIEW_COUNT = 3; + +/** + * Live inference controls (M4). + * + * Lets the user swap the parsing mode and attach a display-only label mapping + * mid-session, without re-flashing the model. Sits in the right-hand + * InferencePanel next to the confidence slider — the other control that + * re-interprets results already on screen — so all the "how do I read this + * output" knobs live together and none of them block the video area. + * + * The label section is only rendered for classification: labels map class + * indices to names, which is meaningless for detection (whose labels come from + * the model metadata per box). + */ +export function InferenceOptions({ deviceId, resultTaskType }: InferenceOptionsProps) { + const { t } = useTranslation(); + const fileInputRef = useRef(null); + const [localError, setLocalError] = useState(null); + const { taskType, labels, applying, error, setTaskType, uploadLabels, clearLabels } = + useInferenceOptionsStore(); + + // The user's explicit choice wins; otherwise mirror what the pipeline last + // reported. Both go through normalizeTaskType so an unknown/legacy spelling + // (plan R-4) falls through to object detection instead of rendering blank. + const effectiveTaskType: FlashTaskType = taskType ?? normalizeTaskType(resultTaskType); + const isClassification = effectiveTaskType === 'classification'; + + const taskTypeLabel = (value: FlashTaskType) => + value === 'classification' + ? t('devices.flash.taskTypeClassification') + : t('devices.flash.taskTypeObjectDetection'); + + const handleTaskTypeChange = (value: string) => { + setLocalError(null); + void setTaskType(deviceId, normalizeTaskType(value)); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + // Reset the input so picking the SAME file again still fires a change + // event — otherwise a user who fixed their labels.txt on disk and re-picked + // it would get no feedback at all. + e.target.value = ''; + if (!file) return; + + const check = validateLabelFile(file); + if (!check.ok) { + setLocalError(check.reason); + return; + } + setLocalError(null); + void uploadLabels(deviceId, file); + }; + + const handleClearLabels = () => { + setLocalError(null); + void clearLabels(deviceId); + }; + + const localErrorMessage = + localError === 'too-large' + ? t('inference.options.labelFileTooLarge', { + limit: `${Math.round(MAX_LABEL_FILE_BYTES / 1024)} KB`, + }) + : localError === 'wrong-type' + ? t('inference.options.labelFileWrongType') + : null; + + // Server errors carry a line number for parse failures — surfacing it is the + // whole reason parsing happens server-side. + const serverErrorMessage = error + ? error.line != null + ? t('inference.options.labelParseErrorLine', { line: error.line, message: error.message }) + : t('inference.options.applyFailed', { message: error.message }) + : null; + + const errorMessage = localErrorMessage ?? serverErrorMessage; + + return ( +
+
+ + +

+ {t('inference.options.taskTypeHint')} +

+
+ + {isClassification && ( +
+

+ {t('inference.options.labels')} +

+

{t('inference.options.labelsHint')}

+ + + +
+ + {labels && ( + + )} +
+ +

+ {t('inference.options.labelsFormatHint')} +

+ +

+ {applying + ? t('inference.options.applying') + : labels + ? t('inference.options.labelsApplied', { + count: labels.length, + names: labels.slice(0, LABEL_PREVIEW_COUNT).join('、'), + }) + : t('inference.options.noLabels')} +

+
+ )} + + {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ ); +} diff --git a/local-tool/frontend/src/components/inference/inference-panel.tsx b/local-tool/frontend/src/components/inference/inference-panel.tsx index 5e7db80..ff07714 100644 --- a/local-tool/frontend/src/components/inference/inference-panel.tsx +++ b/local-tool/frontend/src/components/inference/inference-panel.tsx @@ -2,14 +2,21 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ClassificationResult } from './classification-result'; +import { DetectionResultList } from './detection-result'; import { PerformanceMetrics } from './performance-metrics'; import { ConfidenceSlider } from './confidence-slider'; import { VideoProgress } from './video-progress'; +import { InferenceOptions } from './inference-options'; import { useInferenceStore } from '@/stores/inference-store'; import { useCameraStore } from '@/stores/camera-store'; +import { isClassificationTask } from '@/lib/classification'; import { useTranslation } from '@/lib/i18n'; -export function InferencePanel() { +interface InferencePanelProps { + deviceId: string; +} + +export function InferencePanel({ deviceId }: InferencePanelProps) { const { t } = useTranslation(); const { result, fps, avgLatency, isRunning, confidenceThreshold, batchResults } = useInferenceStore(); @@ -22,6 +29,10 @@ export function InferencePanel() { ? batchResults[batchSelectedIndex] : result; const classifications = displayResult?.classifications || []; + const detections = displayResult?.detections || []; + // Branch on "is classification?" so detection stays the safe fall-through + // under either taskType spelling (see lib/classification.ts). + const isClassification = isClassificationTask(displayResult?.taskType); return (
@@ -62,13 +73,33 @@ export function InferencePanel() { - {t('inference.classificationResults')} + {t('inference.options.title')} - + + + + + + + + {isClassification + ? t('inference.classificationResults') + : t('inference.detectionResults')} + + + + {isClassification ? ( + + ) : ( + + )} diff --git a/local-tool/frontend/src/hooks/use-stable-top-class.ts b/local-tool/frontend/src/hooks/use-stable-top-class.ts new file mode 100644 index 0000000..4c79028 --- /dev/null +++ b/local-tool/frontend/src/hooks/use-stable-top-class.ts @@ -0,0 +1,72 @@ +'use client'; + +import { useState } from 'react'; +import type { ClassResult } from '@/types/inference'; +import { + DEFAULT_HYSTERESIS, + INITIAL_HYSTERESIS_STATE, + nextHysteresisState, + type HysteresisState, +} from '@/lib/classification'; + +interface UseStableTopClassOptions { + confidenceThreshold: number; + streakFrames?: number; + immediateMargin?: number; +} + +interface InternalState { + hysteresis: HysteresisState; + /** The `classifications` array identity the hysteresis state was derived from. */ + seenClassifications: ClassResult[] | undefined; + seenConfidenceThreshold: number; +} + +const INITIAL_INTERNAL: InternalState = { + hysteresis: INITIAL_HYSTERESIS_STATE, + seenClassifications: undefined, + seenConfidenceThreshold: NaN, +}; + +/** + * Returns the anti-flicker top-1 class for the current frame. + * + * The hysteresis state machine advances once per inference result — the store + * always hands us a fresh `classifications` array, so array identity is a + * reliable "new frame" signal. Static image sources produce a single result and + * therefore a single transition, which the state machine handles (the first + * result above the threshold is displayed immediately). + * + * Uses React's "adjust state while rendering" pattern rather than a ref or an + * effect: an effect-driven version would render one frame behind, which on a + * 15 FPS stream is visible lag, and a ref mutated during render is unsafe under + * concurrent rendering. + * See https://react.dev/reference/react/useState#storing-information-from-previous-renders + */ +export function useStableTopClass( + classifications: ClassResult[] | undefined, + { confidenceThreshold, streakFrames, immediateMargin }: UseStableTopClassOptions, +): ClassResult | null { + const [state, setState] = useState(INITIAL_INTERNAL); + + let current = state; + // Recompute when a new frame arrives, or when the user moves the confidence + // slider (the currently displayed label may no longer qualify). + if ( + state.seenClassifications !== classifications || + state.seenConfidenceThreshold !== confidenceThreshold + ) { + current = { + hysteresis: nextHysteresisState(state.hysteresis, classifications, { + streakFrames: streakFrames ?? DEFAULT_HYSTERESIS.streakFrames, + immediateMargin: immediateMargin ?? DEFAULT_HYSTERESIS.immediateMargin, + confidenceThreshold, + }), + seenClassifications: classifications, + seenConfidenceThreshold: confidenceThreshold, + }; + setState(current); + } + + return current.hysteresis.displayed; +} diff --git a/local-tool/frontend/src/lib/api.ts b/local-tool/frontend/src/lib/api.ts index 0974b28..e88166c 100644 --- a/local-tool/frontend/src/lib/api.ts +++ b/local-tool/frontend/src/lib/api.ts @@ -1,12 +1,22 @@ import { getApiBaseUrl, getRelayToken, fetchAndCacheRelayToken } from './constants'; -export interface ApiResponse { +export interface ApiError { + code: string; + message: string; +} + +/** + * Standard response envelope. + * + * `E` widens the error shape for endpoints that attach extra diagnostics — the + * label upload returns a `line` number on parse failures, and that field has to + * be declared rather than cast in, or the type system stops protecting the one + * piece of information the user actually needs. + */ +export interface ApiResponse { success: boolean; data?: T; - error?: { - code: string; - message: string; - }; + error?: E; } // Ensure relay token is available before making API requests. @@ -40,7 +50,10 @@ function buildHeaders(): Record { }; } -async function request(path: string, options?: RequestInit): Promise> { +async function request( + path: string, + options?: RequestInit, +): Promise> { // Wait for relay token to be available before first request await ensureRelayToken(); @@ -51,10 +64,32 @@ async function request(path: string, options?: RequestInit): Promise( + path: string, + form: FormData, +): Promise> { + await ensureRelayToken(); + + const res = await fetch(`${getApiBaseUrl()}${path}`, { + method: 'POST', + headers: getRelayHeaders(), + body: form, + }); + return res.json(); +} + export const api = { get: (path: string) => request(path), - post: (path: string, body?: unknown) => - request(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }), + post: (path: string, body?: unknown) => + request(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }), + postForm, put: (path: string, body?: unknown) => request(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }), del: (path: string) => request(path, { method: 'DELETE' }), diff --git a/local-tool/frontend/src/lib/classification.ts b/local-tool/frontend/src/lib/classification.ts new file mode 100644 index 0000000..2a8b82f --- /dev/null +++ b/local-tool/frontend/src/lib/classification.ts @@ -0,0 +1,146 @@ +import type { ClassResult } from '@/types/inference'; + +/** + * Defensive taskType check. + * + * The taskType value set has historically been inconsistent across the stack: + * the Python bridge used to emit `"detection"` while models.json / the + * frontend TASK_TYPES constant use `"object_detection"` (see plan R-4). + * + * We therefore only ever branch on "is this classification?" — anything else + * (including `undefined`) falls through to the existing detection rendering + * path, which is the safe default: a detection model rendered as detection is + * correct, whereas a mis-detected classification just means no overlay label. + */ +export function isClassificationTask(taskType: string | undefined | null): boolean { + return taskType === 'classification'; +} + +/** + * Display label for a class result. + * + * `classIndex` is optional because the Go layer may not forward it (M2-d is a + * "nice to have"). When the label is missing/blank we fall back to + * `class_`, and if we have neither we emit a stable placeholder rather + * than rendering an empty chip. + */ +export function classResultLabel(result: Pick): string { + const label = result.label?.trim(); + if (label) return label; + if (typeof result.classIndex === 'number' && Number.isFinite(result.classIndex)) { + return `class_${result.classIndex}`; + } + return 'class_?'; +} + +/** + * Stable identity for a class result, used by the hysteresis state machine to + * decide whether two consecutive frames refer to the same class. + * + * Prefers `classIndex` (immune to label churn when labels are re-uploaded) and + * falls back to the label string when the index is absent. + */ +export function classResultKey(result: Pick): string { + if (typeof result.classIndex === 'number' && Number.isFinite(result.classIndex)) { + return `i:${result.classIndex}`; + } + return `l:${classResultLabel(result)}`; +} + +/** Highest-confidence entry, or null when there is nothing to show. */ +export function pickTopClass(results: ClassResult[] | undefined): ClassResult | null { + if (!results || results.length === 0) return null; + let top = results[0]; + for (let i = 1; i < results.length; i++) { + if (results[i].confidence > top.confidence) top = results[i]; + } + return top; +} + +export interface HysteresisState { + /** Currently displayed class (null = nothing displayed yet). */ + displayed: ClassResult | null; + /** Candidate class that is trying to replace `displayed`. */ + candidateKey: string | null; + /** How many consecutive frames the candidate has been the top class. */ + candidateStreak: number; +} + +export interface HysteresisOptions { + /** + * Frames the challenger must stay on top before it replaces the displayed + * class. 3 @ ~15 FPS ≈ 200 ms — long enough to swallow single-frame noise, + * short enough that a genuine gesture change still feels instant. + */ + streakFrames: number; + /** + * Confidence margin that lets a challenger switch immediately, bypassing the + * streak counter. 0.15 means "clearly more confident, not a coin flip". + */ + immediateMargin: number; + /** + * Below this confidence nothing is displayed. Mirrors the user-facing + * confidence slider so the overlay agrees with the side panel. + */ + confidenceThreshold: number; +} + +export const DEFAULT_HYSTERESIS: Pick = { + streakFrames: 3, + immediateMargin: 0.15, +}; + +export const INITIAL_HYSTERESIS_STATE: HysteresisState = { + displayed: null, + candidateKey: null, + candidateStreak: 0, +}; + +/** + * Anti-flicker state machine for the top-1 classification label. + * + * Video/camera sources re-classify every frame, so a 50/50 boundary makes the + * raw top-1 oscillate wildly (plan risk R-9). A frame is only promoted when it + * either (a) beats the incumbent by a clear confidence margin, or (b) has been + * the top class for `streakFrames` consecutive frames. + * + * Pure function: takes the previous state, returns the next one. No React, + * no timers — which makes it directly unit-testable. + */ +export function nextHysteresisState( + prev: HysteresisState, + results: ClassResult[] | undefined, + options: HysteresisOptions, +): HysteresisState { + const { streakFrames, immediateMargin, confidenceThreshold } = options; + const top = pickTopClass(results); + + // Nothing above the threshold → clear immediately. Showing a stale label over + // a frame the model no longer recognises is worse than showing nothing. + if (!top || top.confidence < confidenceThreshold) { + return INITIAL_HYSTERESIS_STATE; + } + + // First result, or the incumbent already matches → refresh confidence in place. + if (!prev.displayed) { + return { displayed: top, candidateKey: null, candidateStreak: 0 }; + } + + const topKey = classResultKey(top); + if (topKey === classResultKey(prev.displayed)) { + return { displayed: top, candidateKey: null, candidateStreak: 0 }; + } + + // Clear winner → switch without waiting for the streak. + if (top.confidence - prev.displayed.confidence >= immediateMargin) { + return { displayed: top, candidateKey: null, candidateStreak: 0 }; + } + + const streak = prev.candidateKey === topKey ? prev.candidateStreak + 1 : 1; + if (streak >= streakFrames) { + return { displayed: top, candidateKey: null, candidateStreak: 0 }; + } + + // Challenger not convincing enough yet — keep showing the incumbent. + return { displayed: prev.displayed, candidateKey: topKey, candidateStreak: streak }; +} diff --git a/local-tool/frontend/src/lib/i18n/en.ts b/local-tool/frontend/src/lib/i18n/en.ts index 64c4fd4..389feaa 100644 --- a/local-tool/frontend/src/lib/i18n/en.ts +++ b/local-tool/frontend/src/lib/i18n/en.ts @@ -125,6 +125,12 @@ export const en: TranslationDict = { flashFailed: 'Flash Failed', preparingFlash: 'Preparing flash...', flashComplete: 'Flash complete!', + selectModelFirst: 'Select a model first', + // Display names for the inference types. The flash dialog no longer + // selects a type, but the inference page's InferenceOptions still shares + // these two labels, so they stay. + taskTypeObjectDetection: 'Object Detection', + taskTypeClassification: 'Classification', }, card: { fwBadge: { @@ -234,6 +240,9 @@ export const en: TranslationDict = { confidenceFilter: 'Confidence Filter', confidenceThreshold: 'Confidence Threshold', classificationResults: 'Classification Results', + detectionResults: 'Detection Results', + detectedCount: 'Detected', + unrecognized: 'Unrecognized', noResultsAboveThreshold: 'No results above threshold', details: 'Details', model: 'Model', @@ -250,6 +259,27 @@ export const en: TranslationDict = { videoProgress: 'Video Progress', frames: 'frames', framesProcessed: 'Frames Processed', + options: { + title: 'Inference Options', + taskType: 'Parsing Mode', + taskTypeHint: + 'Applies to subsequent results immediately — no need to re-flash the model.', + applying: 'Applying...', + applyFailed: 'Could not apply: {message}', + labels: 'Label Mapping', + labelsHint: + 'Optional. Without a label file the raw class indices are shown (class_0, class_1…).', + labelsFormatHint: 'One " " per line, e.g. 0 scissors', + selectLabelFile: 'Upload label file', + replaceLabelFile: 'Replace label file', + clearLabels: 'Clear labels', + labelsApplied: 'Applied {count} labels ({names})', + labelsCleared: 'Labels cleared — raw class indices will be shown', + noLabels: 'No label file uploaded', + labelFileTooLarge: 'Label file is too large (limit {limit})', + labelFileWrongType: 'Only .txt or .names files are accepted', + labelParseErrorLine: 'Line {line}: {message}', + }, }, settings: { title: 'Settings', diff --git a/local-tool/frontend/src/lib/i18n/types.ts b/local-tool/frontend/src/lib/i18n/types.ts index 75df5e3..b497569 100644 --- a/local-tool/frontend/src/lib/i18n/types.ts +++ b/local-tool/frontend/src/lib/i18n/types.ts @@ -123,6 +123,10 @@ export interface TranslationDict { flashFailed: string; preparingFlash: string; flashComplete: string; + selectModelFirst: string; + // Shared with the inference page's InferenceOptions selector. + taskTypeObjectDetection: string; + taskTypeClassification: string; }; card: { fwBadge: { @@ -232,6 +236,9 @@ export interface TranslationDict { confidenceFilter: string; confidenceThreshold: string; classificationResults: string; + detectionResults: string; + detectedCount: string; + unrecognized: string; noResultsAboveThreshold: string; details: string; model: string; @@ -248,6 +255,25 @@ export interface TranslationDict { videoProgress: string; frames: string; framesProcessed: string; + options: { + title: string; + taskType: string; + taskTypeHint: string; + applying: string; + applyFailed: string; + labels: string; + labelsHint: string; + labelsFormatHint: string; + selectLabelFile: string; + replaceLabelFile: string; + clearLabels: string; + labelsApplied: string; + labelsCleared: string; + noLabels: string; + labelFileTooLarge: string; + labelFileWrongType: string; + labelParseErrorLine: string; + }; }; settings: { title: string; diff --git a/local-tool/frontend/src/lib/i18n/zh-TW.ts b/local-tool/frontend/src/lib/i18n/zh-TW.ts index a48c393..0df319d 100644 --- a/local-tool/frontend/src/lib/i18n/zh-TW.ts +++ b/local-tool/frontend/src/lib/i18n/zh-TW.ts @@ -125,6 +125,11 @@ export const zhTW: TranslationDict = { flashFailed: '燒錄失敗', preparingFlash: '準備燒錄中...', flashComplete: '燒錄完成!', + selectModelFirst: '請先選擇模型', + // 推論種類的顯示名稱。燒錄對話框已不再選推論種類,但推論頁的 + // InferenceOptions 仍共用這兩個標籤,故保留。 + taskTypeObjectDetection: '物件偵測', + taskTypeClassification: '分類', }, card: { fwBadge: { @@ -234,6 +239,9 @@ export const zhTW: TranslationDict = { confidenceFilter: '信心度篩選', confidenceThreshold: '信心度門檻', classificationResults: '分類結果', + detectionResults: '偵測結果', + detectedCount: '偵測數量', + unrecognized: '無法辨識', noResultsAboveThreshold: '沒有超過門檻的結果', details: '詳細資訊', model: '模型', @@ -250,6 +258,25 @@ export const zhTW: TranslationDict = { videoProgress: '影片進度', frames: '幀', framesProcessed: '已處理幀數', + options: { + title: '推論設定', + taskType: '解析方式', + taskTypeHint: '切換後立即套用到之後的推論結果,不需要重新載入模型。', + applying: '套用中...', + applyFailed: '套用失敗:{message}', + labels: '標籤對照', + labelsHint: '選用。沒有上傳時會顯示原始類別編號(class_0、class_1…)。', + labelsFormatHint: '每行一筆「編號 名稱」,例如:0 剪刀', + selectLabelFile: '上傳標籤檔', + replaceLabelFile: '更換標籤檔', + clearLabels: '清除標籤', + labelsApplied: '已套用 {count} 個標籤({names})', + labelsCleared: '已清除標籤,改用原始類別編號', + noLabels: '尚未上傳標籤檔', + labelFileTooLarge: '標籤檔過大(上限 {limit})', + labelFileWrongType: '只接受 .txt 或 .names 檔', + labelParseErrorLine: '第 {line} 行:{message}', + }, }, settings: { title: '設定', diff --git a/local-tool/frontend/src/lib/task-type.ts b/local-tool/frontend/src/lib/task-type.ts new file mode 100644 index 0000000..c4edbc3 --- /dev/null +++ b/local-tool/frontend/src/lib/task-type.ts @@ -0,0 +1,29 @@ +/** + * Task types the inference pipeline can actually parse. + * + * models.json also carries `segmentation` / `pose_estimation` (the upload form + * offers them), but neither the Python bridge nor the frontend has a rendering + * path for those yet. The inference-page selector therefore only exposes the + * two types that produce meaningful output. + * + * Naming note: the `FLASH_` prefix is historical — the selector originally + * lived in the flash dialog. It now only backs the inference page's runtime + * switch (`components/inference/inference-options.tsx`). + */ +export const FLASH_TASK_TYPES = ['object_detection', 'classification'] as const; + +export type FlashTaskType = (typeof FLASH_TASK_TYPES)[number]; + +/** + * Normalise an arbitrary taskType string to one of the two supported values. + * + * The value set has historically been inconsistent across the stack: the Python + * bridge used to emit `"detection"` while models.json and the frontend + * TASK_TYPES constant use `"object_detection"` (plan R-4). We therefore only + * ever test for classification and let everything else — including `undefined`, + * `segmentation` and legacy `detection` — fall through to object detection, + * which is the existing safe default. + */ +export function normalizeTaskType(taskType: string | undefined | null): FlashTaskType { + return taskType === 'classification' ? 'classification' : 'object_detection'; +} diff --git a/local-tool/frontend/src/stores/inference-options-store.ts b/local-tool/frontend/src/stores/inference-options-store.ts new file mode 100644 index 0000000..1d5f4f8 --- /dev/null +++ b/local-tool/frontend/src/stores/inference-options-store.ts @@ -0,0 +1,198 @@ +import { create } from 'zustand'; +import { api } from '@/lib/api'; +import { normalizeTaskType, type FlashTaskType } from '@/lib/task-type'; +import type { ApiResponse } from '@/lib/api'; + +/** + * Live inference options — the parsing mode and the display-only label mapping + * that can be changed WITHOUT re-flashing the model (M4). + * + * The whole HTTP contract lives in this file on purpose. The backend endpoint + * is being built in parallel, so keeping every request shape in one module + * means realigning with the server is a single-file edit rather than a hunt + * through components. + * + * Contract as implemented here: + * + * POST /api/devices/:id/inference/options + * Content-Type: application/json + * { "taskType": "object_detection" | "classification" } + * + * POST /api/devices/:id/inference/options (label upload) + * Content-Type: multipart/form-data + * labelFile: // ` ` per line + * + * POST /api/devices/:id/inference/options (clear the mapping) + * Content-Type: application/json + * { "labels": [] } // explicit empty array; omitting the field + * // instead means "leave labels untouched" + * + * All return the standard envelope: + * { success: true, data: { deviceId, taskType, labelCount, labels, maxIndex } } + * { success: false, error: { code, message, line? } } + * + * The server rejects a request that carries neither `taskType` nor `labels`, + * so every action below sends at least one of them. + * + * Nothing is persisted: the user re-uploads the label file per session, which + * is what they asked for ("不用記,每次現場傳"). + */ + +/** Matches the plan §3.5 cap (3 classes ≈ 30 bytes; 10k classes < 200 KB). */ +export const MAX_LABEL_FILE_BYTES = 256 * 1024; + +/** Extensions accepted client-side. Content validation happens server-side. */ +export const ACCEPTED_LABEL_EXTENSIONS = ['.txt', '.names'] as const; + +export interface InferenceOptionsResponse { + taskType?: string; + labelCount?: number; + labels?: string[]; +} + +/** + * Error envelope for label parsing. `line` is optional and only present for + * `LABEL_PARSE_ERROR`, which is the whole point of letting the server parse: + * it can point at the offending line. + */ +export interface InferenceOptionsError { + code: string; + message: string; + line?: number; +} + +interface InferenceOptionsState { + /** + * `null` = the user has not overridden anything this session, so the mode + * flashed with the model is still in effect. Components resolve the value to + * display by falling back to the last inference result's taskType. + */ + taskType: FlashTaskType | null; + labels: string[] | null; + applying: boolean; + error: InferenceOptionsError | null; + + setTaskType: (deviceId: string, taskType: FlashTaskType) => Promise; + uploadLabels: (deviceId: string, file: File) => Promise; + clearLabels: (deviceId: string) => Promise; + clearError: () => void; + reset: () => void; +} + +function optionsPath(deviceId: string) { + return `/devices/${deviceId}/inference/options`; +} + +type OptionsResponse = ApiResponse; + +/** Normalises a failed response into a fully-populated error envelope. */ +function toError(res: OptionsResponse): InferenceOptionsError { + return { + code: res.error?.code || 'UNKNOWN', + message: res.error?.message || 'Request failed', + line: res.error?.line, + }; +} + +export const useInferenceOptionsStore = create((set) => ({ + taskType: null, + labels: null, + applying: false, + error: null, + + setTaskType: async (deviceId, taskType) => { + set({ applying: true, error: null }); + try { + const res = await api.post( + optionsPath(deviceId), + { taskType }, + ); + if (!res.success) { + set({ applying: false, error: toError(res) }); + return false; + } + // Trust the server's echo when it sends one, so a server-side coercion + // never leaves the UI showing a mode that is not actually in effect. + set({ + applying: false, + taskType: res.data?.taskType ? normalizeTaskType(res.data.taskType) : taskType, + }); + return true; + } catch (e) { + set({ + applying: false, + error: { code: 'NETWORK_ERROR', message: e instanceof Error ? e.message : String(e) }, + }); + return false; + } + }, + + uploadLabels: async (deviceId, file) => { + set({ applying: true, error: null }); + try { + const form = new FormData(); + form.append('labelFile', file); + const res = await api.postForm( + optionsPath(deviceId), + form, + ); + if (!res.success) { + set({ applying: false, error: toError(res) }); + return false; + } + set({ applying: false, labels: res.data?.labels ?? [] }); + return true; + } catch (e) { + set({ + applying: false, + error: { code: 'NETWORK_ERROR', message: e instanceof Error ? e.message : String(e) }, + }); + return false; + } + }, + + clearLabels: async (deviceId) => { + set({ applying: true, error: null }); + try { + // An explicit empty array clears the mapping. Omitting the field would + // mean "leave labels as they are", so the array must actually be sent. + const res = await api.post( + optionsPath(deviceId), + { labels: [] }, + ); + if (!res.success) { + set({ applying: false, error: toError(res) }); + return false; + } + set({ applying: false, labels: null }); + return true; + } catch (e) { + set({ + applying: false, + error: { code: 'NETWORK_ERROR', message: e instanceof Error ? e.message : String(e) }, + }); + return false; + } + }, + + clearError: () => set({ error: null }), + + reset: () => set({ taskType: null, labels: null, applying: false, error: null }), +})); + +/** + * Client-side pre-checks. The server validates again — this only spares the + * user a round trip for the two mistakes that need no parsing to detect. + */ +export function validateLabelFile( + file: File, +): { ok: true } | { ok: false; reason: 'too-large' | 'wrong-type' } { + const lower = file.name.toLowerCase(); + if (!ACCEPTED_LABEL_EXTENSIONS.some((ext) => lower.endsWith(ext))) { + return { ok: false, reason: 'wrong-type' }; + } + if (file.size > MAX_LABEL_FILE_BYTES) { + return { ok: false, reason: 'too-large' }; + } + return { ok: true }; +} diff --git a/local-tool/frontend/src/tests/components/detection-result.test.tsx b/local-tool/frontend/src/tests/components/detection-result.test.tsx new file mode 100644 index 0000000..0505b62 --- /dev/null +++ b/local-tool/frontend/src/tests/components/detection-result.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { DetectionResultList } from '@/components/inference/detection-result'; +import type { DetectionResult } from '@/types/inference'; + +function det(label: string, confidence: number): DetectionResult { + return { label, confidence, bbox: { x: 0.1, y: 0.1, width: 0.2, height: 0.2 } }; +} + +describe('DetectionResultList — M3-b detection side panel', () => { + it('lists detections sorted by confidence descending', () => { + render( + , + ); + const items = screen.getAllByRole('listitem').map((li) => li.textContent); + expect(items[0]).toContain('person'); + expect(items[1]).toContain('dog'); + expect(items[2]).toContain('car'); + }); + + it('filters out detections below the confidence threshold', () => { + render( + , + ); + expect(screen.getAllByRole('listitem')).toHaveLength(1); + expect(screen.queryByText('noise')).toBeNull(); + }); + + it('shows the detected count', () => { + render( + , + ); + expect(screen.getByTestId('detection-result-list').textContent).toContain('2'); + }); + + it('shows the empty state when nothing clears the threshold', () => { + render(); + expect(screen.queryByTestId('detection-result-list')).toBeNull(); + }); + + it('shows the empty state for an empty result set', () => { + render(); + expect(screen.queryByTestId('detection-result-list')).toBeNull(); + }); +}); diff --git a/local-tool/frontend/src/tests/components/flash-dialog.test.tsx b/local-tool/frontend/src/tests/components/flash-dialog.test.tsx new file mode 100644 index 0000000..f2a9ceb --- /dev/null +++ b/local-tool/frontend/src/tests/components/flash-dialog.test.tsx @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { FlashDialog } from '@/components/devices/flash-dialog'; +import { useModelStore } from '@/stores/model-store'; +import { useFlashStore } from '@/stores/flash-store'; +import { useDeviceStore } from '@/stores/device-store'; +import { api } from '@/lib/api'; +import type { ModelSummary } from '@/types/model'; + +vi.mock('@/lib/api', () => ({ + api: { + get: vi.fn().mockResolvedValue({ success: true, data: { models: [], total: 0 } }), + post: vi.fn().mockResolvedValue({ success: true }), + }, + getRelayHeaders: vi.fn().mockReturnValue({}), +})); + +vi.mock('@/lib/toast', () => ({ + showSuccess: vi.fn(), + showError: vi.fn(), + showApiError: vi.fn(), +})); + +// The dialog opens a flash-progress WebSocket before POSTing. Stub it so +// `connectAndWait` resolves immediately instead of hitting the 3s timeout. +// +// The returned callbacks must keep a STABLE identity across renders: the real +// hook wraps them in useCallback, and the dialog lists `disconnect` in a +// useEffect dependency array. Returning fresh functions each render re-fires +// that effect on every render and blows the React update-depth limit. +const connectAndWaitMock = vi.fn().mockResolvedValue(undefined); +const disconnectMock = vi.fn(); +vi.mock('@/hooks/use-flash-progress', () => ({ + useFlashProgress: () => ({ + connectAndWait: connectAndWaitMock, + disconnect: disconnectMock, + }), +})); + +const DEVICE_ID = 'dev-1'; + +function model(overrides: Partial & Pick): ModelSummary { + return { + thumbnail: '', + taskType: 'object_detection', + categories: [], + modelSize: 1024, + accuracy: 0.9, + fps: 30, + supportedHardware: ['KL520'], + ...overrides, + }; +} + +const DETECTION_MODEL = model({ id: 'm-det', name: '物件辨識', taskType: 'object_detection' }); +const CLASSIFICATION_MODEL = model({ id: 'm-cls', name: '手勢分類', taskType: 'classification' }); +// Declares a different hardware family than the seeded KL520 device, so the +// incompatibility path can be exercised. +const INCOMPATIBLE_MODEL = model({ id: 'm-720', name: '高階模型', supportedHardware: ['KL720'] }); + +// Stable identity — replacing this per-test would change the dialog's effect +// dependencies on every render and spin the component into an update loop. +const noopFetchModels = async () => {}; + +beforeEach(() => { + vi.clearAllMocks(); + // clearAllMocks() also drops the resolved value, which would make + // connectAndWait return undefined and break the await in handleFlash. + connectAndWaitMock.mockResolvedValue(undefined); + vi.mocked(api.post).mockResolvedValue({ success: true }); + // fetchModels() runs when the dialog opens; with the mocked api it would + // overwrite the seeded list with []. Stub it once with a stable identity so + // the dialog's `useEffect([open, fetchModels, ...])` does not re-fire. + useModelStore.setState({ + models: [DETECTION_MODEL, CLASSIFICATION_MODEL, INCOMPATIBLE_MODEL], + fetchModels: noopFetchModels, + }); + useFlashStore.setState({ + activeDeviceId: null, + isFlashing: false, + progress: null, + error: null, + lastFlashParams: null, + }); + useDeviceStore.setState({ + devices: [{ id: DEVICE_ID, type: 'KL520' } as ReturnType< + typeof useDeviceStore.getState + >['devices'][number]], + }); +}); + +/** + * Radix Select cannot be opened with a plain click under jsdom (it depends on + * pointer capture), but its keyboard path works. Enter opens the listbox and a + * click on the rendered option commits the value. + */ +async function selectOption(trigger: HTMLElement, optionName: string) { + fireEvent.keyDown(trigger, { key: 'Enter', code: 'Enter' }); + fireEvent.click(await screen.findByRole('option', { name: optionName })); +} + +/** Opens the dialog and picks a model by its visible name. */ +async function openAndSelectModel(name: string) { + render(); + fireEvent.click(screen.getByRole('button', { name: '載入模型' })); + const triggers = await screen.findAllByRole('combobox'); + await selectOption(triggers[0], name); +} + +describe('FlashDialog — model selection', () => { + it('cannot submit until a model is chosen', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: '載入模型' })); + expect(screen.getByRole('button', { name: '請先選擇模型' })).toBeDisabled(); + }); + + it('offers exactly one selector — the model dropdown', async () => { + // Guards the removal of the flash-time inference-type selector: parsing is + // now switched on the inference page, so a second combobox here would mean + // the duplicate entry point came back. + await openAndSelectModel('物件辨識'); + expect(screen.getAllByRole('combobox')).toHaveLength(1); + }); + + it('enables submitting once a compatible model is selected', async () => { + await openAndSelectModel('物件辨識'); + expect(screen.getByRole('button', { name: '開始載入' })).toBeEnabled(); + }); +}); + +describe('FlashDialog — hardware compatibility', () => { + it('warns and blocks flashing a model the device does not support', async () => { + await openAndSelectModel('高階模型'); + + expect(screen.getByText('硬體不相容')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '不相容 — 無法載入' })).toBeDisabled(); + }); + + it('shows no incompatibility warning for a supported model', async () => { + await openAndSelectModel('物件辨識'); + expect(screen.queryByText('硬體不相容')).toBeNull(); + }); +}); + +describe('FlashDialog — flash request payload', () => { + it('posts only the model id', async () => { + await openAndSelectModel('物件辨識'); + fireEvent.click(screen.getByRole('button', { name: '開始載入' })); + + await waitFor(() => expect(api.post).toHaveBeenCalled()); + expect(api.post).toHaveBeenCalledWith(`/devices/${DEVICE_ID}/flash`, { modelId: 'm-det' }); + }); + + it('posts only the model id for a classification model too', async () => { + // The model's declared task type must never leak into the flash body: the + // server resolves it from models.json on its own. + await openAndSelectModel('手勢分類'); + fireEvent.click(screen.getByRole('button', { name: '開始載入' })); + + await waitFor(() => expect(api.post).toHaveBeenCalled()); + expect(api.post).toHaveBeenCalledWith(`/devices/${DEVICE_ID}/flash`, { modelId: 'm-cls' }); + }); + + it('waits for the progress socket before posting', async () => { + await openAndSelectModel('物件辨識'); + fireEvent.click(screen.getByRole('button', { name: '開始載入' })); + + await waitFor(() => expect(api.post).toHaveBeenCalled()); + expect(connectAndWaitMock).toHaveBeenCalled(); + }); + + it('does not post when the progress socket fails to connect', async () => { + connectAndWaitMock.mockRejectedValueOnce(new Error('socket down')); + await openAndSelectModel('物件辨識'); + fireEvent.click(screen.getByRole('button', { name: '開始載入' })); + + await waitFor(() => expect(useFlashStore.getState().error).toBe('socket down')); + expect(api.post).not.toHaveBeenCalled(); + }); + + it('replays the same payload on retry', async () => { + await openAndSelectModel('物件辨識'); + fireEvent.click(screen.getByRole('button', { name: '開始載入' })); + await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1)); + + vi.mocked(api.post).mockClear(); + await useFlashStore.getState().retryFlash(); + + expect(api.post).toHaveBeenCalledWith(`/devices/${DEVICE_ID}/flash`, { modelId: 'm-det' }); + }); +}); diff --git a/local-tool/frontend/src/tests/components/inference-options.test.tsx b/local-tool/frontend/src/tests/components/inference-options.test.tsx new file mode 100644 index 0000000..03e1602 --- /dev/null +++ b/local-tool/frontend/src/tests/components/inference-options.test.tsx @@ -0,0 +1,345 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { InferenceOptions } from '@/components/inference/inference-options'; +import { + useInferenceOptionsStore, + MAX_LABEL_FILE_BYTES, + type InferenceOptionsResponse, + type InferenceOptionsError, +} from '@/stores/inference-options-store'; +import { api, type ApiResponse } from '@/lib/api'; + +/** + * The store calls `api.post`/`api.postForm` with the widened error type so a + * parse error keeps its `line` number. `vi.mocked` resolves the generic to its + * default `ApiError`, so responses are built through this helper to stay in the + * shape the store actually receives. + */ +type OptionsResponse = ApiResponse; +const reply = (r: OptionsResponse) => r as ApiResponse; + +vi.mock('@/lib/api', () => ({ + api: { + get: vi.fn().mockResolvedValue({ success: true, data: {} }), + post: vi.fn().mockResolvedValue({ success: true, data: {} }), + postForm: vi.fn().mockResolvedValue({ success: true, data: {} }), + }, + getRelayHeaders: vi.fn().mockReturnValue({}), +})); + +const DEVICE_ID = 'dev-1'; +const OPTIONS_PATH = `/devices/${DEVICE_ID}/inference/options`; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(api.post).mockResolvedValue({ success: true, data: {} }); + vi.mocked(api.postForm).mockResolvedValue({ success: true, data: {} }); + useInferenceOptionsStore.getState().reset(); +}); + +/** + * Radix Select cannot be opened by a plain click under jsdom (pointer capture), + * but the keyboard path works: Enter opens the listbox, a click commits. + */ +async function selectOption(trigger: HTMLElement, optionName: string) { + fireEvent.keyDown(trigger, { key: 'Enter', code: 'Enter' }); + fireEvent.click(await screen.findByRole('option', { name: optionName })); +} + +function taskTypeTrigger() { + return screen.getByTestId('inference-task-type-trigger'); +} + +function labelFile(name = 'labels.txt', content = '0 剪刀\n1 石頭\n2 布\n') { + return new File([content], name, { type: 'text/plain' }); +} + +function uploadLabelFile(file: File) { + const input = screen.getByTestId('inference-label-file-input') as HTMLInputElement; + fireEvent.change(input, { target: { files: [file] } }); +} + +describe('InferenceOptions — parsing mode selector', () => { + it('mirrors the task type reported by the latest result', () => { + render(); + expect(taskTypeTrigger()).toHaveTextContent('分類'); + }); + + it('shows object detection for a detection result', () => { + render(); + expect(taskTypeTrigger()).toHaveTextContent('物件偵測'); + }); + + it('falls back to object detection for the legacy "detection" spelling (R-4)', () => { + render(); + expect(taskTypeTrigger()).toHaveTextContent('物件偵測'); + }); + + it('falls back to object detection when no result has arrived yet', () => { + render(); + expect(taskTypeTrigger()).toHaveTextContent('物件偵測'); + }); + + it('POSTs the newly chosen task type', async () => { + render(); + await selectOption(taskTypeTrigger(), '分類'); + + await waitFor(() => expect(api.post).toHaveBeenCalled()); + expect(api.post).toHaveBeenCalledWith(OPTIONS_PATH, { taskType: 'classification' }); + }); + + it('keeps showing the user choice after it is applied, not the stale result type', async () => { + // The prop still says object_detection (no new frame has arrived yet). The + // explicit choice must win, otherwise the selector snaps back and looks + // like the switch failed. + render(); + await selectOption(taskTypeTrigger(), '分類'); + + await waitFor(() => expect(taskTypeTrigger()).toHaveTextContent('分類')); + }); + + it('adopts the task type echoed back by the server', async () => { + vi.mocked(api.post).mockResolvedValue({ + success: true, + data: { taskType: 'object_detection' }, + }); + render(); + await selectOption(taskTypeTrigger(), '分類'); + + // Server refused to switch and said "still object_detection" — the UI must + // not claim classification is in effect. + await waitFor(() => expect(taskTypeTrigger()).toHaveTextContent('物件偵測')); + }); + + it('surfaces a failed switch', async () => { + vi.mocked(api.post).mockResolvedValue({ + success: false, + error: { code: 'DEVICE_NOT_CONNECTED', message: 'device not connected' }, + }); + render(); + await selectOption(taskTypeTrigger(), '分類'); + + const err = await screen.findByTestId('inference-options-error'); + expect(err.textContent).toContain('device not connected'); + }); +}); + +describe('InferenceOptions — label section visibility', () => { + it('hides the label section for object detection', () => { + render(); + expect(screen.queryByTestId('inference-label-section')).toBeNull(); + }); + + it('shows the label section for classification', () => { + render(); + expect(screen.getByTestId('inference-label-section')).toBeInTheDocument(); + }); + + it('reveals the label section once the user switches to classification', async () => { + render(); + expect(screen.queryByTestId('inference-label-section')).toBeNull(); + + await selectOption(taskTypeTrigger(), '分類'); + await waitFor(() => + expect(screen.getByTestId('inference-label-section')).toBeInTheDocument(), + ); + }); + + it('hides the label section again when switching back to detection', async () => { + render(); + expect(screen.getByTestId('inference-label-section')).toBeInTheDocument(); + + await selectOption(taskTypeTrigger(), '物件偵測'); + await waitFor(() => expect(screen.queryByTestId('inference-label-section')).toBeNull()); + }); +}); + +describe('InferenceOptions — label upload', () => { + it('uploads the picked file as multipart to the options endpoint', async () => { + render(); + uploadLabelFile(labelFile()); + + await waitFor(() => expect(api.postForm).toHaveBeenCalled()); + const [path, form] = vi.mocked(api.postForm).mock.calls[0]; + expect(path).toBe(OPTIONS_PATH); + expect(form).toBeInstanceOf(FormData); + expect((form.get('labelFile') as File).name).toBe('labels.txt'); + }); + + it('shows the applied labels returned by the server', async () => { + vi.mocked(api.postForm).mockResolvedValue({ + success: true, + data: { labelCount: 3, labels: ['剪刀', '石頭', '布'] }, + }); + render(); + uploadLabelFile(labelFile()); + + await waitFor(() => { + const status = screen.getByTestId('inference-label-status'); + expect(status.textContent).toContain('3'); + expect(status.textContent).toContain('剪刀'); + }); + }); + + it('says no labels are set before anything is uploaded', () => { + render(); + expect(screen.getByTestId('inference-label-status').textContent).toContain( + '尚未上傳標籤檔', + ); + }); + + it('offers a clear action only after labels are applied', async () => { + vi.mocked(api.postForm).mockResolvedValue({ + success: true, + data: { labels: ['剪刀', '石頭', '布'] }, + }); + render(); + expect(screen.queryByTestId('inference-label-clear-btn')).toBeNull(); + + uploadLabelFile(labelFile()); + await waitFor(() => + expect(screen.getByTestId('inference-label-clear-btn')).toBeInTheDocument(), + ); + }); + + it('clears the mapping through the endpoint and drops back to raw indices', async () => { + vi.mocked(api.postForm).mockResolvedValue({ + success: true, + data: { labels: ['剪刀', '石頭', '布'] }, + }); + render(); + uploadLabelFile(labelFile()); + await waitFor(() => + expect(screen.getByTestId('inference-label-clear-btn')).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByTestId('inference-label-clear-btn')); + + await waitFor(() => + expect(screen.getByTestId('inference-label-status').textContent).toContain( + '尚未上傳標籤檔', + ), + ); + // Clearing goes over JSON with an explicit empty array — an omitted field + // would leave the mapping in place on the device. + expect(api.post).toHaveBeenCalledWith(OPTIONS_PATH, { labels: [] }); + }); + + it('lets the same file be re-picked after fixing it on disk', async () => { + render(); + uploadLabelFile(labelFile()); + await waitFor(() => expect(api.postForm).toHaveBeenCalledTimes(1)); + + // The input value is reset after each pick, so selecting the same filename + // again still fires a change event. + uploadLabelFile(labelFile()); + await waitFor(() => expect(api.postForm).toHaveBeenCalledTimes(2)); + }); +}); + +describe('InferenceOptions — label upload errors', () => { + it('rejects a non-.txt file without hitting the network', async () => { + render(); + uploadLabelFile(labelFile('labels.json', '{}')); + + expect(await screen.findByTestId('inference-options-error')).toHaveTextContent( + '只接受 .txt 或 .names 檔', + ); + expect(api.postForm).not.toHaveBeenCalled(); + }); + + it('accepts a .names file', async () => { + render(); + uploadLabelFile(labelFile('coco.names')); + + await waitFor(() => expect(api.postForm).toHaveBeenCalled()); + expect(screen.queryByTestId('inference-options-error')).toBeNull(); + }); + + it('rejects an oversized file without hitting the network', async () => { + const huge = labelFile('labels.txt', 'x'.repeat(MAX_LABEL_FILE_BYTES + 1)); + render(); + uploadLabelFile(huge); + + expect(await screen.findByTestId('inference-options-error')).toHaveTextContent('過大'); + expect(api.postForm).not.toHaveBeenCalled(); + }); + + it('shows the offending line number for a parse error', async () => { + vi.mocked(api.postForm).mockResolvedValue( + reply({ + success: false, + error: { + code: 'LABEL_PARSE_ERROR', + message: "index 必須為非負整數,收到 'abc'", + line: 5, + }, + }), + ); + render(); + uploadLabelFile(labelFile()); + + const err = await screen.findByTestId('inference-options-error'); + // The line number is the whole reason parsing lives server-side — losing it + // would leave the user hunting through their file blind. + expect(err.textContent).toContain('5'); + expect(err.textContent).toContain('非負整數'); + }); + + it('shows a generic failure message when the server sends no line number', async () => { + vi.mocked(api.postForm).mockResolvedValue({ + success: false, + error: { code: 'STORAGE_ERROR', message: 'could not write labels' }, + }); + render(); + uploadLabelFile(labelFile()); + + const err = await screen.findByTestId('inference-options-error'); + expect(err.textContent).toContain('could not write labels'); + }); + + it('keeps the previous mapping when a replacement upload fails', async () => { + vi.mocked(api.postForm).mockResolvedValue({ + success: true, + data: { labels: ['剪刀', '石頭', '布'] }, + }); + render(); + uploadLabelFile(labelFile()); + await waitFor(() => + expect(screen.getByTestId('inference-label-status').textContent).toContain('剪刀'), + ); + + vi.mocked(api.postForm).mockResolvedValue( + reply({ + success: false, + error: { code: 'LABEL_PARSE_ERROR', message: 'bad', line: 2 }, + }), + ); + uploadLabelFile(labelFile('bad.txt', 'garbage')); + + await screen.findByTestId('inference-options-error'); + // A failed replacement must not silently wipe the mapping that is still in + // effect on the device. + expect(screen.getByTestId('inference-label-status').textContent).toContain('剪刀'); + }); + + it('clears a stale error once a later upload succeeds', async () => { + vi.mocked(api.postForm).mockResolvedValue( + reply({ + success: false, + error: { code: 'LABEL_PARSE_ERROR', message: 'bad', line: 2 }, + }), + ); + render(); + uploadLabelFile(labelFile('bad.txt')); + await screen.findByTestId('inference-options-error'); + + vi.mocked(api.postForm).mockResolvedValue({ + success: true, + data: { labels: ['剪刀'] }, + }); + uploadLabelFile(labelFile()); + + await waitFor(() => expect(screen.queryByTestId('inference-options-error')).toBeNull()); + }); +}); diff --git a/local-tool/frontend/src/tests/components/inference-overlay.test.tsx b/local-tool/frontend/src/tests/components/inference-overlay.test.tsx new file mode 100644 index 0000000..7fe4a64 --- /dev/null +++ b/local-tool/frontend/src/tests/components/inference-overlay.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { InferenceOverlay } from '@/components/camera/inference-overlay'; +import type { InferenceResult } from '@/types/inference'; + +function makeResult(overrides: Partial = {}): InferenceResult { + return { + deviceId: 'dev-1', + modelId: 'model-1', + taskType: 'object_detection', + timestamp: 1784620457620, + latencyMs: 12.5, + detections: [], + classifications: [], + ...overrides, + }; +} + +const SIZE = { width: 640, height: 480, confidenceThreshold: 0.5 }; + +describe('InferenceOverlay — M3-a taskType dispatch', () => { + it('renders the bbox canvas for object_detection', () => { + render(); + expect(screen.getByTestId('camera-overlay')).toBeInTheDocument(); + expect(screen.queryByTestId('classification-overlay')).toBeNull(); + }); + + it('renders the bbox canvas for the legacy "detection" spelling (R-4)', () => { + render(); + expect(screen.getByTestId('camera-overlay')).toBeInTheDocument(); + }); + + it('falls back to the bbox canvas when taskType is unknown', () => { + render(); + expect(screen.getByTestId('camera-overlay')).toBeInTheDocument(); + }); + + it('falls back to the bbox canvas when there is no result at all', () => { + render(); + expect(screen.getByTestId('camera-overlay')).toBeInTheDocument(); + }); + + it('renders the classification chip — and NO bbox canvas — for classification', () => { + render( + , + ); + expect(screen.getByTestId('classification-overlay')).toBeInTheDocument(); + // The whole point of the feature: classification must not draw boxes. + expect(screen.queryByTestId('camera-overlay')).toBeNull(); + }); +}); + +describe('ClassificationOverlay rendering', () => { + function renderClassification(classifications: InferenceResult['classifications'], threshold = 0.5) { + return render( + , + ); + } + + it('shows the top-1 label and confidence', () => { + renderClassification([ + { label: '布', classIndex: 2, confidence: 0.0384 }, + { label: '石頭', classIndex: 1, confidence: 0.9425 }, + ]); + expect(screen.getByTestId('classification-overlay-label').textContent).toBe('石頭'); + expect(screen.getByTestId('classification-overlay-confidence').textContent).toBe('94.3%'); + }); + + it('falls back to class_ when the label is missing', () => { + renderClassification([{ label: '', classIndex: 1, confidence: 0.88 }]); + expect(screen.getByTestId('classification-overlay-label').textContent).toBe('class_1'); + }); + + it('renders without crashing when classIndex is absent (M2-d not shipped)', () => { + renderClassification([{ label: 'class_1', confidence: 0.88 }]); + expect(screen.getByTestId('classification-overlay-label').textContent).toBe('class_1'); + }); + + it('shows the unrecognized state when nothing clears the threshold', () => { + renderClassification([{ label: '石頭', classIndex: 1, confidence: 0.2 }]); + expect(screen.getByTestId('classification-overlay-empty')).toBeInTheDocument(); + expect(screen.queryByTestId('classification-overlay-label')).toBeNull(); + }); + + it('shows the unrecognized state when the classifications array is empty', () => { + renderClassification([]); + expect(screen.getByTestId('classification-overlay-empty')).toBeInTheDocument(); + }); + + it('exposes the verdict to assistive tech via a polite live region', () => { + renderClassification([{ label: '石頭', classIndex: 1, confidence: 0.9 }]); + const status = screen.getByRole('status'); + expect(status.getAttribute('aria-live')).toBe('polite'); + expect(status.textContent).toContain('石頭'); + }); +}); diff --git a/local-tool/frontend/src/tests/components/inference-panel.test.tsx b/local-tool/frontend/src/tests/components/inference-panel.test.tsx new file mode 100644 index 0000000..e277586 --- /dev/null +++ b/local-tool/frontend/src/tests/components/inference-panel.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { InferencePanel } from '@/components/inference/inference-panel'; +import { useInferenceStore } from '@/stores/inference-store'; +import { useCameraStore } from '@/stores/camera-store'; +import { useInferenceOptionsStore } from '@/stores/inference-options-store'; +import type { InferenceResult } from '@/types/inference'; + +const DEVICE_ID = 'dev-1'; + +function makeResult(overrides: Partial = {}): InferenceResult { + return { + deviceId: 'dev-1', + modelId: 'model-1', + taskType: 'object_detection', + timestamp: 1784620457620, + latencyMs: 12.5, + detections: [], + classifications: [], + ...overrides, + }; +} + +function setResult(result: InferenceResult | null) { + useInferenceStore.setState({ result, confidenceThreshold: 0.5 }); +} + +beforeEach(() => { + useInferenceStore.setState({ + isRunning: false, + result: null, + results: [], + fps: 0, + avgLatency: 0, + confidenceThreshold: 0.5, + batchResults: {}, + }); + useCameraStore.setState({ sourceType: 'camera', batchSelectedIndex: 0, batchImages: [] }); + useInferenceOptionsStore.getState().reset(); +}); + +describe('InferencePanel — M3-b taskType card switching', () => { + it('shows the detection list card for object_detection', () => { + setResult( + makeResult({ + detections: [ + { label: 'person', confidence: 0.9, bbox: { x: 0, y: 0, width: 0.1, height: 0.1 } }, + ], + }), + ); + render(); + expect(screen.getByTestId('detection-result-list')).toBeInTheDocument(); + expect(screen.queryByTestId('classification-result-chart')).toBeNull(); + }); + + it('shows the classification chart card for classification', () => { + setResult( + makeResult({ + taskType: 'classification', + classifications: [{ label: '石頭', classIndex: 1, confidence: 0.94 }], + }), + ); + render(); + expect(screen.getByTestId('classification-result-chart')).toBeInTheDocument(); + expect(screen.queryByTestId('detection-result-list')).toBeNull(); + }); + + it('defaults to the detection card when there is no result yet', () => { + setResult(null); + render(); + // No result → detection branch renders its empty state, not the chart. + expect(screen.queryByTestId('classification-result-chart')).toBeNull(); + }); + + it('defaults to the detection card for the legacy "detection" spelling (R-4)', () => { + setResult( + makeResult({ + taskType: 'detection', + detections: [ + { label: 'person', confidence: 0.9, bbox: { x: 0, y: 0, width: 0.1, height: 0.1 } }, + ], + }), + ); + render(); + expect(screen.getByTestId('detection-result-list')).toBeInTheDocument(); + }); +}); diff --git a/local-tool/frontend/src/tests/lib/classification.test.ts b/local-tool/frontend/src/tests/lib/classification.test.ts new file mode 100644 index 0000000..c61c44b --- /dev/null +++ b/local-tool/frontend/src/tests/lib/classification.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from 'vitest'; +import { + isClassificationTask, + classResultLabel, + classResultKey, + pickTopClass, + nextHysteresisState, + INITIAL_HYSTERESIS_STATE, + DEFAULT_HYSTERESIS, + type HysteresisOptions, + type HysteresisState, +} from '@/lib/classification'; +import type { ClassResult } from '@/types/inference'; + +const OPTS: HysteresisOptions = { + ...DEFAULT_HYSTERESIS, + confidenceThreshold: 0.5, +}; + +function cls(label: string, confidence: number, classIndex?: number): ClassResult { + return classIndex === undefined ? { label, confidence } : { label, confidence, classIndex }; +} + +/** Feed a sequence of frames through the state machine, return displayed labels. */ +function run(frames: ClassResult[][], options: HysteresisOptions = OPTS): (string | null)[] { + let state: HysteresisState = INITIAL_HYSTERESIS_STATE; + return frames.map((f) => { + state = nextHysteresisState(state, f, options); + return state.displayed ? classResultLabel(state.displayed) : null; + }); +} + +describe('isClassificationTask — R-4 defensive branching', () => { + it('is true only for the exact classification value', () => { + expect(isClassificationTask('classification')).toBe(true); + }); + + it('is false for both detection spellings, so detection stays the fall-through', () => { + expect(isClassificationTask('detection')).toBe(false); + expect(isClassificationTask('object_detection')).toBe(false); + }); + + it('is false for undefined/null/unknown values', () => { + expect(isClassificationTask(undefined)).toBe(false); + expect(isClassificationTask(null)).toBe(false); + expect(isClassificationTask('segmentation')).toBe(false); + }); +}); + +describe('classResultLabel — classIndex fallback (M2-d may be absent)', () => { + it('uses the label when present', () => { + expect(classResultLabel(cls('石頭', 0.9, 1))).toBe('石頭'); + }); + + it('falls back to class_ when the label is empty', () => { + expect(classResultLabel(cls('', 0.9, 7))).toBe('class_7'); + }); + + it('falls back to class_ when the label is only whitespace', () => { + expect(classResultLabel(cls(' ', 0.9, 0))).toBe('class_0'); + }); + + it('emits a placeholder when both label and classIndex are missing', () => { + expect(classResultLabel(cls('', 0.9))).toBe('class_?'); + }); + + it('works when classIndex is undefined but the label exists (the common case)', () => { + expect(classResultLabel(cls('cat', 0.9))).toBe('cat'); + }); +}); + +describe('classResultKey — identity used by hysteresis', () => { + it('prefers classIndex so label churn does not reset the state machine', () => { + expect(classResultKey(cls('石頭', 0.9, 1))).toBe('i:1'); + expect(classResultKey(cls('rock', 0.9, 1))).toBe('i:1'); + }); + + it('falls back to the label when classIndex is absent', () => { + expect(classResultKey(cls('石頭', 0.9))).toBe('l:石頭'); + }); +}); + +describe('pickTopClass', () => { + it('returns null for empty/undefined input', () => { + expect(pickTopClass([])).toBeNull(); + expect(pickTopClass(undefined)).toBeNull(); + }); + + it('returns the highest-confidence entry regardless of array order', () => { + const top = pickTopClass([cls('a', 0.1), cls('b', 0.8), cls('c', 0.3)]); + expect(top?.label).toBe('b'); + }); +}); + +describe('nextHysteresisState — R-9 anti-flicker', () => { + it('shows the first above-threshold result immediately', () => { + expect(run([[cls('石頭', 0.9)]])).toEqual(['石頭']); + }); + + it('shows nothing when the top class is below the confidence threshold', () => { + expect(run([[cls('石頭', 0.3)]])).toEqual([null]); + }); + + it('clears the label when confidence drops below the threshold', () => { + expect(run([[cls('石頭', 0.9)], [cls('石頭', 0.2)]])).toEqual(['石頭', null]); + }); + + it('does NOT switch on a single dissenting frame (the core anti-flicker case)', () => { + // 石頭 established, then one frame where 布 barely wins → must hold 石頭. + const out = run([ + [cls('石頭', 0.6), cls('布', 0.4)], + [cls('布', 0.62), cls('石頭', 0.58)], + [cls('石頭', 0.6), cls('布', 0.4)], + ]); + expect(out).toEqual(['石頭', '石頭', '石頭']); + }); + + it('switches after streakFrames consecutive frames of the challenger', () => { + // Margins stay under immediateMargin (0.15) so only the streak can promote. + const out = run([ + [cls('石頭', 0.60)], + [cls('布', 0.62)], + [cls('布', 0.63)], + [cls('布', 0.64)], + ]); + // frame1 石頭; frames 2-3 still 石頭 (streak 1,2); frame 4 promotes 布 (streak 3). + expect(out).toEqual(['石頭', '石頭', '石頭', '布']); + }); + + it('switches immediately when the challenger wins by the confidence margin', () => { + const out = run([ + [cls('石頭', 0.60)], + [cls('布', 0.80)], // +0.20 >= immediateMargin 0.15 + ]); + expect(out).toEqual(['石頭', '布']); + }); + + it('resets the streak when the challenger is interrupted', () => { + const out = run([ + [cls('石頭', 0.60)], + [cls('布', 0.62)], // streak 1 + [cls('石頭', 0.61)], // incumbent back → streak cleared + [cls('布', 0.62)], // streak restarts at 1 + [cls('布', 0.62)], // streak 2 + ]); + expect(out).toEqual(['石頭', '石頭', '石頭', '石頭', '石頭']); + }); + + it('keeps refreshing confidence of the incumbent while it stays on top', () => { + let state: HysteresisState = INITIAL_HYSTERESIS_STATE; + state = nextHysteresisState(state, [cls('石頭', 0.60)], OPTS); + state = nextHysteresisState(state, [cls('石頭', 0.95)], OPTS); + expect(state.displayed?.confidence).toBe(0.95); + }); + + it('uses classIndex identity so a re-labelled same class is not treated as a switch', () => { + let state: HysteresisState = INITIAL_HYSTERESIS_STATE; + state = nextHysteresisState(state, [cls('石頭', 0.60, 1)], OPTS); + // Same index, different label string → same class, no hysteresis delay. + state = nextHysteresisState(state, [cls('rock', 0.61, 1)], OPTS); + expect(state.displayed?.label).toBe('rock'); + expect(state.candidateStreak).toBe(0); + }); + + it('clears state entirely on an empty frame', () => { + let state: HysteresisState = INITIAL_HYSTERESIS_STATE; + state = nextHysteresisState(state, [cls('石頭', 0.9)], OPTS); + state = nextHysteresisState(state, [], OPTS); + expect(state).toEqual(INITIAL_HYSTERESIS_STATE); + }); + + it('honours a raised confidenceThreshold on the very next frame', () => { + let state: HysteresisState = INITIAL_HYSTERESIS_STATE; + state = nextHysteresisState(state, [cls('石頭', 0.6)], OPTS); + expect(state.displayed).not.toBeNull(); + state = nextHysteresisState(state, [cls('石頭', 0.6)], { ...OPTS, confidenceThreshold: 0.8 }); + expect(state.displayed).toBeNull(); + }); +}); diff --git a/local-tool/frontend/src/tests/setup.ts b/local-tool/frontend/src/tests/setup.ts index 9772ddc..ca55bea 100644 --- a/local-tool/frontend/src/tests/setup.ts +++ b/local-tool/frontend/src/tests/setup.ts @@ -6,6 +6,31 @@ afterEach(() => { cleanup(); }); +// jsdom does not implement ResizeObserver, which Radix primitives (Slider) and +// CameraFeed rely on. A no-op stub is enough: tests assert on rendered output, +// not on resize-driven behaviour. +if (!('ResizeObserver' in globalThis)) { + class ResizeObserverStub implements ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } + globalThis.ResizeObserver = ResizeObserverStub; +} + +// jsdom implements neither the Pointer Capture API nor scrollIntoView, both of +// which Radix Select calls while opening its dropdown. Without these stubs the +// trigger throws `target.hasPointerCapture is not a function` and the listbox +// never mounts, so any test that opens a