feat(T03): OCR-Erfassung via OpenRouter Qwen2.5-VL + OCR UI with drag-and-drop
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { OCRUpload } from '@/components/ocr/OCRUpload';
|
||||
import { OCRResults } from '@/components/ocr/OCRResults';
|
||||
import { OCRDetail } from '@/components/ocr/OCRDetail';
|
||||
import type { OCRResultResponse } from '@/lib/ocr';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => { store[key] = value; },
|
||||
removeItem: (key: string) => { delete store[key]; },
|
||||
clear: () => { store = {}; },
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
|
||||
// Mock apiFetch to avoid auth header complexity for list/get tests
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
API_BASE_URL: 'http://localhost:8000/api/v1',
|
||||
}));
|
||||
|
||||
const { apiFetch } = await import('@/lib/api');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.clear();
|
||||
});
|
||||
|
||||
const mockOCRResult: OCRResultResponse = {
|
||||
id: 'result-123',
|
||||
vehicle_id: 'vehicle-456',
|
||||
file_path: '/tmp/uploads/scan.png',
|
||||
file_name: 'scan.png',
|
||||
mime_type: 'image/png',
|
||||
status: 'completed',
|
||||
raw_text: '{"brand": "Mercedes"}',
|
||||
structured_data: {
|
||||
brand: 'Mercedes',
|
||||
model: 'Actros',
|
||||
vin: 'WDB9066351L123456',
|
||||
first_registration: '15.03.2020',
|
||||
mileage: 120000,
|
||||
power_kw: 350,
|
||||
fuel_type: 'Diesel',
|
||||
},
|
||||
confidence_score: 0.92,
|
||||
error_message: null,
|
||||
created_at: '2026-07-16T10:00:00Z',
|
||||
updated_at: '2026-07-16T10:01:00Z',
|
||||
};
|
||||
|
||||
describe('OCRUpload', () => {
|
||||
it('renders drag-and-drop zone', () => {
|
||||
render(<OCRUpload />);
|
||||
expect(screen.getByTestId('ocr-dropzone')).toBeInTheDocument();
|
||||
expect(screen.getByText('Drag and drop scan image here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows file input on click', () => {
|
||||
render(<OCRUpload />);
|
||||
const input = screen.getByTestId('ocr-file-input');
|
||||
expect(input).toHaveAttribute('type', 'file');
|
||||
expect(input).toHaveAttribute('accept', 'image/*');
|
||||
});
|
||||
|
||||
it('shows error for non-image file', async () => {
|
||||
render(<OCRUpload />);
|
||||
const input = screen.getByTestId('ocr-file-input') as HTMLInputElement;
|
||||
|
||||
const file = new File(['data'], 'doc.pdf', { type: 'application/pdf' });
|
||||
Object.defineProperty(input, 'files', { value: [file], writable: false });
|
||||
fireEvent.change(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ocr-upload-error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Only image files are allowed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads image file successfully', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
message: 'OCR processing queued',
|
||||
ocr_result_id: 'new-result-id',
|
||||
status: 'pending',
|
||||
}),
|
||||
});
|
||||
|
||||
let uploadedId = '';
|
||||
render(<OCRUpload onUploadComplete={(id) => { uploadedId = id; }} />);
|
||||
const input = screen.getByTestId('ocr-file-input') as HTMLInputElement;
|
||||
|
||||
const file = new File(['image-data'], 'scan.png', { type: 'image/png' });
|
||||
Object.defineProperty(input, 'files', { value: [file], writable: false });
|
||||
fireEvent.change(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ocr-upload-success')).toBeInTheDocument();
|
||||
expect(uploadedId).toBe('new-result-id');
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const call = mockFetch.mock.calls[0];
|
||||
expect(call[0]).toContain('/ocr/upload');
|
||||
expect(call[1].method).toBe('POST');
|
||||
});
|
||||
|
||||
it('shows error on upload failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({
|
||||
error: { code: 'INVALID_MIME_TYPE', message: 'Invalid MIME type' },
|
||||
}),
|
||||
});
|
||||
|
||||
render(<OCRUpload />);
|
||||
const input = screen.getByTestId('ocr-file-input') as HTMLInputElement;
|
||||
|
||||
const file = new File(['image-data'], 'scan.png', { type: 'image/png' });
|
||||
Object.defineProperty(input, 'files', { value: [file], writable: false });
|
||||
fireEvent.change(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ocr-upload-error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Invalid MIME type')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OCRResults', () => {
|
||||
it('renders results list', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce({
|
||||
items: [mockOCRResult],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
render(<OCRResults />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('scan.png')).toBeInTheDocument();
|
||||
expect(screen.getByText('completed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state', async () => {
|
||||
vi.mocked(apiFetch).mockImplementationOnce(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
}), 100))
|
||||
);
|
||||
|
||||
render(<OCRResults />);
|
||||
expect(screen.getByTestId('ocr-results-loading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error on fetch failure', async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValueOnce({
|
||||
error: { code: 'UNKNOWN', message: 'Network error' },
|
||||
});
|
||||
|
||||
render(<OCRResults />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ocr-results-error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSelectResult when row is clicked', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce({
|
||||
items: [mockOCRResult],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
const onSelect = vi.fn();
|
||||
render(<OCRResults onSelectResult={onSelect} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ocr-row-result-123')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('ocr-row-result-123'));
|
||||
expect(onSelect).toHaveBeenCalledWith(mockOCRResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OCRDetail', () => {
|
||||
it('renders side-by-side original and extracted data', () => {
|
||||
render(<OCRDetail result={mockOCRResult} />);
|
||||
|
||||
expect(screen.getByTestId('ocr-original-scan')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ocr-extracted-data')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ocr-field-brand')).toHaveTextContent('Mercedes');
|
||||
expect(screen.getByTestId('ocr-field-model')).toHaveTextContent('Actros');
|
||||
expect(screen.getByTestId('ocr-field-vin')).toHaveTextContent('WDB9066351L123456');
|
||||
expect(screen.getByTestId('ocr-field-mileage')).toHaveTextContent('120000 km');
|
||||
expect(screen.getByTestId('ocr-field-power_kw')).toHaveTextContent('350 kW');
|
||||
expect(screen.getByTestId('ocr-field-fuel_type')).toHaveTextContent('Diesel');
|
||||
});
|
||||
|
||||
it('shows apply button when status is completed and vehicle linked', () => {
|
||||
render(<OCRDetail result={mockOCRResult} />);
|
||||
expect(screen.getByTestId('ocr-apply-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides apply button when no vehicle linked', () => {
|
||||
const noVehicleResult = { ...mockOCRResult, vehicle_id: null };
|
||||
render(<OCRDetail result={noVehicleResult} />);
|
||||
expect(screen.queryByTestId('ocr-apply-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides apply button when status is pending', () => {
|
||||
const pendingResult = { ...mockOCRResult, status: 'pending' as const };
|
||||
render(<OCRDetail result={pendingResult} />);
|
||||
expect(screen.queryByTestId('ocr-apply-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends POST apply request on button click', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce({
|
||||
message: 'OCR data applied to vehicle',
|
||||
ocr_result_id: 'result-123',
|
||||
vehicle_id: 'vehicle-456',
|
||||
updated_fields: ['make', 'model', 'mileage_km'],
|
||||
});
|
||||
|
||||
render(<OCRDetail result={mockOCRResult} />);
|
||||
fireEvent.click(screen.getByTestId('ocr-apply-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ocr-apply-success')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Applied 3 fields/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith('/ocr/results/result-123/apply', {
|
||||
method: 'POST',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error on apply failure', async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValueOnce({
|
||||
error: { code: 'APPLY_FAILED', message: 'Vehicle not found' },
|
||||
});
|
||||
|
||||
render(<OCRDetail result={mockOCRResult} />);
|
||||
fireEvent.click(screen.getByTestId('ocr-apply-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ocr-apply-error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vehicle not found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows raw text when available', () => {
|
||||
render(<OCRDetail result={mockOCRResult} />);
|
||||
expect(screen.getByTestId('ocr-raw-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ocr-raw-text')).toHaveTextContent('Mercedes');
|
||||
});
|
||||
|
||||
it('shows error message when status is failed', () => {
|
||||
const failedResult: OCRResultResponse = {
|
||||
...mockOCRResult,
|
||||
status: 'failed',
|
||||
error_message: 'OpenRouter API unavailable',
|
||||
structured_data: null,
|
||||
};
|
||||
render(<OCRDetail result={failedResult} />);
|
||||
expect(screen.getByText(/OpenRouter API unavailable/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no data message when structured_data is null', () => {
|
||||
const noDataResult: OCRResultResponse = {
|
||||
...mockOCRResult,
|
||||
structured_data: null,
|
||||
};
|
||||
render(<OCRDetail result={noDataResult} />);
|
||||
expect(screen.getByText('No structured data available yet.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user