+
+
+ {t('inference.options.taskType')}
+
+
+
+
+
+
+ {FLASH_TASK_TYPES.map((value) => (
+
+ {taskTypeLabel(value)}
+
+ ))}
+
+
+
+ {t('inference.options.taskTypeHint')}
+
+
+
+ {isClassification && (
+
+
+ {t('inference.options.labels')}
+
+
{t('inference.options.labelsHint')}
+
+
+
+
+ fileInputRef.current?.click()}
+ data-testid="inference-label-upload-btn"
+ >
+ {labels
+ ? t('inference.options.replaceLabelFile')
+ : t('inference.options.selectLabelFile')}
+
+ {labels && (
+
+ {t('inference.options.clearLabels')}
+
+ )}
+
+
+
+ {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 is untestable.
+if (!Element.prototype.hasPointerCapture) {
+ Element.prototype.hasPointerCapture = () => false;
+ Element.prototype.setPointerCapture = () => {};
+ Element.prototype.releasePointerCapture = () => {};
+}
+if (!Element.prototype.scrollIntoView) {
+ Element.prototype.scrollIntoView = () => {};
+}
+
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
diff --git a/local-tool/frontend/src/tests/stores/inference-options-store.test.ts b/local-tool/frontend/src/tests/stores/inference-options-store.test.ts
new file mode 100644
index 0000000..8980566
--- /dev/null
+++ b/local-tool/frontend/src/tests/stores/inference-options-store.test.ts
@@ -0,0 +1,169 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import {
+ useInferenceOptionsStore,
+ validateLabelFile,
+ MAX_LABEL_FILE_BYTES,
+ type InferenceOptionsResponse,
+ type InferenceOptionsError,
+} from '@/stores/inference-options-store';
+import { api, type ApiResponse } from '@/lib/api';
+
+/**
+ * The store widens the error type so a parse error keeps its `line`. `vi.mocked`
+ * resolves the generic to the default `ApiError`, so mock responses go 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: {
+ post: vi.fn(),
+ postForm: vi.fn(),
+ },
+ 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();
+});
+
+function file(name = 'labels.txt', content = '0 a\n') {
+ return new File([content], name, { type: 'text/plain' });
+}
+
+describe('inference options store — request shapes', () => {
+ it('POSTs JSON { taskType } to the device options path', async () => {
+ await useInferenceOptionsStore.getState().setTaskType(DEVICE_ID, 'classification');
+ expect(api.post).toHaveBeenCalledWith(OPTIONS_PATH, { taskType: 'classification' });
+ });
+
+ it('POSTs the label file as multipart under the "labelFile" field', async () => {
+ await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
+ const [path, form] = vi.mocked(api.postForm).mock.calls[0];
+ expect(path).toBe(OPTIONS_PATH);
+ // The server reads `labelFile`; anything else is silently ignored and the
+ // request then fails the "neither taskType nor labels" guard.
+ expect((form.get('labelFile') as File).name).toBe('labels.txt');
+ });
+
+ it('clears via an explicit empty labels array, not multipart', async () => {
+ await useInferenceOptionsStore.getState().clearLabels(DEVICE_ID);
+ // Omitting `labels` means "leave untouched" server-side, so the empty array
+ // has to be on the wire for the mapping to actually be dropped.
+ expect(api.post).toHaveBeenCalledWith(OPTIONS_PATH, { labels: [] });
+ expect(api.postForm).not.toHaveBeenCalled();
+ });
+});
+
+describe('inference options store — state transitions', () => {
+ it('returns true and records the task type on success', async () => {
+ const ok = await useInferenceOptionsStore
+ .getState()
+ .setTaskType(DEVICE_ID, 'classification');
+ expect(ok).toBe(true);
+ expect(useInferenceOptionsStore.getState().taskType).toBe('classification');
+ });
+
+ it('returns false and leaves the task type untouched on failure', async () => {
+ vi.mocked(api.post).mockResolvedValue({
+ success: false,
+ error: { code: 'DEVICE_NOT_CONNECTED', message: 'nope' },
+ });
+ const ok = await useInferenceOptionsStore
+ .getState()
+ .setTaskType(DEVICE_ID, 'classification');
+
+ expect(ok).toBe(false);
+ // A rejected switch must not leave the UI claiming the new mode is active.
+ expect(useInferenceOptionsStore.getState().taskType).toBeNull();
+ expect(useInferenceOptionsStore.getState().error?.code).toBe('DEVICE_NOT_CONNECTED');
+ });
+
+ it('preserves the parse error line number', async () => {
+ vi.mocked(api.postForm).mockResolvedValue(
+ reply({
+ success: false,
+ error: { code: 'LABEL_PARSE_ERROR', message: 'bad index', line: 7 },
+ }),
+ );
+ await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
+ expect(useInferenceOptionsStore.getState().error?.line).toBe(7);
+ });
+
+ it('keeps existing labels when a replacement upload fails', async () => {
+ vi.mocked(api.postForm).mockResolvedValue({ success: true, data: { labels: ['a', 'b'] } });
+ await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
+
+ vi.mocked(api.postForm).mockResolvedValue(
+ reply({
+ success: false,
+ error: { code: 'LABEL_PARSE_ERROR', message: 'bad', line: 1 },
+ }),
+ );
+ await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
+
+ expect(useInferenceOptionsStore.getState().labels).toEqual(['a', 'b']);
+ });
+
+ it('turns a thrown network error into an error envelope rather than rejecting', async () => {
+ vi.mocked(api.post).mockRejectedValue(new Error('offline'));
+ const ok = await useInferenceOptionsStore
+ .getState()
+ .setTaskType(DEVICE_ID, 'classification');
+
+ expect(ok).toBe(false);
+ expect(useInferenceOptionsStore.getState().error?.code).toBe('NETWORK_ERROR');
+ expect(useInferenceOptionsStore.getState().applying).toBe(false);
+ });
+
+ it('drops both the task type and labels on reset', async () => {
+ vi.mocked(api.postForm).mockResolvedValue({ success: true, data: { labels: ['a'] } });
+ await useInferenceOptionsStore.getState().setTaskType(DEVICE_ID, 'classification');
+ await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
+
+ useInferenceOptionsStore.getState().reset();
+
+ const s = useInferenceOptionsStore.getState();
+ expect(s.taskType).toBeNull();
+ expect(s.labels).toBeNull();
+ expect(s.error).toBeNull();
+ });
+});
+
+describe('validateLabelFile', () => {
+ it('accepts .txt', () => {
+ expect(validateLabelFile(file('labels.txt'))).toEqual({ ok: true });
+ });
+
+ it('accepts .names', () => {
+ expect(validateLabelFile(file('coco.names'))).toEqual({ ok: true });
+ });
+
+ it('accepts an uppercase extension', () => {
+ expect(validateLabelFile(file('LABELS.TXT'))).toEqual({ ok: true });
+ });
+
+ it('rejects another extension', () => {
+ expect(validateLabelFile(file('labels.json'))).toEqual({
+ ok: false,
+ reason: 'wrong-type',
+ });
+ });
+
+ it('rejects a file over the size cap', () => {
+ const huge = file('labels.txt', 'x'.repeat(MAX_LABEL_FILE_BYTES + 1));
+ expect(validateLabelFile(huge)).toEqual({ ok: false, reason: 'too-large' });
+ });
+
+ it('accepts a file exactly at the cap', () => {
+ const exact = file('labels.txt', 'x'.repeat(MAX_LABEL_FILE_BYTES));
+ expect(validateLabelFile(exact)).toEqual({ ok: true });
+ });
+});
diff --git a/local-tool/frontend/src/types/inference.ts b/local-tool/frontend/src/types/inference.ts
index 54cc14f..4c72809 100644
--- a/local-tool/frontend/src/types/inference.ts
+++ b/local-tool/frontend/src/types/inference.ts
@@ -8,6 +8,11 @@ export interface BBox {
export interface ClassResult {
label: string;
confidence: number;
+ /**
+ * Raw class index from the model output. Optional — the Go layer may not
+ * forward it (M2-d is optional), so consumers must fall back to `label`.
+ */
+ classIndex?: number;
}
export interface DetectionResult {