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()); }); });