feat(T03): OCR-Erfassung via OpenRouter Qwen2.5-VL + OCR UI with drag-and-drop

This commit is contained in:
2026-07-17 00:45:14 +02:00
parent b128ea6b39
commit 149ac04dc1
20 changed files with 2348 additions and 182 deletions
+294
View File
@@ -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();
});
});