feat(T03): OCR-Erfassung via OpenRouter Qwen2.5-VL + OCR UI with drag-and-drop
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { OCRUpload } from '@/components/ocr/OCRUpload';
|
||||
import { OCRResults } from '@/components/ocr/OCRResults';
|
||||
import { OCRDetail } from '@/components/ocr/OCRDetail';
|
||||
import { getOCRResult, type OCRResultResponse } from '@/lib/ocr';
|
||||
|
||||
export default function OCRPage() {
|
||||
const [selectedResult, setSelectedResult] = useState<OCRResultResponse | null>(null);
|
||||
|
||||
const handleUploadComplete = async (resultId: string) => {
|
||||
// Refresh results by triggering a re-render
|
||||
// The OCRResults component will auto-refresh via its useEffect
|
||||
// Optionally fetch the new result
|
||||
try {
|
||||
const result = await getOCRResult(resultId);
|
||||
setSelectedResult(result);
|
||||
} catch {
|
||||
// Result might not be ready yet, ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectResult = (result: OCRResultResponse) => {
|
||||
setSelectedResult(result);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="ocr-page" className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-text">OCR Erfassung</h1>
|
||||
|
||||
<OCRUpload onUploadComplete={handleUploadComplete} />
|
||||
|
||||
<OCRResults onSelectResult={handleSelectResult} />
|
||||
|
||||
{selectedResult && (
|
||||
<OCRDetail
|
||||
result={selectedResult}
|
||||
onApplyComplete={() => {
|
||||
// Could refresh results here
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { applyOCRToVehicle, type OCRResultResponse } from '@/lib/ocr';
|
||||
|
||||
interface OCRDetailProps {
|
||||
result: OCRResultResponse;
|
||||
onApplyComplete?: () => void;
|
||||
}
|
||||
|
||||
export function OCRDetail({ result, onApplyComplete }: OCRDetailProps) {
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
const [applySuccess, setApplySuccess] = useState<string | null>(null);
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true);
|
||||
setApplyError(null);
|
||||
setApplySuccess(null);
|
||||
try {
|
||||
const response = await applyOCRToVehicle(result.id);
|
||||
setApplySuccess(`Applied ${response.updated_fields.length} fields to vehicle`);
|
||||
if (onApplyComplete) onApplyComplete();
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setApplyError(apiErr?.error?.message || 'Failed to apply OCR data');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const data = result.structured_data;
|
||||
const canApply = result.vehicle_id && result.structured_data && result.status === 'completed';
|
||||
|
||||
return (
|
||||
<div data-testid="ocr-detail" className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Original Scan Side */}
|
||||
<Card title="Original Scan">
|
||||
<div data-testid="ocr-original-scan" className="space-y-2">
|
||||
<p className="text-sm text-text-muted">File: {result.file_name}</p>
|
||||
<p className="text-sm text-text-muted">Type: {result.mime_type}</p>
|
||||
<p className="text-sm text-text-muted">Status: {result.status}</p>
|
||||
{result.confidence_score != null && (
|
||||
<p className="text-sm text-text-muted">
|
||||
Confidence: {(result.confidence_score * 100).toFixed(1)}%
|
||||
</p>
|
||||
)}
|
||||
{result.error_message && (
|
||||
<p className="text-sm text-error">Error: {result.error_message}</p>
|
||||
)}
|
||||
{result.raw_text && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-text mb-1">Raw Text:</p>
|
||||
<pre data-testid="ocr-raw-text" className="text-xs bg-gray-50 p-3 rounded overflow-auto max-h-48">
|
||||
{result.raw_text}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Extracted Data Side */}
|
||||
<Card title="Extracted Data">
|
||||
<div data-testid="ocr-extracted-data" className="space-y-2">
|
||||
{data ? (
|
||||
<dl className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm font-medium text-text">Brand:</dt>
|
||||
<dd className="text-sm text-text-muted" data-testid="ocr-field-brand">{data.brand || '-'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm font-medium text-text">Model:</dt>
|
||||
<dd className="text-sm text-text-muted" data-testid="ocr-field-model">{data.model || '-'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm font-medium text-text">VIN:</dt>
|
||||
<dd className="text-sm text-text-muted" data-testid="ocr-field-vin">{data.vin || '-'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm font-medium text-text">First Registration:</dt>
|
||||
<dd className="text-sm text-text-muted" data-testid="ocr-field-first_registration">{data.first_registration || '-'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm font-medium text-text">Mileage:</dt>
|
||||
<dd className="text-sm text-text-muted" data-testid="ocr-field-mileage">{data.mileage != null ? `${data.mileage} km` : '-'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm font-medium text-text">Power (kW):</dt>
|
||||
<dd className="text-sm text-text-muted" data-testid="ocr-field-power_kw">{data.power_kw != null ? `${data.power_kw} kW` : '-'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm font-medium text-text">Fuel Type:</dt>
|
||||
<dd className="text-sm text-text-muted" data-testid="ocr-field-fuel_type">{data.fuel_type || '-'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No structured data available yet.</p>
|
||||
)}
|
||||
|
||||
{canApply && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
data-testid="ocr-apply-button"
|
||||
onClick={handleApply}
|
||||
loading={applying}
|
||||
>
|
||||
Apply to Vehicle
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{applyError && (
|
||||
<div data-testid="ocr-apply-error" className="mt-2 p-3 bg-error/10 text-error rounded text-sm">
|
||||
{applyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{applySuccess && (
|
||||
<div data-testid="ocr-apply-success" className="mt-2 p-3 bg-green-50 text-green-700 rounded text-sm">
|
||||
{applySuccess}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Table } from '@/components/ui/Table';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { listOCRResults, type OCRResultResponse } from '@/lib/ocr';
|
||||
import type { PaginatedResponse } from '@/lib/api';
|
||||
|
||||
interface OCRResultsProps {
|
||||
vehicleId?: string;
|
||||
onSelectResult?: (result: OCRResultResponse) => void;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
processing: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
manual_review: 'bg-orange-100 text-orange-800',
|
||||
};
|
||||
|
||||
export function OCRResults({ vehicleId, onSelectResult }: OCRResultsProps) {
|
||||
const [results, setResults] = useState<OCRResultResponse[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchResults = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data: PaginatedResponse<OCRResultResponse> = await listOCRResults(vehicleId, page, pageSize);
|
||||
setResults(data.items);
|
||||
setTotal(data.total);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to load OCR results');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [vehicleId, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchResults();
|
||||
}, [fetchResults]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'file_name',
|
||||
label: 'File',
|
||||
render: (row: OCRResultResponse) => (
|
||||
<button
|
||||
data-testid={`ocr-row-${row.id}`}
|
||||
onClick={() => onSelectResult?.(row)}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{row.file_name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ key: 'status', label: 'Status', render: (row: OCRResultResponse) => (
|
||||
<span
|
||||
data-testid={`ocr-status-${row.id}`}
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${STATUS_COLORS[row.status] || 'bg-gray-100 text-gray-800'}`}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
)},
|
||||
{ key: 'confidence_score', label: 'Confidence', render: (row: OCRResultResponse) =>
|
||||
row.confidence_score != null
|
||||
? `${(row.confidence_score * 100).toFixed(1)}%`
|
||||
: '-'
|
||||
},
|
||||
{ key: 'created_at', label: 'Created', render: (row: OCRResultResponse) =>
|
||||
row.created_at ? new Date(row.created_at).toLocaleDateString('de-DE') : '-'
|
||||
},
|
||||
];
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div data-testid="ocr-results" className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-text">OCR Results</h2>
|
||||
<Button variant="secondary" onClick={fetchResults} loading={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div data-testid="ocr-results-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div data-testid="ocr-results-loading" className="text-center py-8 text-text-muted">
|
||||
Loading OCR results...
|
||||
</div>
|
||||
) : (
|
||||
<Table columns={columns} data={results} rowKey={row => row.id} />
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div data-testid="ocr-results-pagination" className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">
|
||||
Page {page} of {totalPages} ({total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { uploadOCRScan } from '@/lib/ocr';
|
||||
|
||||
interface OCRUploadProps {
|
||||
vehicleId?: string;
|
||||
onUploadComplete?: (resultId: string) => void;
|
||||
}
|
||||
|
||||
export function OCRUpload({ vehicleId, onUploadComplete }: OCRUploadProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = useCallback(
|
||||
async (file: File) => {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('Only image files are allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await uploadOCRScan(file, vehicleId);
|
||||
setSuccess(`Upload queued. Result ID: ${result.ocr_result_id}`);
|
||||
if (onUploadComplete) {
|
||||
onUploadComplete(result.ocr_result_id);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[vehicleId, onUploadComplete]
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="ocr-upload" className="space-y-4">
|
||||
<div
|
||||
data-testid="ocr-dropzone"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
|
||||
${isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
data-testid="ocr-file-input"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-text-muted"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-text font-medium">
|
||||
{isDragging ? 'Drop image here' : 'Drag and drop scan image here'}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">or click to browse</p>
|
||||
<p className="text-xs text-text-muted">PNG, JPEG, WebP (max 50 MB)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div data-testid="ocr-uploading" className="text-center text-text-muted">
|
||||
Uploading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div data-testid="ocr-upload-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div data-testid="ocr-upload-success" className="p-4 bg-green-50 text-green-700 rounded-lg">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { apiFetch, type PaginatedResponse } from './api';
|
||||
|
||||
export interface OCRResultResponse {
|
||||
id: string;
|
||||
vehicle_id?: string | null;
|
||||
file_path: string;
|
||||
file_name: string;
|
||||
mime_type: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed' | 'manual_review';
|
||||
raw_text?: string | null;
|
||||
structured_data?: {
|
||||
brand?: string | null;
|
||||
model?: string | null;
|
||||
vin?: string | null;
|
||||
first_registration?: string | null;
|
||||
mileage?: number | null;
|
||||
power_kw?: number | null;
|
||||
fuel_type?: string | null;
|
||||
} | null;
|
||||
confidence_score?: number | null;
|
||||
error_message?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface OCRUploadResponse {
|
||||
message: string;
|
||||
ocr_result_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface OCRApplyResponse {
|
||||
message: string;
|
||||
ocr_result_id: string;
|
||||
vehicle_id: string;
|
||||
updated_fields: string[];
|
||||
}
|
||||
|
||||
export async function uploadOCRScan(
|
||||
file: File,
|
||||
vehicleId?: string
|
||||
): Promise<OCRUploadResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (vehicleId) {
|
||||
formData.append('vehicle_id', vehicleId);
|
||||
}
|
||||
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/ocr/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ error: { code: 'UNKNOWN', message: 'Upload failed' } }));
|
||||
throw err;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getOCRResult(id: string): Promise<OCRResultResponse> {
|
||||
return apiFetch<OCRResultResponse>(`/ocr/results/${id}`);
|
||||
}
|
||||
|
||||
export async function listOCRResults(
|
||||
vehicleId?: string,
|
||||
page = 1,
|
||||
pageSize = 20
|
||||
): Promise<PaginatedResponse<OCRResultResponse>> {
|
||||
const params = new URLSearchParams();
|
||||
if (vehicleId) params.set('vehicle_id', vehicleId);
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', String(pageSize));
|
||||
return apiFetch<PaginatedResponse<OCRResultResponse>>(`/ocr/results?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function applyOCRToVehicle(resultId: string): Promise<OCRApplyResponse> {
|
||||
return apiFetch<OCRApplyResponse>(`/ocr/results/${resultId}/apply`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -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