visionA/local-tool/frontend/src/tests/components/inference-options.test.tsx
jim800121chen 5c1c37d151 feat(frontend): classification 結果呈現與推論期控制項
detection 畫框、classification 改在影像右上角疊標籤(不畫框),
並在推論面板提供即時切換解析方式與上傳 label 檔。

- 新增 InferenceOverlay 依 taskType 分派;ClassificationOverlay 用
  DOM 而非 canvas,讓 CJK 排版與 aria-live 交給瀏覽器處理
- 標籤防閃爍:挑戰者需連續 3 幀居冠才切換,或信心度領先 15% 直接切;
  低於門檻立即清空(顯示過期標籤比空白更糟)
- 身分比對優先用 classIndex,避免換 label 檔時被誤判為換類別
- 推論設定卡片:即時切 detection/classification、上傳 .txt label、
  清除 label,皆不需重燒
- classification-result 原本無條件渲染,導致 detection 模式下側欄
  永遠顯示「分類結果」與空圖表,改為依 taskType 切換
- 清掉 camera-overlay 每幀執行的 debug console.log
- 補 ResizeObserver stub,原本任何渲染 InferencePanel 的測試都會
  在 jsdom 直接拋錯

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:27:28 +08:00

346 lines
14 KiB
TypeScript

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<InferenceOptionsResponse, InferenceOptionsError>;
const reply = (r: OptionsResponse) => r as ApiResponse<InferenceOptionsResponse>;
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
expect(taskTypeTrigger()).toHaveTextContent('分類');
});
it('shows object detection for a detection result', () => {
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
expect(taskTypeTrigger()).toHaveTextContent('物件偵測');
});
it('falls back to object detection for the legacy "detection" spelling (R-4)', () => {
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="detection" />);
expect(taskTypeTrigger()).toHaveTextContent('物件偵測');
});
it('falls back to object detection when no result has arrived yet', () => {
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType={undefined} />);
expect(taskTypeTrigger()).toHaveTextContent('物件偵測');
});
it('POSTs the newly chosen task type', async () => {
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
expect(screen.queryByTestId('inference-label-section')).toBeNull();
});
it('shows the label section for classification', () => {
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
expect(screen.getByTestId('inference-label-section')).toBeInTheDocument();
});
it('reveals the label section once the user switches to classification', async () => {
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
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(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
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());
});
});