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>
346 lines
14 KiB
TypeScript
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());
|
|
});
|
|
});
|