refactor: Migrate to ESM and Vite for modern build

Updates package.json and index.html to use ES Modules and Vite for development and building. This includes migrating dependencies and removing old build scripts and testing configurations.

Also, simplifies the Gemini API key handling by directly using environment variables and refactors the Gemini response schema for clearer field definitions. Updates React component imports to use ESM paths for better maintainability.
This commit is contained in:
Kenearos 2026-02-06 14:03:50 +01:00
parent 2ed8e57267
commit 778caa8a45
35 changed files with 562 additions and 10837 deletions

97
.gitignore vendored
View file

@ -4,26 +4,85 @@ logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Python
__pycache__/
*.py[cod]
*.pytest_cache/
# Runtime data
pids
*.pid
*.seed
*.pid.lock
node_modules
dist
dist-ssr
*.local
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# OS metadata
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
Thumbs.db
# Dist folder
dist/
dist-ssr/

48
App.tsx
View file

@ -2,11 +2,9 @@ import React, { useState, useEffect } from 'react';
import { AppStatus, FileData, FormResponse } from './types';
import { FileUpload } from './components/FileUpload';
import { ReviewPanel } from './components/ReviewPanel';
import { ApiKeyModal } from './components/ApiKeyModal';
import { processDocuments } from './services/geminiService';
import { getPdfFields, PdfFieldInfo } from './services/pdfService';
import { getApiKey, setApiKey, hasApiKey } from './services/apiKeyService';
import { Bot, Sparkles, ArrowRight, FileCheck2, ScanText, Loader2, AlertTriangle, FileText, Check, Settings } from 'lucide-react';
import { Bot, Sparkles, ArrowRight, FileCheck2, ScanText, Loader2, AlertTriangle, FileText, Check } from 'lucide-react';
const App: React.FC = () => {
const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE);
@ -15,12 +13,6 @@ const App: React.FC = () => {
const [pdfFields, setPdfFields] = useState<PdfFieldInfo[]>([]);
const [responseData, setResponseData] = useState<FormResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [showApiKeyModal, setShowApiKeyModal] = useState(!hasApiKey());
const handleSaveApiKey = (key: string) => {
setApiKey(key);
setShowApiKeyModal(false);
};
useEffect(() => {
const analyzePdf = async () => {
@ -63,27 +55,14 @@ const App: React.FC = () => {
if (status === AppStatus.REVIEW && responseData && formFile && sourceFile) {
return (
<div className="min-h-screen bg-slate-50">
<ApiKeyModal
isOpen={showApiKeyModal}
onSave={handleSaveApiKey}
onClose={() => setShowApiKeyModal(false)}
currentKey={getApiKey() || ''}
/>
<header className="bg-white border-b border-slate-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center">
<div className="flex items-center space-x-2">
<div className="bg-indigo-600 p-1.5 rounded-lg">
<Bot className="w-5 h-5 text-white" />
</div>
<span className="font-bold text-lg text-slate-900">AutoForm AI</span>
</div>
<button
onClick={() => setShowApiKeyModal(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="API Key Einstellungen"
>
<Settings className="w-5 h-5" />
</button>
</div>
</header>
<ReviewPanel
@ -100,12 +79,6 @@ const App: React.FC = () => {
return (
<div className="min-h-screen bg-slate-50 flex flex-col">
<ApiKeyModal
isOpen={showApiKeyModal}
onSave={handleSaveApiKey}
onClose={hasApiKey() ? () => setShowApiKeyModal(false) : undefined}
currentKey={getApiKey() || ''}
/>
{/* Header */}
<header className="bg-white border-b border-slate-200">
<div className="max-w-7xl mx-auto px-4 h-20 flex items-center justify-between">
@ -118,19 +91,10 @@ const App: React.FC = () => {
<p className="text-xs text-slate-500 font-medium">Intelligent Document Processing</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="hidden md:flex items-center space-x-6 text-sm font-medium text-slate-600">
<span className="flex items-center"><ScanText className="w-4 h-4 mr-2" />1. Scan</span>
<span className="flex items-center"><Sparkles className="w-4 h-4 mr-2" />2. Extract</span>
<span className="flex items-center"><FileCheck2 className="w-4 h-4 mr-2" />3. Review</span>
</div>
<button
onClick={() => setShowApiKeyModal(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="API Key Einstellungen"
>
<Settings className="w-5 h-5" />
</button>
<div className="hidden md:flex items-center space-x-6 text-sm font-medium text-slate-600">
<span className="flex items-center"><ScanText className="w-4 h-4 mr-2" />1. Scan</span>
<span className="flex items-center"><Sparkles className="w-4 h-4 mr-2" />2. Extract</span>
<span className="flex items-center"><FileCheck2 className="w-4 h-4 mr-2" />3. Review</span>
</div>
</div>
</header>

View file

@ -1,70 +0,0 @@
# Multi-stage Dockerfile for AutoForm AI with LaTeX support
# Stage 1: Build Frontend
FROM node:20-alpine AS frontend-builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source files
COPY . .
# Build the frontend
RUN npm run build
# Stage 2: Production image with Python and LaTeX
FROM python:3.11-slim
# Install TeX Live (minimal installation for form generation)
RUN apt-get update && apt-get install -y --no-install-recommends \
texlive-latex-base \
texlive-latex-recommended \
texlive-latex-extra \
texlive-fonts-recommended \
texlive-lang-german \
texlive-plain-generic \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy Python requirements and install
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Install serve for static file serving
RUN npm install -g serve
# Copy the built frontend
COPY --from=frontend-builder /app/dist ./dist
# Copy Python backend and LaTeX templates
COPY latex_service.py ./
COPY server.py ./
COPY templates ./templates
# Create startup script
RUN echo '#!/bin/bash\n\
python server.py &\n\
serve dist -l ${PORT:-3000}\n\
' > /app/start.sh && chmod +x /app/start.sh
# Expose ports
EXPOSE 3000 5000
# Environment variables
# PORT is used by serve for the frontend (Railway will set this)
ENV PORT=3000
# FLASK_PORT is used by the Python API server (separate from Railway's PORT)
ENV FLASK_PORT=5000
ENV FLASK_DEBUG=false
ENV VITE_LATEX_API_URL=http://localhost:5000
# Start both services
CMD ["/app/start.sh"]

View file

@ -1,85 +0,0 @@
import React, { useState } from 'react';
import { Key, ExternalLink, X } from 'lucide-react';
interface ApiKeyModalProps {
isOpen: boolean;
onSave: (key: string) => void;
onClose?: () => void;
currentKey?: string;
}
export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({ isOpen, onSave, onClose, currentKey }) => {
const [apiKey, setApiKey] = useState(currentKey || '');
const [error, setError] = useState('');
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!apiKey.trim()) {
setError('Bitte gib einen API Key ein');
return;
}
if (!apiKey.startsWith('AI')) {
setError('Der Key sollte mit "AI" beginnen');
return;
}
onSave(apiKey.trim());
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden">
<div className="bg-indigo-600 px-6 py-4 flex items-center justify-between">
<div className="flex items-center space-x-3">
<Key className="w-5 h-5 text-white" />
<h2 className="text-lg font-bold text-white">Gemini API Key</h2>
</div>
{onClose && (
<button onClick={onClose} className="text-white/80 hover:text-white">
<X className="w-5 h-5" />
</button>
)}
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<p className="text-slate-600 text-sm">
Dein API Key wird nur lokal in deinem Browser gespeichert und nie an unsere Server gesendet.
</p>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
API Key
</label>
<input
type="password"
value={apiKey}
onChange={(e) => { setApiKey(e.target.value); setError(''); }}
placeholder="AIza..."
className="w-full px-4 py-3 border border-slate-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
autoFocus
/>
{error && <p className="text-red-500 text-sm mt-1">{error}</p>}
</div>
<a
href="https://aistudio.google.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-sm text-indigo-600 hover:text-indigo-700"
>
<ExternalLink className="w-4 h-4 mr-1" />
API Key bei Google AI Studio holen
</a>
<button
type="submit"
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 px-4 rounded-xl transition-colors"
>
Speichern
</button>
</form>
</div>
</div>
);
};

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { ExtractedField, FileData } from '../types';
import { Check, Edit2, Download, RefreshCw, FileText, AlertTriangle, XCircle, ArrowRight, PenTool, CheckCircle2, Circle, FileCheck } from 'lucide-react';
import { Check, Edit2, Download, RefreshCw, FileText, AlertTriangle, XCircle, ArrowRight, PenTool, CheckCircle2, Circle, LayoutTemplate, List, Move } from 'lucide-react';
import { createFilledPdf } from '../services/pdfService';
import { jsPDF } from "jspdf";
@ -25,6 +25,11 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
const [activeField, setActiveField] = useState<number | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [filterMode, setFilterMode] = useState<'ALL' | 'ATTENTION'>('ALL');
const [viewMode, setViewMode] = useState<'LIST' | 'FORM'>('LIST');
// Dragging state
const [draggingField, setDraggingField] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Derived state for progress
const verifiedCount = fields.filter(f => f.isVerified).length;
@ -35,16 +40,22 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
fields.filter(f => f.validation?.status !== 'VALID'),
[fields]);
// Generate preview - fills the original PDF directly
// Generate preview for PDF download
useEffect(() => {
const updatePreview = async () => {
let active = true;
let timeoutId: ReturnType<typeof setTimeout>;
const generatePreview = async () => {
if (formFile.type === 'application/pdf') {
try {
const filledPdfBytes = await createFilledPdf(formFile.base64, fields, isFillablePdf);
if (!active) return;
const blob = new Blob([filledPdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
setPreviewUrl(prev => {
if (prev) URL.revokeObjectURL(prev);
if (prev && prev.startsWith('blob:')) URL.revokeObjectURL(prev);
return url;
});
} catch (e) {
@ -55,21 +66,49 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
}
};
const timer = setTimeout(updatePreview, 500);
return () => clearTimeout(timer);
}, [fields, isFillablePdf, formFile.base64, formFile.type, formFile.previewUrl]);
// Debounce to avoid excessive PDF generation
timeoutId = setTimeout(generatePreview, 600);
return () => {
active = false;
clearTimeout(timeoutId);
};
}, [fields, isFillablePdf, formFile]);
// Cleanup blob URL on unmount
useEffect(() => {
return () => {
setPreviewUrl(prev => {
if (prev && prev.startsWith('blob:')) URL.revokeObjectURL(prev);
return null;
});
};
}, []);
const handleUpdate = (index: number, newValue: string) => {
const newFields = [...fields];
newFields[index] = {
...newFields[index],
value: newValue,
// Auto-verify when manually edited
isVerified: true,
validation: { ...newFields[index].validation!, status: 'VALID', message: 'Manually verified' }
};
setFields(newFields);
};
const handleCoordinateUpdate = (index: number, x: number, y: number) => {
const newFields = [...fields];
if (newFields[index].coordinates) {
newFields[index] = {
...newFields[index],
coordinates: { ...newFields[index].coordinates!, x, y },
isVerified: true // Moving it implies verification
};
setFields(newFields);
}
};
const toggleVerify = (index: number) => {
const newFields = [...fields];
newFields[index] = {
@ -106,15 +145,48 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
}
};
// Drag handlers
const handleDragStart = (e: React.MouseEvent, index: number) => {
e.stopPropagation();
setDraggingField(index);
setActiveField(index);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (draggingField !== null && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const relativeX = e.clientX - rect.left;
const relativeY = e.clientY - rect.top;
// Convert pixels to 0-1000 scale
const scaleX = (relativeX / rect.width) * 1000;
const scaleY = (relativeY / rect.height) * 1000;
// Clamp values
const clampedX = Math.max(0, Math.min(1000, scaleX));
const clampedY = Math.max(0, Math.min(1000, scaleY));
handleCoordinateUpdate(draggingField, clampedX, clampedY);
}
};
const handleMouseUp = () => {
setDraggingField(null);
};
// Sort: Unverified/Issues first, then verified
const displayedFields = fields.map((f, i) => ({ ...f, originalIndex: i }))
.sort((a, b) => {
// Priority 1: Attention needed
const aNeedsAttn = a.validation?.status !== 'VALID';
const bNeedsAttn = b.validation?.status !== 'VALID';
if (aNeedsAttn && !bNeedsAttn) return -1;
if (!aNeedsAttn && bNeedsAttn) return 1;
// Priority 2: Unverified
if (!a.isVerified && b.isVerified) return -1;
if (a.isVerified && !b.isVerified) return 1;
return a.originalIndex - b.originalIndex;
})
.filter(f => filterMode === 'ALL' || (f.validation?.status !== 'VALID' && !f.isVerified));
@ -129,6 +201,29 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
</div>
<div className="flex items-center gap-4 w-full md:w-auto">
{/* View Toggle */}
<div className="flex bg-slate-100 p-1 rounded-lg border border-slate-200">
<button
onClick={() => setViewMode('LIST')}
className={`p-1.5 rounded-md flex items-center space-x-2 text-sm font-medium transition-all ${viewMode === 'LIST' ? 'bg-white shadow text-slate-900' : 'text-slate-500 hover:text-slate-700'}`}
title="List View"
>
<List className="w-4 h-4" />
<span className="hidden sm:inline">List</span>
</button>
<button
onClick={() => setViewMode('FORM')}
className={`p-1.5 rounded-md flex items-center space-x-2 text-sm font-medium transition-all ${viewMode === 'FORM' ? 'bg-white shadow text-slate-900' : 'text-slate-500 hover:text-slate-700'}`}
title="Form Overlay View"
>
<LayoutTemplate className="w-4 h-4" />
<span className="hidden sm:inline">Form View</span>
</button>
</div>
<div className="h-6 w-px bg-slate-300 mx-2 hidden md:block"></div>
{/* Verification Progress */}
<div className="hidden md:flex flex-col items-end mr-2">
<span className="text-xs font-semibold text-slate-600 mb-1">
{verifiedCount} / {totalCount} Verified
@ -151,7 +246,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
<button
onClick={handleDownload}
className="flex items-center px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors shadow-sm bg-indigo-600 hover:bg-indigo-700"
className={`flex items-center px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors shadow-sm bg-indigo-600 hover:bg-indigo-700`}
>
<Download className="w-4 h-4 mr-2" />
Download PDF
@ -159,228 +254,271 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 flex-1 min-h-0">
{/* Left Column: PDF Preview */}
<div className="bg-slate-900 rounded-xl overflow-hidden shadow-lg flex flex-col">
<div className="p-3 bg-slate-800 border-b border-slate-700 flex justify-between items-center">
<div className="flex items-center space-x-2">
<span className="text-xs font-medium text-slate-300 uppercase tracking-wider">Preview</span>
{isFillablePdf ? (
<span className="flex items-center text-[10px] bg-emerald-500/20 text-emerald-300 px-1.5 py-0.5 rounded border border-emerald-500/30">
<FileCheck className="w-3 h-3 mr-1" />
Fillable PDF
</span>
{viewMode === 'LIST' ? (
/* ================= LIST VIEW ================= */
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 flex-1 min-h-0">
{/* Left Column: Preview */}
<div className="bg-slate-900 rounded-xl overflow-hidden shadow-lg flex flex-col">
<div className="p-3 bg-slate-800 border-b border-slate-700 flex justify-between items-center">
<div className="flex items-center space-x-2">
<span className="text-xs font-medium text-slate-300 uppercase tracking-wider">PDF Preview</span>
</div>
<span className="text-xs text-slate-400">{formFile.file.name}</span>
</div>
<div className="flex-1 bg-slate-900 relative">
{previewUrl ? (
formFile.type === 'application/pdf' ? (
<object
data={previewUrl}
type="application/pdf"
className="w-full h-full block"
aria-label="PDF Preview"
>
<div className="flex flex-col items-center justify-center h-full text-white/70">
<p>Unable to display PDF directly.</p>
<a href={previewUrl} download className="text-indigo-400 underline mt-2">Download to view</a>
</div>
</object>
) : (
<div className="w-full h-full overflow-auto flex items-center justify-center p-4">
<img
src={previewUrl}
alt="Form Document"
className="max-w-full shadow-lg border border-slate-700"
/>
</div>
)
) : (
<span className="flex items-center text-[10px] bg-amber-500/20 text-amber-300 px-1.5 py-0.5 rounded border border-amber-500/30">
<PenTool className="w-3 h-3 mr-1" />
Visual Overlay
</span>
)}
</div>
<span className="text-xs text-slate-400">{formFile.file.name}</span>
</div>
<div className="flex-1 bg-slate-900 relative">
{previewUrl ? (
formFile.type === 'application/pdf' ? (
<iframe
src={previewUrl}
title="Form PDF Preview"
className="w-full h-full border-none"
/>
) : (
<div className="w-full h-full overflow-auto flex items-center justify-center p-4">
<img
src={previewUrl}
alt="Form Document"
className="max-w-full shadow-lg border border-slate-700"
/>
<div className="flex flex-col items-center justify-center h-full text-slate-500">
<FileText className="w-16 h-16 mb-4 opacity-50" />
<p>Preview not available</p>
</div>
)
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-500">
<FileText className="w-16 h-16 mb-4 opacity-50" />
<p>Preview not available</p>
</div>
)}
)}
</div>
</div>
</div>
{/* Right Column: Field List */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col overflow-hidden">
<div className="p-4 border-b border-slate-100 bg-slate-50/50">
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-slate-800">Field Verification</h3>
<div className="flex space-x-2 text-xs">
<button
onClick={() => setFilterMode('ALL')}
className={`px-3 py-1 rounded-full border transition-colors ${filterMode === 'ALL' ? 'bg-slate-800 text-white border-slate-800' : 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'}`}
>
All Fields ({totalCount})
</button>
{fieldsRequiresAttention.length > 0 && (
<button
onClick={() => setFilterMode('ATTENTION')}
className={`px-3 py-1 rounded-full border transition-colors flex items-center ${filterMode === 'ATTENTION' ? 'bg-amber-100 text-amber-800 border-amber-200' : 'bg-white text-amber-600 border-slate-200 hover:bg-amber-50'}`}
{/* Right Column: Verification List */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col overflow-hidden">
<div className="p-4 border-b border-slate-100 bg-slate-50/50">
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-slate-800">Field Verification</h3>
<div className="flex space-x-2 text-xs">
<button
onClick={() => setFilterMode('ALL')}
className={`px-3 py-1 rounded-full border transition-colors ${filterMode === 'ALL' ? 'bg-slate-800 text-white border-slate-800' : 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'}`}
>
<AlertTriangle className="w-3 h-3 mr-1" />
Needs Review ({fieldsRequiresAttention.length})
All ({totalCount})
</button>
)}
{fieldsRequiresAttention.length > 0 && (
<button
onClick={() => setFilterMode('ATTENTION')}
className={`px-3 py-1 rounded-full border transition-colors flex items-center ${filterMode === 'ATTENTION' ? 'bg-amber-100 text-amber-800 border-amber-200' : 'bg-white text-amber-600 border-slate-200 hover:bg-amber-50'}`}
>
<AlertTriangle className="w-3 h-3 mr-1" />
Needs Review ({fieldsRequiresAttention.length})
</button>
)}
</div>
</div>
</div>
{isFillablePdf ? (
<div className="flex items-start space-x-2 bg-emerald-50 text-emerald-800 p-3 rounded-md border border-emerald-100 text-xs">
<FileCheck className="w-4 h-4 flex-shrink-0 text-emerald-600 mt-0.5" />
<div>
<p className="font-semibold">Fillable PDF Mode</p>
<p>Fields are filled directly into the original PDF form.</p>
</div>
</div>
) : (
<div className="flex items-start space-x-2 bg-amber-50 text-amber-800 p-3 rounded-md border border-amber-100 text-xs">
<PenTool className="w-4 h-4 flex-shrink-0 text-amber-600 mt-0.5" />
<div>
<p className="font-semibold">Visual Overlay Mode</p>
<p>This PDF has no fillable fields. Text is overlaid at estimated positions.</p>
</div>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50/30">
{displayedFields.map((field) => {
const idx = field.originalIndex;
const status = field.validation?.status || 'VALID';
const isVerified = field.isVerified;
{/* Fields List */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50/30">
{displayedFields.map((field) => {
const idx = field.originalIndex;
const status = field.validation?.status || 'VALID';
const isVerified = field.isVerified;
let statusBorder = isVerified ? "border-emerald-200" : "border-slate-200";
let statusBg = isVerified ? "bg-emerald-50/30" : "bg-white";
let statusBorder = isVerified ? "border-emerald-200" : "border-slate-200";
let statusBg = isVerified ? "bg-emerald-50/30" : "bg-white";
if (!isVerified) {
if (status === 'INVALID') {
statusBorder = "border-red-200";
statusBg = "bg-red-50/50";
} else if (status === 'WARNING') {
statusBorder = "border-amber-200";
statusBg = "bg-amber-50/50";
if (!isVerified) {
if (status === 'INVALID') {
statusBorder = "border-red-200";
statusBg = "bg-red-50/50";
} else if (status === 'WARNING') {
statusBorder = "border-amber-200";
statusBg = "bg-amber-50/50";
}
}
}
return (
<div
key={idx}
className={`relative group rounded-lg border transition-all duration-200 p-3 shadow-sm ${activeField === idx ? 'ring-1 ring-indigo-500 border-indigo-500 shadow-md z-10' : statusBorder} ${statusBg}`}
onFocus={() => setActiveField(idx)}
onBlur={() => setActiveField(null)}
>
<div className="flex items-start gap-3">
<button
onClick={() => toggleVerify(idx)}
className={`mt-1 flex-shrink-0 w-5 h-5 rounded border flex items-center justify-center transition-colors ${isVerified ? 'bg-emerald-500 border-emerald-500 text-white' : 'bg-white border-slate-300 text-transparent hover:border-emerald-400'}`}
title={isVerified ? "Mark as unverified" : "Mark as verified"}
>
<Check className="w-3.5 h-3.5" strokeWidth={3} />
</button>
return (
<div
key={idx}
className={`relative group rounded-lg border transition-all duration-200 p-3 shadow-sm ${activeField === idx ? 'ring-1 ring-indigo-500 border-indigo-500 shadow-md z-10' : statusBorder} ${statusBg}`}
onFocus={() => setActiveField(idx)}
onBlur={() => setActiveField(null)}
>
<div className="flex items-start gap-3">
<button
onClick={() => toggleVerify(idx)}
className={`mt-1 flex-shrink-0 w-5 h-5 rounded border flex items-center justify-center transition-colors ${isVerified ? 'bg-emerald-500 border-emerald-500 text-white' : 'bg-white border-slate-300 text-transparent hover:border-emerald-400'}`}
title={isVerified ? "Mark as unverified" : "Mark as verified"}
>
<Check className="w-3.5 h-3.5" strokeWidth={3} />
</button>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1">
<label className={`text-xs font-semibold uppercase tracking-wider truncate ${isVerified ? 'text-emerald-700' : 'text-slate-600'}`}>
{field.label || field.key || "Unknown Field"}
</label>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1">
<label className={`text-xs font-semibold uppercase tracking-wider truncate ${isVerified ? 'text-emerald-700' : 'text-slate-600'}`}>
{field.label || field.key || "Unknown Field"}
</label>
{!isVerified && status !== 'VALID' && (
<span className={`flex items-center text-[10px] font-bold px-1.5 py-0.5 rounded ${status === 'INVALID' ? 'bg-red-100 text-red-700' : 'bg-amber-100 text-amber-700'}`}>
{status === 'INVALID' ? <XCircle className="w-3 h-3 mr-1"/> : <AlertTriangle className="w-3 h-3 mr-1"/>}
{status}
</span>
)}
</div>
{!isVerified && status !== 'VALID' && (
<span className={`flex items-center text-[10px] font-bold px-1.5 py-0.5 rounded ${status === 'INVALID' ? 'bg-red-100 text-red-700' : 'bg-amber-100 text-amber-700'}`}>
{status === 'INVALID' ? <XCircle className="w-3 h-3 mr-1"/> : <AlertTriangle className="w-3 h-3 mr-1"/>}
{status}
</span>
)}
{isVerified && (
<span className="flex items-center text-[10px] font-bold px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700">
<CheckCircle2 className="w-3 h-3 mr-1"/>
VERIFIED
</span>
<div className="relative">
<input
type="text"
value={field.value}
onChange={(e) => handleUpdate(idx, e.target.value)}
className={`block w-full rounded-md px-2.5 py-1.5 text-sm font-medium transition-colors border ${isVerified ? 'border-emerald-200 text-emerald-900 bg-emerald-50/50' : 'border-slate-300 text-slate-900 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500'}`}
placeholder="Empty"
/>
</div>
{(!isVerified && field.validation?.suggestion && status !== 'VALID') && (
<button
onClick={() => applySuggestion(idx)}
className="mt-2 flex items-center text-xs font-bold text-indigo-600 hover:text-indigo-800 bg-indigo-50 hover:bg-indigo-100 px-2 py-1 rounded transition-colors"
>
<ArrowRight className="w-3 h-3 mr-1" />
Accept: "{field.validation.suggestion}"
</button>
)}
</div>
<div className="relative">
<input
type="text"
value={field.value}
onChange={(e) => handleUpdate(idx, e.target.value)}
className={`block w-full rounded-md px-2.5 py-1.5 text-sm font-medium transition-colors border ${isVerified ? 'border-emerald-200 text-emerald-900 bg-emerald-50/50' : 'border-slate-300 text-slate-900 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500'}`}
placeholder="Empty"
/>
{!isVerified && (
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none text-slate-400">
<Edit2 className="w-3.5 h-3.5 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
)}
</div>
{(!isVerified && status !== 'VALID' && field.validation?.message) && (
<p className={`mt-1.5 text-xs ${status === 'INVALID' ? 'text-red-600' : 'text-amber-600'}`}>
{field.validation.message}
</p>
)}
{(!isVerified && field.validation?.suggestion && status !== 'VALID') && (
<button
onClick={() => applySuggestion(idx)}
className="mt-2 flex items-center text-xs font-bold text-indigo-600 hover:text-indigo-800 bg-indigo-50 hover:bg-indigo-100 px-2 py-1 rounded transition-colors w-full sm:w-auto"
>
<ArrowRight className="w-3 h-3 mr-1" />
Accept Fix: "{field.validation.suggestion}"
</button>
)}
{!isVerified && field.sourceContext && (
<div className="mt-2 p-2 bg-slate-100 rounded text-[11px] text-slate-500 border border-slate-200">
<span className="font-semibold text-slate-700">Source:</span> "{field.sourceContext}"
</div>
)}
{/* Show PDF field key if available */}
{field.key && (
<div className="mt-1 text-[10px] text-slate-400">
PDF Field: {field.key}
</div>
)}
</div>
</div>
</div>
);
})}
{displayedFields.length === 0 && (
<div className="text-center py-10 text-slate-400">
<CheckCircle2 className="w-12 h-12 mx-auto mb-2 opacity-20" />
<p>All fields verified!</p>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-200 bg-white">
<div className="flex justify-between items-center text-xs font-medium text-slate-500">
<span>{verifiedCount} of {totalCount} fields verified</span>
{progressPercent === 100 ? (
<span className="text-emerald-600 font-bold flex items-center">
<CheckCircle2 className="w-4 h-4 mr-1"/> Ready to Download
</span>
) : (
<span className="text-amber-600 flex items-center">
<Circle className="w-4 h-4 mr-1 fill-amber-100"/> Review in progress
</span>
)}
</div>
<div className="md:hidden mt-2 w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-emerald-500 transition-all" style={{ width: `${progressPercent}%` }} />
</div>
);
})}
</div>
</div>
</div>
</div>
) : (
/* ================= FORM VIEW (Overlay with Drag & Drop) ================= */
<div className="bg-slate-200 rounded-xl overflow-auto shadow-inner flex-1 border border-slate-300 relative p-8 flex justify-center">
<div
ref={containerRef}
className="relative bg-white shadow-2xl transition-cursor"
style={{
width: '794px',
minHeight: '1123px',
cursor: draggingField !== null ? 'grabbing' : 'default'
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* Background Image/PDF */}
{formFile.previewUrl && (
<img
src={formFile.previewUrl}
className="absolute inset-0 w-full h-full object-contain pointer-events-none opacity-90 select-none"
alt="Form Background"
/>
)}
{/* Fallback info if not visual mode compatible */}
{!fields.some(f => f.coordinates) && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 z-20 backdrop-blur-sm">
<div className="text-center p-6 max-w-md">
<AlertTriangle className="w-12 h-12 text-amber-500 mx-auto mb-4" />
<h3 className="text-lg font-bold text-slate-900">Visual Mode Not Available</h3>
<p className="text-slate-600 mt-2">
Coordinates were not extracted for this document. Please use the List View to edit fields.
</p>
<button
onClick={() => setViewMode('LIST')}
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
Switch to List View
</button>
</div>
</div>
)}
{/* Overlay Inputs */}
{fields.map((field, idx) => {
if (!field.coordinates) return null;
// Coordinate conversion (0-1000 scale to percentage)
const left = (field.coordinates.x / 1000) * 100;
const top = (field.coordinates.y / 1000) * 100;
const status = field.validation?.status || 'VALID';
let borderColor = 'border-indigo-400 bg-white/60';
if (field.isVerified) borderColor = 'border-emerald-500 bg-emerald-50/70';
else if (status === 'INVALID') borderColor = 'border-red-500 bg-red-50/70';
else if (status === 'WARNING') borderColor = 'border-amber-500 bg-amber-50/70';
const isDragging = draggingField === idx;
const isCheckbox = field.value === 'X';
return (
<div
key={idx}
className={`absolute group hover:z-50 ${isDragging ? 'z-50 cursor-grabbing' : 'z-10'}`}
style={{
left: `${left}%`,
top: `${top}%`,
width: isCheckbox ? '20px' : '200px',
transform: 'translateY(-50%)', // Center vertically
}}
onMouseDown={(e) => handleDragStart(e, idx)}
>
<div className="relative">
{/* Drag Handle (Visible on Hover) */}
<div className={`absolute -top-3 -left-3 cursor-grab p-1 bg-slate-800 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity ${isDragging ? 'opacity-100' : ''}`}>
<Move className="w-3 h-3" />
</div>
{isCheckbox ? (
<div className={`w-6 h-6 border-2 flex items-center justify-center font-bold text-black ${borderColor}`}>
X
</div>
) : (
<input
type="text"
value={field.value}
onChange={(e) => handleUpdate(idx, e.target.value)}
onFocus={() => setActiveField(idx)}
className={`
w-full px-1 py-0.5 text-xs font-medium border-2 rounded transition-all shadow-sm
focus:ring-2 focus:ring-offset-1 focus:z-10 focus:bg-white
${borderColor}
`}
style={{
fontFamily: 'Courier, monospace',
color: 'black',
background: 'rgba(255, 255, 255, 0.7)'
}}
/>
)}
{/* Validation Icon Overlay */}
{!field.isVerified && status !== 'VALID' && (
<div className="absolute -top-2 -right-2 bg-white rounded-full shadow-md z-20 pointer-events-none">
{status === 'INVALID'
? <XCircle className="w-4 h-4 text-red-500" />
: <AlertTriangle className="w-4 h-4 text-amber-500" />
}
</div>
)}
{/* Tooltip on Hover */}
<div className="absolute opacity-0 group-hover:opacity-100 bottom-full left-0 mb-1 bg-slate-800 text-white text-[10px] px-2 py-1 rounded whitespace-nowrap pointer-events-none z-30 transition-opacity">
{field.label}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
};

View file

@ -1,95 +0,0 @@
#!/usr/bin/env python3
"""
PDF Form Filler - Automatisches Ausfüllen von PDF-Formularen mit AcroForm-Feldern.
Usage:
python fill_pdf.py <input.pdf> <values.json> <output.pdf>
python fill_pdf.py --extract <input.pdf> # Extrahiert Feldnamen
"""
from pypdf import PdfReader, PdfWriter
import json
import sys
def extract_fields(pdf_path: str) -> list[dict]:
"""Extrahiert alle Formularfelder aus einer PDF."""
reader = PdfReader(pdf_path)
fields = reader.get_fields()
if not fields:
return []
result = []
for field_name, field_data in fields.items():
field_type = field_data.get('/FT', '')
field_info = {
"field_id": field_name,
"type": str(field_type),
"value": field_data.get('/V', '')
}
result.append(field_info)
return result
def fill_pdf(input_pdf: str, field_values: dict, output_pdf: str):
"""
Befüllt eine PDF mit den angegebenen Feldwerten.
field_values Format:
{
"txtName": "Max Mustermann",
"txtDatum": "28.01.2025",
"chkOption": "/Ja" # Checkboxen: /On, /Off, /Ja, /Nein
}
"""
reader = PdfReader(input_pdf)
writer = PdfWriter()
writer.append(reader)
# Felder auf allen Seiten befüllen
for page_num in range(len(writer.pages)):
writer.update_page_form_field_values(
writer.pages[page_num],
field_values
)
with open(output_pdf, "wb") as output:
writer.write(output)
def main():
# Extraktionsmodus
if len(sys.argv) == 3 and sys.argv[1] == "--extract":
pdf_path = sys.argv[2]
fields = extract_fields(pdf_path)
print(json.dumps(fields, indent=2, ensure_ascii=False))
return
# Normaler Füllmodus
if len(sys.argv) != 4:
print("Usage: python fill_pdf.py <input.pdf> <values.json> <output.pdf>")
print(" python fill_pdf.py --extract <input.pdf>")
sys.exit(1)
input_pdf = sys.argv[1]
values_json = sys.argv[2]
output_pdf = sys.argv[3]
# JSON laden
with open(values_json, 'r', encoding='utf-8') as f:
data = json.load(f)
# Wenn Liste: in Dict umwandeln
if isinstance(data, list):
field_values = {item['field_id']: item['value'] for item in data}
else:
field_values = data
fill_pdf(input_pdf, field_values, output_pdf)
print(f"PDF erfolgreich erstellt: {output_pdf}")
if __name__ == "__main__":
main()

View file

@ -12,9 +12,23 @@
background-color: #f8fafc;
}
</style>
</head>
<script type="importmap">
{
"imports": {
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
"react/": "https://esm.sh/react@^19.2.4/",
"react": "https://esm.sh/react@^19.2.4",
"lucide-react": "https://esm.sh/lucide-react@^0.563.0",
"@google/genai": "https://esm.sh/@google/genai@^1.38.0",
"jspdf": "https://esm.sh/jspdf@2.5.1",
"pdf-lib": "https://esm.sh/pdf-lib@^1.17.1",
"vite": "https://esm.sh/vite@^7.3.1",
"@vitejs/plugin-react": "https://esm.sh/@vitejs/plugin-react@^5.1.2"
}
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

View file

@ -1,249 +0,0 @@
#!/usr/bin/env python3
"""
LaTeX Form Generation Service
This service generates filled PDF forms using LaTeX templates.
It takes extracted field data and compiles a LaTeX template into a PDF.
"""
import json
import os
import subprocess
import tempfile
import base64
import sys
import shutil
from pathlib import Path
from typing import Dict, Any, Optional
# Template directory
TEMPLATE_DIR = Path(__file__).parent / "templates"
def escape_latex(text: str) -> str:
"""Escape special LaTeX characters in text."""
if not text:
return ""
# LaTeX special characters that need escaping
replacements = [
('\\', r'\textbackslash{}'),
('&', r'\&'),
('%', r'\%'),
('$', r'\$'),
('#', r'\#'),
('_', r'\_'),
('{', r'\{'),
('}', r'\}'),
('~', r'\textasciitilde{}'),
('^', r'\textasciicircum{}'),
]
result = text
for old, new in replacements:
result = result.replace(old, new)
return result
def checkbox(value: str) -> str:
"""Return LaTeX checkbox symbol based on value."""
if not value:
return r'$\square$'
val_lower = value.lower().strip()
if val_lower in ('true', 'yes', 'ja', 'x', '1', 'checked'):
return r'$\boxtimes$'
return r'$\square$'
def format_date(date_str: str) -> str:
"""Ensure date is in DD.MM.YYYY format."""
if not date_str:
return ""
# Already in correct format
if len(date_str) == 10 and date_str[2] == '.' and date_str[5] == '.':
return escape_latex(date_str)
# Try to parse ISO format
try:
from datetime import datetime
for fmt in ['%Y-%m-%d', '%d/%m/%Y', '%m/%d/%Y']:
try:
dt = datetime.strptime(date_str, fmt)
return dt.strftime('%d.%m.%Y')
except ValueError:
continue
except:
pass
return escape_latex(date_str)
def load_template(template_name: str) -> str:
"""Load a LaTeX template file."""
template_path = TEMPLATE_DIR / f"{template_name}.tex"
if not template_path.exists():
raise FileNotFoundError(f"Template not found: {template_path}")
return template_path.read_text(encoding='utf-8')
def fill_template(template: str, fields: Dict[str, Any]) -> str:
"""
Fill a LaTeX template with field values.
Fields can be accessed in template as:
- {{field_name}} for escaped text values
- {{field_name|raw}} for raw values (no escaping)
- {{field_name|checkbox}} for checkbox symbols
- {{field_name|date}} for date formatting
"""
result = template
# Process each field
for key, value in fields.items():
value_str = str(value) if value is not None else ""
# Replace with different formatters
# Raw (no escaping)
result = result.replace(f'{{{{{key}|raw}}}}', value_str)
# Checkbox
result = result.replace(f'{{{{{key}|checkbox}}}}', checkbox(value_str))
# Date
result = result.replace(f'{{{{{key}|date}}}}', format_date(value_str))
# Default (escaped)
result = result.replace(f'{{{{{key}}}}}', escape_latex(value_str))
# Clean up any remaining placeholders (unfilled fields)
import re
result = re.sub(r'\{\{[^}]+\}\}', '', result)
return result
def compile_latex(latex_content: str, output_format: str = 'pdf') -> bytes:
"""
Compile LaTeX content to PDF.
Returns the PDF as bytes.
"""
with tempfile.TemporaryDirectory() as tmpdir:
tex_file = Path(tmpdir) / "document.tex"
tex_file.write_text(latex_content, encoding='utf-8')
# Copy any additional files (images, etc.) if needed
# For now, we just compile the main document
# Run pdflatex twice (for references)
for _ in range(2):
result = subprocess.run(
['pdflatex', '-interaction=nonstopmode', '-output-directory', tmpdir, str(tex_file)],
capture_output=True,
text=True,
timeout=60
)
if result.returncode != 0:
# Check for common errors
error_log = Path(tmpdir) / "document.log"
if error_log.exists():
log_content = error_log.read_text(encoding='utf-8', errors='ignore')
# Extract error lines
errors = [line for line in log_content.split('\n') if line.startswith('!')]
if errors:
raise RuntimeError(f"LaTeX compilation failed: {'; '.join(errors[:3])}")
raise RuntimeError(f"LaTeX compilation failed: {result.stderr[:500]}")
pdf_file = Path(tmpdir) / "document.pdf"
if not pdf_file.exists():
raise RuntimeError("PDF file was not created")
return pdf_file.read_bytes()
def generate_form(template_name: str, fields: Dict[str, Any]) -> bytes:
"""
Generate a filled PDF form from a template and field data.
Args:
template_name: Name of the template (without .tex extension)
fields: Dictionary of field names to values
Returns:
PDF content as bytes
"""
template = load_template(template_name)
filled = fill_template(template, fields)
return compile_latex(filled)
def list_templates() -> list:
"""List available templates."""
if not TEMPLATE_DIR.exists():
return []
return [f.stem for f in TEMPLATE_DIR.glob("*.tex")]
# CLI Interface
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='LaTeX Form Generation Service')
parser.add_argument('command', choices=['generate', 'list', 'preview'],
help='Command to execute')
parser.add_argument('--template', '-t', help='Template name')
parser.add_argument('--fields', '-f', help='JSON string or file path with field data')
parser.add_argument('--output', '-o', help='Output file path')
args = parser.parse_args()
if args.command == 'list':
templates = list_templates()
print(json.dumps(templates))
elif args.command == 'preview':
# Output the filled LaTeX source (for debugging)
if not args.template or not args.fields:
print("Error: --template and --fields required", file=sys.stderr)
sys.exit(1)
if args.fields.startswith('{'):
fields = json.loads(args.fields)
else:
with open(args.fields, 'r') as f:
fields = json.load(f)
template = load_template(args.template)
filled = fill_template(template, fields)
print(filled)
elif args.command == 'generate':
if not args.template or not args.fields:
print("Error: --template and --fields required", file=sys.stderr)
sys.exit(1)
# Parse fields
if args.fields.startswith('{'):
fields = json.loads(args.fields)
else:
with open(args.fields, 'r') as f:
fields = json.load(f)
try:
pdf_bytes = generate_form(args.template, fields)
if args.output:
with open(args.output, 'wb') as f:
f.write(pdf_bytes)
print(f"PDF written to {args.output}", file=sys.stderr)
else:
# Output base64 encoded PDF to stdout
print(base64.b64encode(pdf_bytes).decode('ascii'))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)

5048
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,39 +1,29 @@
{
"name": "autoform-ai",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"start": "serve dist -l ${PORT:-3000}",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:run": "vitest run",
"latex:server": "python3 server.py",
"dev:all": "concurrently \"npm run dev\" \"npm run latex:server\""
"preview": "vite preview"
},
"dependencies": {
"react-dom": "^19.2.4",
"react": "^19.2.4",
"lucide-react": "^0.563.0",
"@google/genai": "^1.38.0",
"jspdf": "2.5.1",
"jspdf": "^2.5.1",
"lucide-react": "^0.563.0",
"pdf-lib": "^1.17.1",
"serve": "^14.2.4"
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0",
"vitest": "^3.0.0",
"@vitest/coverage-v8": "^3.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/user-event": "^14.5.0",
"jsdom": "^26.0.0"
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^5.1.4"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -1,9 +0,0 @@
[build]
builder = "dockerfile"
dockerfilePath = "Dockerfile"
[deploy]
healthcheckPath = "/"
healthcheckTimeout = 300
restartPolicyType = "on_failure"
restartPolicyMaxRetries = 3

View file

@ -1,3 +0,0 @@
pypdf>=4.0.0
flask>=3.0.0
flask-cors>=4.0.0

339
server.py
View file

@ -1,339 +0,0 @@
#!/usr/bin/env python3
"""
Flask server for LaTeX form generation.
Provides API endpoints for compiling LaTeX templates with field data.
"""
from flask import Flask, request, jsonify, send_file
from flask_cors import CORS
import base64
import io
import json
import os
import sys
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent))
from latex_service import generate_form, list_templates, load_template, fill_template, escape_latex
app = Flask(__name__)
CORS(app) # Enable CORS for frontend access
# Field mapping for G2210-11 template
# Maps extracted field labels to LaTeX template variables
G2210_FIELD_MAPPING = {
# Patient data
"versicherungsnummer": ["versicherungsnummer", "vers.nr.", "vers-nr", "rentenversicherungsnummer", "rvnr"],
"abt_nr": ["abt.-nr.", "abt-nr", "abteilungsnummer", "aktenzeichen"],
"name_vorname": ["name, vorname", "name vorname", "patient", "patientenname", "name des versicherten"],
"geburtsdatum": ["geburtsdatum", "geb.", "geb.datum", "geboren am", "geburtstag"],
"geschlecht": ["geschlecht", "sex", "m/w/d"],
"strasse": ["straße", "strasse", "anschrift", "adresse"],
"plz": ["plz", "postleitzahl"],
"ort": ["ort", "wohnort", "stadt"],
"telefon": ["telefon", "tel.", "tel", "telefonnummer", "rufnummer"],
"krankenkasse": ["krankenkasse", "krankenversicherung", "kk", "versicherung"],
# Employment
"beruf_taetigkeit": ["beruf", "tätigkeit", "derzeitige tätigkeit", "beschäftigung", "arbeit"],
"arbeitgeber": ["arbeitgeber", "firma", "unternehmen"],
"au_seit": ["arbeitsunfähig seit", "au seit", "arbeitsunfähigkeit seit", "krankgeschrieben seit"],
"letzte_arbeit": ["letzte arbeitsaufnahme", "letzter arbeitstag", "zuletzt gearbeitet"],
# Diagnoses
"diagnose_1": ["diagnose 1", "hauptdiagnose", "1. diagnose"],
"diagnose_1_icd": ["icd 1", "icd-10 1", "diagnose 1 icd"],
"diagnose_2": ["diagnose 2", "nebendiagnose 1", "2. diagnose"],
"diagnose_2_icd": ["icd 2", "icd-10 2", "diagnose 2 icd"],
"diagnose_3": ["diagnose 3", "nebendiagnose 2", "3. diagnose"],
"diagnose_3_icd": ["icd 3", "icd-10 3", "diagnose 3 icd"],
"diagnose_4": ["diagnose 4", "nebendiagnose 3", "4. diagnose"],
"diagnose_4_icd": ["icd 4", "icd-10 4", "diagnose 4 icd"],
"diagnose_5": ["diagnose 5", "nebendiagnose 4", "5. diagnose"],
"diagnose_5_icd": ["icd 5", "icd-10 5", "diagnose 5 icd"],
"diagnose_6": ["diagnose 6", "nebendiagnose 5", "6. diagnose"],
"diagnose_6_icd": ["icd 6", "icd-10 6", "diagnose 6 icd"],
# Anamnesis
"anamnese_beschwerden": ["anamnese", "beschwerden", "eigenanamnese", "aktuelle beschwerden", "symptome"],
"krankheitsverlauf": ["krankheitsverlauf", "verlauf", "bisherige behandlung", "behandlungsverlauf"],
"koerperlicher_befund": ["befund", "körperlicher befund", "aktueller befund", "untersuchungsbefund"],
# Functional limitations (checkboxes)
"mobilitaet_keine": ["mobilität keine", "mobilität: keine"],
"mobilitaet_gering": ["mobilität gering", "mobilität: gering"],
"mobilitaet_erheblich": ["mobilität erheblich", "mobilität: erheblich"],
"selbstversorgung_keine": ["selbstversorgung keine"],
"selbstversorgung_gering": ["selbstversorgung gering"],
"selbstversorgung_erheblich": ["selbstversorgung erheblich"],
"haushalt_keine": ["haushaltsführung keine", "haushalt keine"],
"haushalt_gering": ["haushaltsführung gering", "haushalt gering"],
"haushalt_erheblich": ["haushaltsführung erheblich", "haushalt erheblich"],
"erwerb_keine": ["erwerbstätigkeit keine", "erwerb keine"],
"erwerb_gering": ["erwerbstätigkeit gering", "erwerb gering"],
"erwerb_erheblich": ["erwerbstätigkeit erheblich", "erwerb erheblich"],
"kommunikation_keine": ["kommunikation keine"],
"kommunikation_gering": ["kommunikation gering"],
"kommunikation_erheblich": ["kommunikation erheblich"],
"psyche_keine": ["psychische belastbarkeit keine", "psyche keine"],
"psyche_gering": ["psychische belastbarkeit gering", "psyche gering"],
"psyche_erheblich": ["psychische belastbarkeit erheblich", "psyche erheblich"],
"beeintraechtigungen_erlaeuterung": ["beeinträchtigungen erläuterung", "erläuterungen beeinträchtigungen"],
# Medication
"medikament_1": ["medikament 1", "medikation 1"],
"medikament_1_dosis": ["dosis 1", "medikament 1 dosis"],
"medikament_1_seit": ["seit 1", "medikament 1 seit"],
"medikament_2": ["medikament 2", "medikation 2"],
"medikament_2_dosis": ["dosis 2", "medikament 2 dosis"],
"medikament_2_seit": ["seit 2", "medikament 2 seit"],
"medikament_3": ["medikament 3", "medikation 3"],
"medikament_3_dosis": ["dosis 3", "medikament 3 dosis"],
"medikament_3_seit": ["seit 3", "medikament 3 seit"],
"medikament_4": ["medikament 4", "medikation 4"],
"medikament_4_dosis": ["dosis 4", "medikament 4 dosis"],
"medikament_4_seit": ["seit 4", "medikament 4 seit"],
"medikament_5": ["medikament 5", "medikation 5"],
"medikament_5_dosis": ["dosis 5", "medikament 5 dosis"],
"medikament_5_seit": ["seit 5", "medikament 5 seit"],
"physikalische_therapie": ["physikalische therapie", "heilmittel", "physiotherapie", "krankengymnastik"],
# Previous rehab
"reha_1_zeitraum": ["reha 1 zeitraum", "frühere reha 1 zeitraum"],
"reha_1_einrichtung": ["reha 1 einrichtung", "frühere reha 1 einrichtung"],
"reha_1_erfolg": ["reha 1 erfolg", "frühere reha 1 erfolg"],
"reha_2_zeitraum": ["reha 2 zeitraum", "frühere reha 2 zeitraum"],
"reha_2_einrichtung": ["reha 2 einrichtung", "frühere reha 2 einrichtung"],
"reha_2_erfolg": ["reha 2 erfolg", "frühere reha 2 erfolg"],
# Assessment
"leistungsvermoegen_checkbox_vollschichtig": ["vollschichtig", "leistungsvermögen vollschichtig", "6 stunden und mehr"],
"leistungsvermoegen_checkbox_teilschichtig": ["teilschichtig", "leistungsvermögen 3-6", "3-6 stunden"],
"leistungsvermoegen_checkbox_unter3": ["unter 3 stunden", "leistungsvermögen unter 3"],
"reha_beduerftig_begruendung": ["rehabilitationsbedürftigkeit", "reha begründung", "reha bedürftigkeit"],
"reha_ziel": ["rehabilitationsziel", "reha ziel", "therapieziel"],
"reha_stationaer": ["stationär", "stationäre reha"],
"reha_ambulant": ["ambulant", "ambulante reha"],
"reha_ganztaegig": ["ganztägig ambulant", "teilstationär"],
"reha_einrichtung_empfehlung": ["empfohlene einrichtung", "reha einrichtung", "klinikempfehlung"],
# Travel capability
"reisefaehig_ja": ["reisefähig ja", "öffentliche verkehrsmittel ja"],
"reisefaehig_nein": ["reisefähig nein", "öffentliche verkehrsmittel nein"],
"reisefaehig_begruendung": ["reisefähigkeit begründung", "nicht reisefähig weil"],
"begleitperson_ja": ["begleitperson ja", "begleitperson erforderlich"],
"begleitperson_nein": ["begleitperson nein", "keine begleitperson"],
# Additional
"ergaenzende_angaben": ["ergänzende angaben", "zusätzliche informationen", "bemerkungen", "sonstiges"],
# Attachments
"anlage_laborbefunde": ["anlage laborbefunde", "laborbefunde"],
"anlage_roentgen": ["anlage röntgen", "bildgebende befunde", "röntgenbefunde"],
"anlage_arztbriefe": ["anlage arztbriefe", "arztbriefe"],
"anlage_krankenhausberichte": ["anlage krankenhausberichte", "krankenhausberichte", "entlassungsberichte"],
"anlage_sonstige": ["anlage sonstige", "sonstige anlagen"],
"anlage_sonstige_text": ["sonstige anlagen text", "anlage sonstige bezeichnung"],
# Signature
"unterschrift_datum": ["unterschrift datum", "datum unterschrift", "ausstellungsdatum"],
"arzt_name": ["arzt name", "name des arztes", "behandelnder arzt"],
"arzt_fachrichtung": ["facharztbezeichnung", "fachrichtung", "facharzt"],
"praxis_anschrift": ["praxis anschrift", "praxisadresse", "arztpraxis"],
"praxis_telefon": ["praxis telefon", "praxis tel"],
"bsnr": ["bsnr", "betriebsstättennummer"],
"lanr": ["lanr", "lebenslange arztnummer"],
}
def normalize_label(label: str) -> str:
"""Normalize a label for matching."""
return label.lower().strip().replace(':', '').replace('_', ' ')
def map_fields_to_template(extracted_fields: list, template_mapping: dict) -> dict:
"""
Map extracted fields to template variables using fuzzy matching.
Args:
extracted_fields: List of {label, value, ...} dicts from AI extraction
template_mapping: Dict mapping template vars to possible label variations
Returns:
Dict of template variables to values
"""
result = {}
# Build reverse mapping: normalized label -> template var
reverse_map = {}
for template_var, possible_labels in template_mapping.items():
for label in possible_labels:
reverse_map[normalize_label(label)] = template_var
# Map each extracted field
for field in extracted_fields:
label = normalize_label(field.get('label', ''))
value = field.get('value', '')
if not label or not value:
continue
# Direct match
if label in reverse_map:
result[reverse_map[label]] = value
continue
# Fuzzy match: check if any mapping label is contained in the extracted label
for possible_label, template_var in reverse_map.items():
if possible_label in label or label in possible_label:
result[template_var] = value
break
return result
@app.route('/api/health', methods=['GET'])
def health_check():
"""Health check endpoint."""
return jsonify({'status': 'ok', 'service': 'latex-form-generator'})
@app.route('/api/templates', methods=['GET'])
def get_templates():
"""List available LaTeX templates."""
templates = list_templates()
return jsonify({'templates': templates})
@app.route('/api/generate', methods=['POST'])
def generate_pdf():
"""
Generate a filled PDF from a template and field data.
Request body:
{
"template": "G2210-11",
"fields": [
{"label": "Name, Vorname", "value": "Müller, Hans"},
{"label": "Geburtsdatum", "value": "01.01.1970"},
...
]
}
Returns: PDF file or base64-encoded PDF
"""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No JSON data provided'}), 400
template_name = data.get('template', 'G2210-11')
extracted_fields = data.get('fields', [])
return_format = data.get('format', 'base64') # 'base64' or 'file'
# Get the appropriate field mapping
if template_name == 'G2210-11':
field_mapping = G2210_FIELD_MAPPING
else:
# For other templates, try direct field names
field_mapping = {}
# Map extracted fields to template variables
if field_mapping:
template_fields = map_fields_to_template(extracted_fields, field_mapping)
else:
# Direct mapping: use label as key
template_fields = {normalize_label(f['label']).replace(' ', '_'): f['value']
for f in extracted_fields if f.get('value')}
# Generate PDF
pdf_bytes = generate_form(template_name, template_fields)
if return_format == 'file':
return send_file(
io.BytesIO(pdf_bytes),
mimetype='application/pdf',
as_attachment=True,
download_name=f'{template_name}_filled.pdf'
)
else:
# Return base64
pdf_base64 = base64.b64encode(pdf_bytes).decode('ascii')
return jsonify({
'success': True,
'pdf': pdf_base64,
'mapped_fields': template_fields
})
except FileNotFoundError as e:
return jsonify({'error': str(e)}), 404
except Exception as e:
return jsonify({'error': f'Generation failed: {str(e)}'}), 500
@app.route('/api/preview', methods=['POST'])
def preview_latex():
"""
Preview the filled LaTeX source (for debugging).
Same request format as /api/generate.
Returns the LaTeX source instead of compiled PDF.
"""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No JSON data provided'}), 400
template_name = data.get('template', 'G2210-11')
extracted_fields = data.get('fields', [])
# Get the appropriate field mapping
if template_name == 'G2210-11':
field_mapping = G2210_FIELD_MAPPING
else:
field_mapping = {}
# Map extracted fields to template variables
if field_mapping:
template_fields = map_fields_to_template(extracted_fields, field_mapping)
else:
template_fields = {normalize_label(f['label']).replace(' ', '_'): f['value']
for f in extracted_fields if f.get('value')}
# Load and fill template
template = load_template(template_name)
filled = fill_template(template, template_fields)
return jsonify({
'success': True,
'latex': filled,
'mapped_fields': template_fields
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/field-mapping/<template_name>', methods=['GET'])
def get_field_mapping(template_name):
"""Get the field mapping for a specific template."""
if template_name == 'G2210-11':
return jsonify({
'template': template_name,
'fields': list(G2210_FIELD_MAPPING.keys()),
'mapping': G2210_FIELD_MAPPING
})
else:
return jsonify({'error': 'Unknown template'}), 404
if __name__ == '__main__':
# Use FLASK_PORT to avoid conflict with Railway's PORT variable
# which is used by the frontend static file server
port = int(os.environ.get('FLASK_PORT', 5000))
debug = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
app.run(host='0.0.0.0', port=port, debug=debug)

View file

@ -1,18 +0,0 @@
const STORAGE_KEY = 'gemini_api_key';
export const getApiKey = (): string | null => {
return localStorage.getItem(STORAGE_KEY);
};
export const setApiKey = (key: string): void => {
localStorage.setItem(STORAGE_KEY, key);
};
export const clearApiKey = (): void => {
localStorage.removeItem(STORAGE_KEY);
};
export const hasApiKey = (): boolean => {
const key = getApiKey();
return key !== null && key.length > 0;
};

View file

@ -1,15 +1,8 @@
import { GoogleGenAI, Type, Schema } from "@google/genai";
import { FileData, FormResponse } from "../types";
import { PdfFieldInfo } from "./pdfService";
import { getApiKey } from "./apiKeyService";
const getAI = () => {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error("Kein API Key gesetzt. Bitte gib deinen Gemini API Key ein.");
}
return new GoogleGenAI({ apiKey });
};
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const responseSchema: Schema = {
type: Type.OBJECT,
@ -33,11 +26,11 @@ const responseSchema: Schema = {
},
value: {
type: Type.STRING,
description: "The value to fill. For checkboxes, use 'true'/'false' or 'X'."
description: "The value to fill. For checkboxes, use 'X' if true, otherwise leave empty."
},
sourceContext: {
type: Type.STRING,
description: "The exact snippet of text from the source document used to derive this value. Used for user verification."
description: "The exact snippet of text from the source document used to derive this value."
},
coordinates: {
type: Type.OBJECT,
@ -58,11 +51,11 @@ const responseSchema: Schema = {
},
message: {
type: Type.STRING,
description: "Validation message explaining any issues or uncertainty."
description: "Validation message explaining any issues."
},
suggestion: {
type: Type.STRING,
description: "Alternative value suggestion if the extracted value is uncertain."
description: "Alternative value suggestion."
}
},
required: ["status"]
@ -96,67 +89,51 @@ export const processDocuments = async (
};
let systemPrompt = `
ROLE: Intelligent Document Processing AI.
TASK: Extract data from the SOURCE DOCUMENT and fill the BLANK TARGET FORM.
ROLE: Intelligent Document Processing AI (German Bureaucracy Expert).
TASK: Extract data from the SOURCE DOCUMENT and map it to the TARGET FORM visually or logically.
CRITICAL: You must verify every extraction. If uncertain, set validation.status to 'WARNING'.
STRICT FORMATTING RULES (German Context):
1. DATES: Must be formatted as 'DD.MM.YYYY' (e.g., 24.01.1982). Do not use ISO or US formats.
2. NUMBERS/CURRENCY: Use comma as decimal separator (e.g., 1.425,00). Do NOT write the currency symbol () if the form already has it printed.
3. CHECKBOXES: If a condition is met (e.g., "Männlich", "Ja"), the 'value' must be "X". If not met, leave empty.
CRITICAL: Verify every extraction. If ambiguous, set validation.status to 'WARNING'.
`;
// PRIORITY 1: If PDF has fillable fields, USE THEM - this is the simplest and best approach
if (pdfFields.length > 0) {
const fieldList = pdfFields.map(f => `"${f.name}" (${f.type})`).join("\n- ");
const fieldList = pdfFields.map(f => `"${f.name}" (${f.type})`).join(", ");
systemPrompt += `
MODE: FILLABLE PDF (AcroForm).
The target PDF has these EXACT fillable fields:
- ${fieldList}
CRITICAL INSTRUCTIONS:
1. For EACH field listed above, extract the corresponding value from the SOURCE DOCUMENT.
2. Return the 'key' property with the EXACT field name from the list above.
3. The 'label' should be a human-readable description.
4. For checkboxes: use value "true" to check, "false" to uncheck.
5. For text fields: use the extracted text value.
You MUST return a field entry for each PDF field listed above.
The 'key' MUST match exactly one of the field names I provided.
Map extracted data to these exact field IDs: [${fieldList}].
`;
} else {
// FALLBACK: Visual overlay mode for non-fillable PDFs
systemPrompt += `
MODE: VISUAL FILLING (Flat PDF/Scan).
The target form does NOT have digital form fields.
MODE: VISUAL FILLING (Flat Scan/Image).
The target form has NO digital fields. You must estimate COORDINATES.
For every field you identify on the TARGET FORM:
1. Extract the corresponding value from the SOURCE DOCUMENT.
2. Estimate VISUAL COORDINATES [pageIndex, x, y] where the text should be written.
- x and y are on a scale of 0 to 1000.
- (0,0) is the top-left corner.
- (1000,1000) is the bottom-right corner.
COORDINATE SYSTEM (0-1000):
- x=0, y=0 is Top-Left.
- x=1000, y=1000 is Bottom-Right.
For checkboxes: value should be "X" if checked.
STRATEGY:
1. Analyze the blank form image. Identify where user input belongs (lines, boxes).
2. For "Reisekosten" (Travel Expenses): Look for columns like "Fahrtkosten", "Übernachtung". accurately place the amounts in the "Betrag" column.
3. Place text slightly ABOVE the underline so it looks natural.
4. For Checkboxes: Estimate the center of the square box.
`;
}
systemPrompt += `
VALIDATION RULES:
1. Dates: German format DD.MM.YYYY
2. Missing Data: Leave 'value' empty, don't hallucinate.
3. Source Context: Include the exact text snippet from source that justifies the extraction.
`;
try {
const ai = getAI();
const modelId = "gemini-2.0-flash";
const modelId = "gemini-3-flash-preview";
const response = await ai.models.generateContent({
model: modelId,
contents: {
parts: [
formPart,
{ text: "This is the BLANK TARGET FORM." },
{ text: "TARGET FORM (Blank)" },
sourcePart,
{ text: "This is the SOURCE DOCUMENT." },
{ text: "SOURCE DATA (Email/Receipts)" },
]
},
config: {

View file

@ -1,251 +0,0 @@
/**
* LaTeX Form Generation Service
*
* This service communicates with the Python LaTeX backend to generate
* filled PDF forms using LaTeX templates.
*/
import { ExtractedField } from '../types';
// Backend API URL - can be configured via environment variable
const API_BASE_URL = import.meta.env.VITE_LATEX_API_URL || 'http://localhost:5000';
export interface LatexGenerationResult {
success: boolean;
pdf?: string; // base64 encoded PDF
mappedFields?: Record<string, string>;
error?: string;
}
export interface TemplateInfo {
name: string;
fields: string[];
}
/**
* Check if the LaTeX backend is available
*/
export const isLatexServiceAvailable = async (): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE_URL}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(3000),
});
return response.ok;
} catch {
return false;
}
};
/**
* Get list of available LaTeX templates
*/
export const getAvailableTemplates = async (): Promise<string[]> => {
try {
const response = await fetch(`${API_BASE_URL}/api/templates`);
if (!response.ok) {
throw new Error('Failed to fetch templates');
}
const data = await response.json();
return data.templates || [];
} catch (error) {
console.warn('Could not fetch templates:', error);
return [];
}
};
/**
* Get field mapping for a specific template
*/
export const getTemplateFieldMapping = async (templateName: string): Promise<Record<string, string[]> | null> => {
try {
const response = await fetch(`${API_BASE_URL}/api/field-mapping/${templateName}`);
if (!response.ok) {
return null;
}
const data = await response.json();
return data.mapping || null;
} catch (error) {
console.warn('Could not fetch field mapping:', error);
return null;
}
};
/**
* Generate a filled PDF using LaTeX template
*/
export const generateLatexPdf = async (
templateName: string,
fields: ExtractedField[]
): Promise<LatexGenerationResult> => {
try {
const response = await fetch(`${API_BASE_URL}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: templateName,
fields: fields.map(f => ({
label: f.label,
value: f.value,
key: f.key,
})),
format: 'base64',
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
const data = await response.json();
return {
success: true,
pdf: data.pdf,
mappedFields: data.mapped_fields,
};
} catch (error) {
console.error('LaTeX PDF generation failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
};
/**
* Preview the filled LaTeX source (for debugging)
*/
export const previewLatexSource = async (
templateName: string,
fields: ExtractedField[]
): Promise<{ latex?: string; error?: string }> => {
try {
const response = await fetch(`${API_BASE_URL}/api/preview`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: templateName,
fields: fields.map(f => ({
label: f.label,
value: f.value,
key: f.key,
})),
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
const data = await response.json();
return { latex: data.latex };
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
};
/**
* Convert base64 PDF to Blob URL for preview/download
*/
export const base64ToBlob = (base64: string, mimeType: string = 'application/pdf'): Blob => {
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
return new Blob([byteArray], { type: mimeType });
};
/**
* Detect which template to use based on form file name or content
*/
export const detectTemplate = (fileName: string): string | null => {
const lowerName = fileName.toLowerCase();
// G2210-11 Ärztlicher Befundbericht
if (lowerName.includes('g2210') ||
lowerName.includes('befundbericht') ||
lowerName.includes('aerztlicher') ||
lowerName.includes('ärztlicher')) {
return 'G2210-11';
}
// Add more template detection patterns here
// if (lowerName.includes('s0051')) return 'S0051';
return null;
};
/**
* Get expected fields for a known form type
* This helps the AI extraction know what fields to look for
*/
export const getExpectedFields = (templateName: string): string[] => {
const fieldMappings: Record<string, string[]> = {
'G2210-11': [
'Versicherungsnummer',
'ABT.-Nr.',
'Name, Vorname',
'Geburtsdatum',
'Geschlecht',
'Straße, Hausnummer',
'PLZ',
'Ort',
'Telefon',
'Krankenkasse',
'Derzeitige Tätigkeit',
'Arbeitgeber',
'Arbeitsunfähig seit',
'Diagnose 1',
'Diagnose 1 ICD',
'Diagnose 2',
'Diagnose 2 ICD',
'Diagnose 3',
'Diagnose 3 ICD',
'Diagnose 4',
'Diagnose 4 ICD',
'Diagnose 5',
'Diagnose 5 ICD',
'Diagnose 6',
'Diagnose 6 ICD',
'Anamnese/Beschwerden',
'Krankheitsverlauf',
'Körperlicher Befund',
'Mobilität (keine/gering/erheblich)',
'Selbstversorgung (keine/gering/erheblich)',
'Haushaltsführung (keine/gering/erheblich)',
'Erwerbstätigkeit (keine/gering/erheblich)',
'Medikament 1',
'Medikament 1 Dosis',
'Medikament 2',
'Medikament 2 Dosis',
'Medikament 3',
'Medikament 3 Dosis',
'Physikalische Therapie',
'Frühere Reha Zeitraum',
'Frühere Reha Einrichtung',
'Leistungsvermögen',
'Rehabilitationsbedürftigkeit',
'Rehabilitationsziel',
'Rehabilitationsform (stationär/ambulant)',
'Reisefähig (ja/nein)',
'Begleitperson erforderlich (ja/nein)',
'Ergänzende Angaben',
'Arzt Name',
'Facharztbezeichnung',
'Praxis Anschrift',
'Praxis Telefon',
'BSNR',
'LANR',
],
};
return fieldMappings[templateName] || [];
};

View file

@ -42,7 +42,7 @@ export const createFilledPdf = async (base64: string, fields: ExtractedField[],
if (field instanceof PDFTextField) {
field.setText(String(value));
} else if (field instanceof PDFCheckBox) {
const isChecked = String(value).toLowerCase() === 'true' || String(value).toLowerCase() === 'yes';
const isChecked = String(value).toLowerCase() === 'true' || String(value).toLowerCase() === 'yes' || String(value).toLowerCase() === 'x';
if (isChecked) field.check();
else field.uncheck();
}
@ -63,8 +63,9 @@ export const createFilledPdf = async (base64: string, fields: ExtractedField[],
const { pageIndex, x, y } = field.coordinates;
// Safety check for page index
if (pageIndex < 0 || pageIndex >= pages.length) continue;
// Safety check for page index and coordinates
if (typeof pageIndex !== 'number' || pageIndex < 0 || pageIndex >= pages.length) continue;
if (isNaN(x) || isNaN(y)) continue;
const page = pages[pageIndex];
const { width, height } = page.getSize();
@ -100,36 +101,5 @@ export const createFilledPdf = async (base64: string, fields: ExtractedField[],
};
export const fillPdf = async (base64: string, fieldValues: Record<string, string | boolean>): Promise<Uint8Array> => {
const pdfDoc = await PDFDocument.load(base64);
try {
const form = pdfDoc.getForm();
for (const [fieldName, value] of Object.entries(fieldValues)) {
try {
const field = form.getField(fieldName);
if (!field) continue;
if (field instanceof PDFTextField) {
field.setText(String(value));
} else if (field instanceof PDFCheckBox) {
const isChecked = typeof value === 'boolean'
? value
: String(value).toLowerCase() === 'true' || String(value).toLowerCase() === 'yes';
if (isChecked) {
field.check();
} else {
field.uncheck();
}
}
} catch (e) {
// Field might be read-only or have other issues - continue with other fields
console.warn(`Could not fill field "${fieldName}":`, e);
}
}
} catch (e) {
console.warn("Error filling form fields:", e);
}
return await pdfDoc.save();
return new Uint8Array();
};

11
tailwind.config.js Normal file
View file

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View file

@ -1,353 +0,0 @@
\documentclass[a4paper,10pt]{article}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage[ngerman]{babel}
\usepackage[margin=1.5cm,top=1cm,bottom=1.5cm]{geometry}
\usepackage{array}
\usepackage{tabularx}
\usepackage{booktabs}
\usepackage{multirow}
\usepackage{graphicx}
\usepackage{xcolor}
\usepackage{helvet}
\usepackage{amssymb}
\usepackage{tikz}
\usepackage{fancyhdr}
\usepackage{lastpage}
\usepackage{enumitem}
\renewcommand{\familydefault}{\sfdefault}
\setlength{\parindent}{0pt}
\setlength{\parskip}{0.3em}
% Custom colors
\definecolor{drvblue}{RGB}{0,51,102}
\definecolor{lightgray}{RGB}{240,240,240}
\definecolor{bordergray}{RGB}{180,180,180}
% Header and Footer
\pagestyle{fancy}
\fancyhf{}
\renewcommand{\headrulewidth}{0pt}
\fancyfoot[C]{\footnotesize Seite \thepage\ von \pageref{LastPage}}
\fancyfoot[R]{\footnotesize G2210-11 (01.2025)}
% Custom commands
\newcommand{\formfield}[2]{\textbf{#1:} #2}
\newcommand{\formbox}[1]{\fbox{\parbox{0.95\linewidth}{#1}}}
\newcommand{\sectionheader}[1]{\colorbox{lightgray}{\parbox{\dimexpr\linewidth-2\fboxsep}{\textbf{#1}}}}
\begin{document}
% === HEADER ===
\begin{center}
{\Large\bfseries\color{drvblue} Deutsche Rentenversicherung Westfalen}\\[0.3em]
{\large Ärztlicher Befundbericht}\\[0.2em]
{\small zum Antrag auf Leistungen zur medizinischen Rehabilitation}\\[0.5em]
\hrule
\end{center}
\vspace{0.5em}
% === PATIENT DATA ===
\sectionheader{1. Angaben zur versicherten Person}
\vspace{0.3em}
\begin{tabularx}{\linewidth}{|p{4cm}|X|p{4cm}|X|}
\hline
\textbf{Versicherungsnummer:} & {{versicherungsnummer}} & \textbf{ABT.-Nr.:} & {{abt_nr}} \\
\hline
\textbf{Name, Vorname:} & \multicolumn{3}{l|}{{{name_vorname}}} \\
\hline
\textbf{Geburtsdatum:} & {{geburtsdatum|date}} & \textbf{Geschlecht:} & {{geschlecht}} \\
\hline
\textbf{Straße, Hausnummer:} & \multicolumn{3}{l|}{{{strasse}}} \\
\hline
\textbf{PLZ, Ort:} & \multicolumn{3}{l|}{{{plz}} {{ort}}} \\
\hline
\textbf{Telefon:} & {{telefon}} & \textbf{Krankenkasse:} & {{krankenkasse}} \\
\hline
\end{tabularx}
\vspace{0.5em}
% === EMPLOYMENT ===
\sectionheader{2. Angaben zur beruflichen Situation}
\vspace{0.3em}
\begin{tabularx}{\linewidth}{|p{5cm}|X|}
\hline
\textbf{Derzeitige Tätigkeit:} & {{beruf_taetigkeit}} \\
\hline
\textbf{Arbeitgeber:} & {{arbeitgeber}} \\
\hline
\textbf{Arbeitsunfähig seit:} & {{au_seit|date}} \\
\hline
\textbf{Letzte Arbeitsaufnahme:} & {{letzte_arbeit|date}} \\
\hline
\end{tabularx}
\vspace{0.5em}
% === DIAGNOSES ===
\sectionheader{3. Diagnosen (mit ICD-10-Schlüssel)}
\vspace{0.3em}
\begin{tabularx}{\linewidth}{|p{1cm}|X|p{2.5cm}|}
\hline
\textbf{Nr.} & \textbf{Diagnose} & \textbf{ICD-10} \\
\hline
1. & {{diagnose_1}} & {{diagnose_1_icd}} \\
\hline
2. & {{diagnose_2}} & {{diagnose_2_icd}} \\
\hline
3. & {{diagnose_3}} & {{diagnose_3_icd}} \\
\hline
4. & {{diagnose_4}} & {{diagnose_4_icd}} \\
\hline
5. & {{diagnose_5}} & {{diagnose_5_icd}} \\
\hline
6. & {{diagnose_6}} & {{diagnose_6_icd}} \\
\hline
\end{tabularx}
\vspace{0.5em}
% === ANAMNESIS ===
\sectionheader{4. Anamnese und aktueller Befund}
\vspace{0.3em}
\textbf{4.1 Eigenanamnese / Beschwerden des Patienten:}
\begin{tabularx}{\linewidth}{|X|}
\hline
{{anamnese_beschwerden}} \\[4em]
\hline
\end{tabularx}
\vspace{0.3em}
\textbf{4.2 Krankheitsverlauf / bisherige Behandlungen:}
\begin{tabularx}{\linewidth}{|X|}
\hline
{{krankheitsverlauf}} \\[4em]
\hline
\end{tabularx}
\vspace{0.3em}
\textbf{4.3 Aktueller körperlicher Befund:}
\begin{tabularx}{\linewidth}{|X|}
\hline
{{koerperlicher_befund}} \\[4em]
\hline
\end{tabularx}
\newpage
% === FUNCTIONAL LIMITATIONS ===
\sectionheader{5. Nicht nur vorübergehende Beeinträchtigungen der Aktivitäten und Teilhabe}
\vspace{0.3em}
\begin{tabularx}{\linewidth}{|X|c|c|c|}
\hline
\textbf{Beeinträchtigungsbereich} & \textbf{Keine} & \textbf{Gering} & \textbf{Erheblich} \\
\hline
Mobilität (Gehen, Treppensteigen, Fortbewegung) & {{mobilitaet_keine|checkbox}} & {{mobilitaet_gering|checkbox}} & {{mobilitaet_erheblich|checkbox}} \\
\hline
Selbstversorgung (Waschen, Ankleiden, Essen) & {{selbstversorgung_keine|checkbox}} & {{selbstversorgung_gering|checkbox}} & {{selbstversorgung_erheblich|checkbox}} \\
\hline
Haushaltsführung & {{haushalt_keine|checkbox}} & {{haushalt_gering|checkbox}} & {{haushalt_erheblich|checkbox}} \\
\hline
Erwerbstätigkeit & {{erwerb_keine|checkbox}} & {{erwerb_gering|checkbox}} & {{erwerb_erheblich|checkbox}} \\
\hline
Kommunikation & {{kommunikation_keine|checkbox}} & {{kommunikation_gering|checkbox}} & {{kommunikation_erheblich|checkbox}} \\
\hline
Psychische Belastbarkeit & {{psyche_keine|checkbox}} & {{psyche_gering|checkbox}} & {{psyche_erheblich|checkbox}} \\
\hline
\end{tabularx}
\vspace{0.3em}
\textbf{Erläuterungen zu den Beeinträchtigungen:}
\begin{tabularx}{\linewidth}{|X|}
\hline
{{beeintraechtigungen_erlaeuterung}} \\[3em]
\hline
\end{tabularx}
\vspace{0.5em}
% === THERAPY ===
\sectionheader{6. Bisherige und aktuelle Therapie}
\vspace{0.3em}
\textbf{6.1 Aktuelle Medikation:}
\begin{tabularx}{\linewidth}{|X|p{2cm}|p{2.5cm}|}
\hline
\textbf{Medikament} & \textbf{Dosis} & \textbf{Seit wann} \\
\hline
{{medikament_1}} & {{medikament_1_dosis}} & {{medikament_1_seit}} \\
\hline
{{medikament_2}} & {{medikament_2_dosis}} & {{medikament_2_seit}} \\
\hline
{{medikament_3}} & {{medikament_3_dosis}} & {{medikament_3_seit}} \\
\hline
{{medikament_4}} & {{medikament_4_dosis}} & {{medikament_4_seit}} \\
\hline
{{medikament_5}} & {{medikament_5_dosis}} & {{medikament_5_seit}} \\
\hline
\end{tabularx}
\vspace{0.3em}
\textbf{6.2 Physikalische Therapie / Heilmittel:}
\begin{tabularx}{\linewidth}{|X|}
\hline
{{physikalische_therapie}} \\[2em]
\hline
\end{tabularx}
\vspace{0.5em}
% === PREVIOUS REHAB ===
\sectionheader{7. Frühere Rehabilitationsmaßnahmen}
\vspace{0.3em}
\begin{tabularx}{\linewidth}{|p{3cm}|X|p{2.5cm}|}
\hline
\textbf{Zeitraum} & \textbf{Einrichtung / Art} & \textbf{Erfolg} \\
\hline
{{reha_1_zeitraum}} & {{reha_1_einrichtung}} & {{reha_1_erfolg}} \\
\hline
{{reha_2_zeitraum}} & {{reha_2_einrichtung}} & {{reha_2_erfolg}} \\
\hline
\end{tabularx}
\vspace{0.5em}
% === ASSESSMENT ===
\sectionheader{8. Ärztliche Beurteilung}
\vspace{0.3em}
\textbf{8.1 Leistungsvermögen im bisherigen Beruf:}\\
{{leistungsvermoegen_checkbox_vollschichtig|checkbox}} Vollschichtig (6 Std. und mehr) \quad
{{leistungsvermoegen_checkbox_teilschichtig|checkbox}} 3-6 Stunden \quad
{{leistungsvermoegen_checkbox_unter3|checkbox}} Unter 3 Stunden
\vspace{0.3em}
\textbf{8.2 Rehabilitationsbedürftigkeit:}
\begin{tabularx}{\linewidth}{|X|}
\hline
{{reha_beduerftig_begruendung}} \\[3em]
\hline
\end{tabularx}
\vspace{0.3em}
\textbf{8.3 Rehabilitationsziel:}
\begin{tabularx}{\linewidth}{|X|}
\hline
{{reha_ziel}} \\[2em]
\hline
\end{tabularx}
\vspace{0.3em}
\textbf{8.4 Empfohlene Rehabilitationsform:}\\
{{reha_stationaer|checkbox}} Stationär \quad
{{reha_ambulant|checkbox}} Ambulant \quad
{{reha_ganztaegig|checkbox}} Ganztägig ambulant
\vspace{0.3em}
\textbf{8.5 Empfohlene Rehabilitationseinrichtung (falls bekannt):}
\begin{tabularx}{\linewidth}{|X|}
\hline
{{reha_einrichtung_empfehlung}} \\[1em]
\hline
\end{tabularx}
\newpage
% === TRAVEL CAPABILITY ===
\sectionheader{9. Reisefähigkeit}
\vspace{0.3em}
\textbf{Kann der Patient mit öffentlichen Verkehrsmitteln reisen?}\\
{{reisefaehig_ja|checkbox}} Ja \quad
{{reisefaehig_nein|checkbox}} Nein
\vspace{0.2em}
\textbf{Falls nein, Begründung:}
\begin{tabularx}{\linewidth}{|X|}
\hline
{{reisefaehig_begruendung}} \\[2em]
\hline
\end{tabularx}
\vspace{0.3em}
\textbf{Begleitperson erforderlich?}\\
{{begleitperson_ja|checkbox}} Ja \quad
{{begleitperson_nein|checkbox}} Nein
\vspace{0.5em}
% === ADDITIONAL INFO ===
\sectionheader{10. Ergänzende Angaben}
\vspace{0.3em}
\begin{tabularx}{\linewidth}{|X|}
\hline
{{ergaenzende_angaben}} \\[4em]
\hline
\end{tabularx}
\vspace{0.5em}
% === ATTACHMENTS ===
\sectionheader{11. Anlagen}
\vspace{0.3em}
{{anlage_laborbefunde|checkbox}} Laborbefunde \quad
{{anlage_roentgen|checkbox}} Röntgen-/Bildgebende Befunde \quad
{{anlage_arztbriefe|checkbox}} Arztbriefe\\
{{anlage_krankenhausberichte|checkbox}} Krankenhausberichte \quad
{{anlage_sonstige|checkbox}} Sonstige: {{anlage_sonstige_text}}
\vspace{1em}
% === SIGNATURE ===
\sectionheader{12. Unterschrift des behandelnden Arztes}
\vspace{0.5em}
\begin{tabularx}{\linewidth}{|p{5cm}|X|}
\hline
\textbf{Datum:} & {{unterschrift_datum|date}} \\
\hline
\textbf{Name des Arztes:} & {{arzt_name}} \\
\hline
\textbf{Facharztbezeichnung:} & {{arzt_fachrichtung}} \\
\hline
\textbf{Anschrift Praxis:} & {{praxis_anschrift}} \\
\hline
\textbf{Telefon:} & {{praxis_telefon}} \\
\hline
\textbf{BSNR/LANR:} & {{bsnr}} / {{lanr}} \\
\hline
\end{tabularx}
\vspace{1.5em}
\begin{tabularx}{\linewidth}{X X}
\hrulefill & \hrulefill \\
Ort, Datum & Unterschrift und Stempel \\
\end{tabularx}
\vspace{1em}
\footnotesize
\textit{Hinweis: Die Angaben werden vertraulich behandelt und unterliegen dem Sozialgeheimnis nach § 35 SGB I.}
\end{document}

View file

@ -1,431 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../App';
import { AppStatus, FileData, FormResponse } from '../types';
// Mock the services
vi.mock('../services/apiKeyService', () => ({
getApiKey: vi.fn(() => 'AItest123'),
setApiKey: vi.fn(),
hasApiKey: vi.fn(() => true),
clearApiKey: vi.fn(),
}));
vi.mock('../services/pdfService', () => ({
getPdfFields: vi.fn(() => Promise.resolve([])),
PdfFieldInfo: {},
}));
vi.mock('../services/geminiService', () => ({
processDocuments: vi.fn(),
}));
// Mock the child components
vi.mock('../components/FileUpload', () => ({
FileUpload: ({ label, onFileSelect, selectedFile }: any) => (
<div data-testid={`file-upload-${label}`}>
<span>{label}</span>
{selectedFile && <span data-testid="file-selected">{selectedFile.file.name}</span>}
<button
data-testid={`select-file-${label}`}
onClick={() => onFileSelect({
file: new File(['test'], 'test.pdf', { type: 'application/pdf' }),
previewUrl: null,
base64: 'base64content',
type: 'application/pdf'
})}
>
Select File
</button>
</div>
),
}));
vi.mock('../components/ReviewPanel', () => ({
ReviewPanel: ({ fields, summary, onReset }: any) => (
<div data-testid="review-panel">
<span data-testid="summary">{summary}</span>
<span data-testid="fields-count">{fields.length} fields</span>
<button data-testid="reset-button" onClick={onReset}>Reset</button>
</div>
),
}));
vi.mock('../components/ApiKeyModal', () => ({
ApiKeyModal: ({ isOpen, onSave, onClose }: any) => (
isOpen ? (
<div data-testid="api-key-modal">
<button data-testid="save-key" onClick={() => onSave('AItest')}>Save</button>
{onClose && <button data-testid="close-modal" onClick={onClose}>Close</button>}
</div>
) : null
),
}));
// Import the mocked modules
import * as apiKeyService from '../services/apiKeyService';
import * as pdfService from '../services/pdfService';
import * as geminiService from '../services/geminiService';
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock: user has API key
vi.mocked(apiKeyService.hasApiKey).mockReturnValue(true);
vi.mocked(apiKeyService.getApiKey).mockReturnValue('AItest123');
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initial rendering', () => {
it('should render the app header with title', () => {
render(<App />);
expect(screen.getByText('AutoForm AI')).toBeInTheDocument();
});
it('should render the main heading', () => {
render(<App />);
expect(screen.getByText('Fill Forms Automatically with AI')).toBeInTheDocument();
});
it('should render two file upload components', () => {
render(<App />);
expect(screen.getByTestId('file-upload-Fillable PDF Form')).toBeInTheDocument();
expect(screen.getByTestId('file-upload-Source Data')).toBeInTheDocument();
});
it('should render the analyze button', () => {
render(<App />);
expect(screen.getByText('Analyze & Fill')).toBeInTheDocument();
});
});
describe('API key modal', () => {
it('should show API key modal when no API key exists', () => {
vi.mocked(apiKeyService.hasApiKey).mockReturnValue(false);
render(<App />);
expect(screen.getByTestId('api-key-modal')).toBeInTheDocument();
});
it('should not show API key modal when API key exists', () => {
vi.mocked(apiKeyService.hasApiKey).mockReturnValue(true);
render(<App />);
expect(screen.queryByTestId('api-key-modal')).not.toBeInTheDocument();
});
it('should save API key when save is clicked', async () => {
vi.mocked(apiKeyService.hasApiKey).mockReturnValue(false);
const user = userEvent.setup();
render(<App />);
await user.click(screen.getByTestId('save-key'));
expect(apiKeyService.setApiKey).toHaveBeenCalledWith('AItest');
});
it('should show settings button that opens API key modal', async () => {
const user = userEvent.setup();
render(<App />);
// Find settings button by title
const settingsButton = screen.getByTitle('API Key Einstellungen');
await user.click(settingsButton);
expect(screen.getByTestId('api-key-modal')).toBeInTheDocument();
});
});
describe('analyze button state', () => {
it('should disable analyze button when no files are selected', () => {
render(<App />);
const button = screen.getByText('Analyze & Fill').closest('button');
expect(button).toBeDisabled();
});
it('should enable analyze button when both files are selected', async () => {
const user = userEvent.setup();
render(<App />);
// Select both files
await user.click(screen.getByTestId('select-file-Fillable PDF Form'));
await user.click(screen.getByTestId('select-file-Source Data'));
const button = screen.getByText('Analyze & Fill').closest('button');
expect(button).not.toBeDisabled();
});
});
describe('processing state', () => {
it('should show processing UI when analyzing', async () => {
const user = userEvent.setup();
// Make processDocuments hang
vi.mocked(geminiService.processDocuments).mockImplementation(
() => new Promise(() => {})
);
render(<App />);
// Select both files
await user.click(screen.getByTestId('select-file-Fillable PDF Form'));
await user.click(screen.getByTestId('select-file-Source Data'));
// Click analyze
await user.click(screen.getByText('Analyze & Fill'));
await waitFor(() => {
expect(screen.getByText('Processing Documents...')).toBeInTheDocument();
});
});
it('should show processing step indicators', async () => {
const user = userEvent.setup();
vi.mocked(geminiService.processDocuments).mockImplementation(
() => new Promise(() => {})
);
render(<App />);
await user.click(screen.getByTestId('select-file-Fillable PDF Form'));
await user.click(screen.getByTestId('select-file-Source Data'));
await user.click(screen.getByText('Analyze & Fill'));
await waitFor(() => {
expect(screen.getByText('Parsing PDF')).toBeInTheDocument();
expect(screen.getByText('Extracting Data')).toBeInTheDocument();
expect(screen.getByText('Filling Form')).toBeInTheDocument();
});
});
});
describe('review state', () => {
it('should show ReviewPanel after successful analysis', async () => {
const user = userEvent.setup();
const mockResponse: FormResponse = {
summary: 'Test summary',
fields: [
{ label: 'Name', value: 'John', validation: { status: 'VALID' } },
],
};
vi.mocked(geminiService.processDocuments).mockResolvedValue(mockResponse);
render(<App />);
await user.click(screen.getByTestId('select-file-Fillable PDF Form'));
await user.click(screen.getByTestId('select-file-Source Data'));
await user.click(screen.getByText('Analyze & Fill'));
await waitFor(() => {
expect(screen.getByTestId('review-panel')).toBeInTheDocument();
});
});
it('should display summary from response', async () => {
const user = userEvent.setup();
const mockResponse: FormResponse = {
summary: 'Document processed successfully',
fields: [],
};
vi.mocked(geminiService.processDocuments).mockResolvedValue(mockResponse);
render(<App />);
await user.click(screen.getByTestId('select-file-Fillable PDF Form'));
await user.click(screen.getByTestId('select-file-Source Data'));
await user.click(screen.getByText('Analyze & Fill'));
await waitFor(() => {
expect(screen.getByText('Document processed successfully')).toBeInTheDocument();
});
});
it('should reset to idle state when reset button is clicked', async () => {
const user = userEvent.setup();
const mockResponse: FormResponse = {
summary: 'Test',
fields: [],
};
vi.mocked(geminiService.processDocuments).mockResolvedValue(mockResponse);
render(<App />);
await user.click(screen.getByTestId('select-file-Fillable PDF Form'));
await user.click(screen.getByTestId('select-file-Source Data'));
await user.click(screen.getByText('Analyze & Fill'));
await waitFor(() => {
expect(screen.getByTestId('review-panel')).toBeInTheDocument();
});
await user.click(screen.getByTestId('reset-button'));
await waitFor(() => {
expect(screen.queryByTestId('review-panel')).not.toBeInTheDocument();
expect(screen.getByText('Fill Forms Automatically with AI')).toBeInTheDocument();
});
});
});
describe('error state', () => {
it('should show error message when analysis fails', async () => {
const user = userEvent.setup();
vi.mocked(geminiService.processDocuments).mockRejectedValue(
new Error('API error occurred')
);
render(<App />);
await user.click(screen.getByTestId('select-file-Fillable PDF Form'));
await user.click(screen.getByTestId('select-file-Source Data'));
await user.click(screen.getByText('Analyze & Fill'));
await waitFor(() => {
expect(screen.getByText('API error occurred')).toBeInTheDocument();
});
});
it('should show generic error message when error has no message', async () => {
const user = userEvent.setup();
vi.mocked(geminiService.processDocuments).mockRejectedValue(new Error());
render(<App />);
await user.click(screen.getByTestId('select-file-Fillable PDF Form'));
await user.click(screen.getByTestId('select-file-Source Data'));
await user.click(screen.getByText('Analyze & Fill'));
await waitFor(() => {
expect(screen.getByText('Something went wrong during analysis.')).toBeInTheDocument();
});
});
it('should allow retry after error', async () => {
const user = userEvent.setup();
vi.mocked(geminiService.processDocuments)
.mockRejectedValueOnce(new Error('First failure'))
.mockResolvedValueOnce({ summary: 'Success', fields: [] });
render(<App />);
await user.click(screen.getByTestId('select-file-Fillable PDF Form'));
await user.click(screen.getByTestId('select-file-Source Data'));
// First attempt fails
await user.click(screen.getByText('Analyze & Fill'));
await waitFor(() => {
expect(screen.getByText('First failure')).toBeInTheDocument();
});
// Second attempt succeeds
await user.click(screen.getByText('Analyze & Fill'));
await waitFor(() => {
expect(screen.getByTestId('review-panel')).toBeInTheDocument();
});
});
});
describe('PDF field detection', () => {
it('should detect PDF fields when form file is selected', async () => {
const user = userEvent.setup();
vi.mocked(pdfService.getPdfFields).mockResolvedValue([
{ name: 'field1', type: 'PDFTextField' },
{ name: 'field2', type: 'PDFTextField' },
]);
render(<App />);
await user.click(screen.getByTestId('select-file-Fillable PDF Form'));
await waitFor(() => {
expect(pdfService.getPdfFields).toHaveBeenCalled();
});
});
it('should show fillable fields count when detected', async () => {
const user = userEvent.setup();
vi.mocked(pdfService.getPdfFields).mockResolvedValue([
{ name: 'field1', type: 'PDFTextField' },
{ name: 'field2', type: 'PDFTextField' },
{ name: 'field3', type: 'PDFTextField' },
]);
render(<App />);
await user.click(screen.getByTestId('select-file-Fillable PDF Form'));
await waitFor(() => {
expect(screen.getByText('3 fillable fields detected')).toBeInTheDocument();
});
});
it('should not show field count when no fields detected', async () => {
const user = userEvent.setup();
vi.mocked(pdfService.getPdfFields).mockResolvedValue([]);
render(<App />);
await user.click(screen.getByTestId('select-file-Fillable PDF Form'));
await waitFor(() => {
expect(screen.queryByText(/fillable fields detected/)).not.toBeInTheDocument();
});
});
it('should pass PDF fields to processDocuments', async () => {
const user = userEvent.setup();
const mockFields = [
{ name: 'firstName', type: 'PDFTextField' },
{ name: 'lastName', type: 'PDFTextField' },
];
vi.mocked(pdfService.getPdfFields).mockResolvedValue(mockFields);
vi.mocked(geminiService.processDocuments).mockResolvedValue({
summary: 'Test',
fields: [],
});
render(<App />);
await user.click(screen.getByTestId('select-file-Fillable PDF Form'));
// Wait for PDF analysis
await waitFor(() => {
expect(pdfService.getPdfFields).toHaveBeenCalled();
});
await user.click(screen.getByTestId('select-file-Source Data'));
await user.click(screen.getByText('Analyze & Fill'));
await waitFor(() => {
expect(geminiService.processDocuments).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
mockFields
);
});
});
});
describe('navigation steps', () => {
it('should display workflow steps in header', () => {
render(<App />);
expect(screen.getByText('1. Scan')).toBeInTheDocument();
expect(screen.getByText('2. Extract')).toBeInTheDocument();
expect(screen.getByText('3. Review')).toBeInTheDocument();
});
});
});

View file

@ -1,237 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ApiKeyModal } from '../../components/ApiKeyModal';
describe('ApiKeyModal', () => {
const mockOnSave = vi.fn();
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('visibility', () => {
it('should not render when isOpen is false', () => {
render(<ApiKeyModal isOpen={false} onSave={mockOnSave} />);
expect(screen.queryByText('Gemini API Key')).not.toBeInTheDocument();
});
it('should render when isOpen is true', () => {
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
expect(screen.getByText('Gemini API Key')).toBeInTheDocument();
});
});
describe('form elements', () => {
it('should render the API key input field', () => {
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
expect(screen.getByPlaceholderText('AIza...')).toBeInTheDocument();
});
it('should render the submit button', () => {
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
expect(screen.getByRole('button', { name: 'Speichern' })).toBeInTheDocument();
});
it('should render link to Google AI Studio', () => {
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
const link = screen.getByRole('link', { name: /API Key bei Google AI Studio holen/i });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://aistudio.google.com/apikey');
expect(link).toHaveAttribute('target', '_blank');
});
it('should render privacy notice', () => {
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
expect(screen.getByText(/nur lokal in deinem Browser gespeichert/i)).toBeInTheDocument();
});
});
describe('close button', () => {
it('should not render close button when onClose is not provided', () => {
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
// The close button should not be present
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(1); // Only save button
});
it('should render close button when onClose is provided', () => {
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} onClose={mockOnClose} />);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2); // Save button and close button
});
it('should call onClose when close button is clicked', async () => {
const user = userEvent.setup();
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} onClose={mockOnClose} />);
const buttons = screen.getAllByRole('button');
const closeButton = buttons.find(btn => btn !== screen.getByText('Speichern'));
await user.click(closeButton!);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
describe('currentKey prop', () => {
it('should pre-fill input with currentKey', () => {
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} currentKey="AIexisting123" />);
const input = screen.getByPlaceholderText('AIza...') as HTMLInputElement;
expect(input.value).toBe('AIexisting123');
});
it('should leave input empty when currentKey is not provided', () => {
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
const input = screen.getByPlaceholderText('AIza...') as HTMLInputElement;
expect(input.value).toBe('');
});
});
describe('form validation', () => {
it('should show error when submitting empty key', async () => {
const user = userEvent.setup();
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
await user.click(screen.getByText('Speichern'));
expect(screen.getByText('Bitte gib einen API Key ein')).toBeInTheDocument();
expect(mockOnSave).not.toHaveBeenCalled();
});
it('should show error when submitting whitespace-only key', async () => {
const user = userEvent.setup();
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
const input = screen.getByPlaceholderText('AIza...');
await user.type(input, ' ');
await user.click(screen.getByText('Speichern'));
expect(screen.getByText('Bitte gib einen API Key ein')).toBeInTheDocument();
expect(mockOnSave).not.toHaveBeenCalled();
});
it('should show error when key does not start with AI', async () => {
const user = userEvent.setup();
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
const input = screen.getByPlaceholderText('AIza...');
await user.type(input, 'invalid_key');
await user.click(screen.getByText('Speichern'));
expect(screen.getByText('Der Key sollte mit "AI" beginnen')).toBeInTheDocument();
expect(mockOnSave).not.toHaveBeenCalled();
});
it('should accept key that starts with AI', async () => {
const user = userEvent.setup();
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
const input = screen.getByPlaceholderText('AIza...');
await user.type(input, 'AIvalidkey123');
await user.click(screen.getByText('Speichern'));
expect(mockOnSave).toHaveBeenCalledWith('AIvalidkey123');
});
it('should clear error when user types', async () => {
const user = userEvent.setup();
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
// First, trigger an error
await user.click(screen.getByText('Speichern'));
expect(screen.getByText('Bitte gib einen API Key ein')).toBeInTheDocument();
// Now type something
const input = screen.getByPlaceholderText('AIza...');
await user.type(input, 'A');
// Error should be cleared
expect(screen.queryByText('Bitte gib einen API Key ein')).not.toBeInTheDocument();
});
});
describe('form submission', () => {
it('should call onSave with trimmed key on valid submit', async () => {
const user = userEvent.setup();
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
const input = screen.getByPlaceholderText('AIza...');
// Type key with leading/trailing spaces
await user.clear(input);
await user.type(input, 'AIkey123');
await user.click(screen.getByText('Speichern'));
// The component should trim the input
expect(mockOnSave).toHaveBeenCalledWith('AIkey123');
});
it('should submit on Enter key press', async () => {
const user = userEvent.setup();
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
const input = screen.getByPlaceholderText('AIza...');
await user.type(input, 'AIkey123');
await user.keyboard('{Enter}');
expect(mockOnSave).toHaveBeenCalledWith('AIkey123');
});
it('should prevent form default submission', async () => {
const user = userEvent.setup();
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
const input = screen.getByPlaceholderText('AIza...');
await user.type(input, 'AIkey123');
const form = input.closest('form');
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
const preventDefaultSpy = vi.spyOn(submitEvent, 'preventDefault');
fireEvent(form!, submitEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
});
});
describe('input type', () => {
it('should have password type for security', () => {
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
const input = screen.getByPlaceholderText('AIza...');
expect(input).toHaveAttribute('type', 'password');
});
});
describe('accessibility', () => {
it('should have proper label for input', () => {
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
expect(screen.getByText('API Key')).toBeInTheDocument();
});
it('should have autofocus on input', () => {
render(<ApiKeyModal isOpen={true} onSave={mockOnSave} />);
const input = screen.getByPlaceholderText('AIza...');
// In the DOM, React's autoFocus prop becomes autofocus attribute (lowercase)
// But jsdom doesn't actually focus, so we check the document.activeElement or just verify the component renders
expect(input).toBeInTheDocument();
});
});
});

View file

@ -1,331 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FileUpload } from '../../components/FileUpload';
import { FileData } from '../../types';
describe('FileUpload', () => {
const defaultProps = {
label: 'Upload Document',
description: 'PDF or Image files accepted',
accept: '.pdf,image/*',
onFileSelect: vi.fn(),
selectedFile: null as FileData | null
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('should render upload area when no file is selected', () => {
render(<FileUpload {...defaultProps} />);
expect(screen.getByText('Upload Document')).toBeInTheDocument();
expect(screen.getByText('PDF or Image files accepted')).toBeInTheDocument();
expect(screen.getByText('Click to upload or drag and drop')).toBeInTheDocument();
});
it('should render file info when a file is selected', () => {
const mockFile: FileData = {
file: new File(['content'], 'document.pdf', { type: 'application/pdf' }),
previewUrl: null,
base64: 'base64content',
type: 'application/pdf'
};
Object.defineProperty(mockFile.file, 'size', { value: 1048576 }); // 1MB
render(<FileUpload {...defaultProps} selectedFile={mockFile} />);
expect(screen.getByText('document.pdf')).toBeInTheDocument();
expect(screen.getByText('1.00 MB')).toBeInTheDocument();
});
it('should show image preview when selected file is an image', () => {
const mockFile: FileData = {
file: new File([''], 'photo.png', { type: 'image/png' }),
previewUrl: 'data:image/png;base64,abc123',
base64: 'abc123',
type: 'image/png'
};
render(<FileUpload {...defaultProps} selectedFile={mockFile} />);
const img = screen.getByAltText('Preview');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc123');
});
it('should show file icon when selected file is a PDF', () => {
const mockFile: FileData = {
file: new File([''], 'document.pdf', { type: 'application/pdf' }),
previewUrl: null,
base64: 'pdfcontent',
type: 'application/pdf'
};
render(<FileUpload {...defaultProps} selectedFile={mockFile} />);
// Should not show image preview
expect(screen.queryByAltText('Preview')).not.toBeInTheDocument();
});
});
describe('file selection via input', () => {
it('should call onFileSelect when a file is selected via input', async () => {
const onFileSelect = vi.fn();
render(<FileUpload {...defaultProps} onFileSelect={onFileSelect} />);
const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
// Mock FileReader
const mockFileReader = {
readAsDataURL: vi.fn(),
result: 'data:application/pdf;base64,dGVzdCBjb250ZW50',
onload: null as ((ev: ProgressEvent<FileReader>) => void) | null
};
vi.spyOn(global, 'FileReader').mockImplementation(() => {
return mockFileReader as unknown as FileReader;
});
fireEvent.change(input, { target: { files: [file] } });
// Trigger the onload callback
mockFileReader.onload?.({} as ProgressEvent<FileReader>);
await waitFor(() => {
expect(onFileSelect).toHaveBeenCalledWith(
expect.objectContaining({
file: expect.any(File),
base64: 'dGVzdCBjb250ZW50', // base64 content without prefix
type: 'application/pdf'
})
);
});
});
it('should not call onFileSelect when no files are selected', () => {
const onFileSelect = vi.fn();
render(<FileUpload {...defaultProps} onFileSelect={onFileSelect} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [] } });
expect(onFileSelect).not.toHaveBeenCalled();
});
});
describe('drag and drop', () => {
it('should highlight on drag over', () => {
render(<FileUpload {...defaultProps} />);
const dropZone = screen.getByText('Click to upload or drag and drop').parentElement?.parentElement?.parentElement;
fireEvent.dragOver(dropZone!, { preventDefault: vi.fn() });
// Check for the dragging class (indigo border)
expect(dropZone).toHaveClass('border-indigo-500');
});
it('should remove highlight on drag leave', () => {
render(<FileUpload {...defaultProps} />);
const dropZone = screen.getByText('Click to upload or drag and drop').parentElement?.parentElement?.parentElement;
fireEvent.dragOver(dropZone!, { preventDefault: vi.fn() });
fireEvent.dragLeave(dropZone!);
expect(dropZone).not.toHaveClass('border-indigo-500');
});
it('should handle file drop', async () => {
const onFileSelect = vi.fn();
render(<FileUpload {...defaultProps} onFileSelect={onFileSelect} />);
const file = new File(['dropped content'], 'dropped.pdf', { type: 'application/pdf' });
const dropZone = screen.getByText('Click to upload or drag and drop').parentElement?.parentElement?.parentElement;
// Mock FileReader
const mockFileReader = {
readAsDataURL: vi.fn(),
result: 'data:application/pdf;base64,ZHJvcHBlZCBjb250ZW50',
onload: null as ((ev: ProgressEvent<FileReader>) => void) | null
};
vi.spyOn(global, 'FileReader').mockImplementation(() => {
return mockFileReader as unknown as FileReader;
});
fireEvent.drop(dropZone!, {
preventDefault: vi.fn(),
dataTransfer: { files: [file] }
});
// Trigger the onload callback
mockFileReader.onload?.({} as ProgressEvent<FileReader>);
await waitFor(() => {
expect(onFileSelect).toHaveBeenCalledWith(
expect.objectContaining({
file: expect.any(File),
base64: 'ZHJvcHBlZCBjb250ZW50',
type: 'application/pdf'
})
);
});
});
it('should not process drop if no files in dataTransfer', () => {
const onFileSelect = vi.fn();
render(<FileUpload {...defaultProps} onFileSelect={onFileSelect} />);
const dropZone = screen.getByText('Click to upload or drag and drop').parentElement?.parentElement?.parentElement;
fireEvent.drop(dropZone!, {
preventDefault: vi.fn(),
dataTransfer: { files: [] }
});
expect(onFileSelect).not.toHaveBeenCalled();
});
});
describe('file clearing', () => {
it('should call onFileSelect with null when clear button is clicked', async () => {
const onFileSelect = vi.fn();
const mockFile: FileData = {
file: new File([''], 'document.pdf', { type: 'application/pdf' }),
previewUrl: null,
base64: 'content',
type: 'application/pdf'
};
render(<FileUpload {...defaultProps} onFileSelect={onFileSelect} selectedFile={mockFile} />);
// Find the clear button (X icon button)
const clearButton = screen.getByRole('button');
await userEvent.click(clearButton);
expect(onFileSelect).toHaveBeenCalledWith(null);
});
});
describe('click to upload', () => {
it('should open file dialog when upload area is clicked', () => {
render(<FileUpload {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(input, 'click');
const uploadArea = screen.getByText('Click to upload or drag and drop').parentElement?.parentElement?.parentElement;
fireEvent.click(uploadArea!);
expect(clickSpy).toHaveBeenCalled();
});
});
describe('file preview generation', () => {
it('should generate preview URL for image files', async () => {
const onFileSelect = vi.fn();
render(<FileUpload {...defaultProps} onFileSelect={onFileSelect} />);
const imageFile = new File(['image'], 'photo.png', { type: 'image/png' });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const mockFileReader = {
readAsDataURL: vi.fn(),
result: 'data:image/png;base64,aW1hZ2U=',
onload: null as ((ev: ProgressEvent<FileReader>) => void) | null
};
vi.spyOn(global, 'FileReader').mockImplementation(() => {
return mockFileReader as unknown as FileReader;
});
fireEvent.change(input, { target: { files: [imageFile] } });
mockFileReader.onload?.({} as ProgressEvent<FileReader>);
await waitFor(() => {
expect(onFileSelect).toHaveBeenCalledWith(
expect.objectContaining({
previewUrl: 'data:image/png;base64,aW1hZ2U=',
type: 'image/png'
})
);
});
});
it('should set previewUrl to null for PDF files', async () => {
const onFileSelect = vi.fn();
render(<FileUpload {...defaultProps} onFileSelect={onFileSelect} />);
const pdfFile = new File(['pdf'], 'doc.pdf', { type: 'application/pdf' });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const mockFileReader = {
readAsDataURL: vi.fn(),
result: 'data:application/pdf;base64,cGRm',
onload: null as ((ev: ProgressEvent<FileReader>) => void) | null
};
vi.spyOn(global, 'FileReader').mockImplementation(() => {
return mockFileReader as unknown as FileReader;
});
fireEvent.change(input, { target: { files: [pdfFile] } });
mockFileReader.onload?.({} as ProgressEvent<FileReader>);
await waitFor(() => {
expect(onFileSelect).toHaveBeenCalledWith(
expect.objectContaining({
previewUrl: null,
type: 'application/pdf'
})
);
});
});
});
describe('accept attribute', () => {
it('should pass accept attribute to input element', () => {
render(<FileUpload {...defaultProps} accept=".pdf,.docx" />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input.accept).toBe('.pdf,.docx');
});
});
describe('file size display', () => {
it('should display correct file size in MB', () => {
const mockFile: FileData = {
file: new File([''], 'large.pdf', { type: 'application/pdf' }),
previewUrl: null,
base64: 'content',
type: 'application/pdf'
};
// 5.5 MB
Object.defineProperty(mockFile.file, 'size', { value: 5767168 });
render(<FileUpload {...defaultProps} selectedFile={mockFile} />);
expect(screen.getByText('5.50 MB')).toBeInTheDocument();
});
it('should display small file sizes correctly', () => {
const mockFile: FileData = {
file: new File([''], 'small.txt', { type: 'application/pdf' }),
previewUrl: null,
base64: 'content',
type: 'application/pdf'
};
// 50 KB
Object.defineProperty(mockFile.file, 'size', { value: 51200 });
render(<FileUpload {...defaultProps} selectedFile={mockFile} />);
expect(screen.getByText('0.05 MB')).toBeInTheDocument();
});
});
});

View file

@ -1,370 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ReviewPanel } from '../../components/ReviewPanel';
import { ExtractedField, FileData } from '../../types';
// Mock pdfService
vi.mock('../../services/pdfService', () => ({
createFilledPdf: vi.fn().mockResolvedValue(new Uint8Array([0, 1, 2, 3]))
}));
// Mock jspdf
vi.mock('jspdf', () => ({
jsPDF: vi.fn().mockImplementation(() => ({
text: vi.fn(),
save: vi.fn()
}))
}));
describe('ReviewPanel', () => {
const mockFields: ExtractedField[] = [
{
key: 'firstName',
label: 'First Name',
value: 'John',
validation: { status: 'VALID' },
isVerified: false
},
{
key: 'lastName',
label: 'Last Name',
value: 'Doe',
validation: { status: 'WARNING', message: 'Name might be incomplete', suggestion: 'Doe Jr.' },
isVerified: false
},
{
key: 'date',
label: 'Date',
value: '2025-01-28',
validation: { status: 'INVALID', message: 'Invalid date format' },
isVerified: false
}
];
const mockFormFile: FileData = {
file: new File([''], 'form.pdf', { type: 'application/pdf' }),
previewUrl: null,
base64: 'formbase64',
type: 'application/pdf'
};
const mockSourceFile: FileData = {
file: new File([''], 'source.pdf', { type: 'application/pdf' }),
previewUrl: null,
base64: 'sourcebase64',
type: 'application/pdf'
};
const defaultProps = {
fields: mockFields,
formFile: mockFormFile,
sourceFile: mockSourceFile,
summary: 'Processed medical document',
isFillablePdf: true,
onReset: vi.fn()
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe('rendering', () => {
it('should render summary text', () => {
render(<ReviewPanel {...defaultProps} />);
expect(screen.getByText('Processed medical document')).toBeInTheDocument();
});
it('should render all fields', () => {
render(<ReviewPanel {...defaultProps} />);
expect(screen.getByText('First Name')).toBeInTheDocument();
expect(screen.getByText('Last Name')).toBeInTheDocument();
expect(screen.getByText('Date')).toBeInTheDocument();
});
it('should render all field values', () => {
render(<ReviewPanel {...defaultProps} />);
const inputs = screen.getAllByRole('textbox');
const values = inputs.map(input => (input as HTMLInputElement).value);
expect(values).toContain('John');
expect(values).toContain('Doe');
expect(values).toContain('2025-01-28');
});
it('should display warning messages for fields with warnings', () => {
render(<ReviewPanel {...defaultProps} />);
expect(screen.getByText('Name might be incomplete')).toBeInTheDocument();
});
it('should display error messages for invalid fields', () => {
render(<ReviewPanel {...defaultProps} />);
expect(screen.getByText('Invalid date format')).toBeInTheDocument();
});
it('should show suggestion button when available', () => {
render(<ReviewPanel {...defaultProps} />);
expect(screen.getByText(/Accept Fix: "Doe Jr\."/)).toBeInTheDocument();
});
it('should show verification progress', () => {
render(<ReviewPanel {...defaultProps} />);
expect(screen.getByText('0 / 3 Verified')).toBeInTheDocument();
expect(screen.getByText('0 of 3 fields verified')).toBeInTheDocument();
});
it('should show Visual Overlay Mode indicator when not fillable', () => {
render(<ReviewPanel {...defaultProps} isFillablePdf={false} />);
expect(screen.getAllByText('Visual Overlay Mode').length).toBeGreaterThan(0);
});
it('should not show Visual Overlay Mode indicator when fillable', () => {
render(<ReviewPanel {...defaultProps} isFillablePdf={true} />);
expect(screen.queryByText('Visual Overlay Mode')).not.toBeInTheDocument();
});
});
describe('field sorting', () => {
it('should sort attention-needed fields before valid fields', () => {
render(<ReviewPanel {...defaultProps} />);
const inputs = screen.getAllByRole('textbox');
// The last input should be the VALID field (John)
expect(inputs[inputs.length - 1]).toHaveValue('John');
});
it('should sort verified fields after unverified', () => {
const fieldsWithVerified: ExtractedField[] = [
{ key: 'a', label: 'A', value: 'val1', validation: { status: 'VALID' }, isVerified: true },
{ key: 'b', label: 'B', value: 'val2', validation: { status: 'VALID' }, isVerified: false }
];
render(<ReviewPanel {...defaultProps} fields={fieldsWithVerified} />);
const inputs = screen.getAllByRole('textbox');
// Unverified first
expect(inputs[0]).toHaveValue('val2');
expect(inputs[1]).toHaveValue('val1');
});
});
describe('field editing', () => {
it('should update field value when edited', async () => {
const user = userEvent.setup();
render(<ReviewPanel {...defaultProps} />);
const inputs = screen.getAllByRole('textbox');
const johnInput = inputs.find(input => (input as HTMLInputElement).value === 'John');
await user.clear(johnInput!);
await user.type(johnInput!, 'Jane');
expect(johnInput).toHaveValue('Jane');
});
it('should auto-verify field when manually edited', async () => {
const user = userEvent.setup();
render(<ReviewPanel {...defaultProps} />);
const inputs = screen.getAllByRole('textbox');
const doeInput = inputs.find(input => (input as HTMLInputElement).value === 'Doe');
await user.clear(doeInput!);
await user.type(doeInput!, 'Smith');
await waitFor(() => {
expect(screen.getAllByText('VERIFIED').length).toBeGreaterThan(0);
});
});
});
describe('verification toggle', () => {
it('should toggle verification status when checkbox clicked', async () => {
const user = userEvent.setup();
render(<ReviewPanel {...defaultProps} />);
const verifyButtons = screen.getAllByTitle('Mark as verified');
await user.click(verifyButtons[0]);
await waitFor(() => {
expect(screen.getByText('1 / 3 Verified')).toBeInTheDocument();
});
});
it('should update progress when field is verified', async () => {
const user = userEvent.setup();
render(<ReviewPanel {...defaultProps} />);
const verifyButtons = screen.getAllByTitle('Mark as verified');
await user.click(verifyButtons[0]);
await user.click(verifyButtons[1]);
await waitFor(() => {
expect(screen.getByText('2 / 3 Verified')).toBeInTheDocument();
});
});
});
describe('suggestion application', () => {
it('should apply suggestion when Accept Fix button is clicked', async () => {
const user = userEvent.setup();
render(<ReviewPanel {...defaultProps} />);
const acceptButton = screen.getByText(/Accept Fix: "Doe Jr\."/);
await user.click(acceptButton);
await waitFor(() => {
const inputs = screen.getAllByRole('textbox');
const updatedInput = inputs.find(input => (input as HTMLInputElement).value === 'Doe Jr.');
expect(updatedInput).toBeInTheDocument();
});
});
});
describe('filtering', () => {
it('should show all fields when ALL filter is selected', () => {
render(<ReviewPanel {...defaultProps} />);
expect(screen.getByText('First Name')).toBeInTheDocument();
expect(screen.getByText('Last Name')).toBeInTheDocument();
expect(screen.getByText('Date')).toBeInTheDocument();
});
it('should filter to only attention-needed fields when ATTENTION filter is selected', async () => {
const user = userEvent.setup();
render(<ReviewPanel {...defaultProps} />);
const attentionButton = screen.getByText(/Needs Review/);
await user.click(attentionButton);
expect(screen.getByText('Last Name')).toBeInTheDocument();
expect(screen.getByText('Date')).toBeInTheDocument();
expect(screen.queryByText('First Name')).not.toBeInTheDocument();
});
it('should show correct count in filter buttons', () => {
render(<ReviewPanel {...defaultProps} />);
expect(screen.getByText('All Fields (3)')).toBeInTheDocument();
expect(screen.getByText(/Needs Review \(2\)/)).toBeInTheDocument();
});
});
describe('reset functionality', () => {
it('should call onReset when Start Over button is clicked', async () => {
const onReset = vi.fn();
const user = userEvent.setup();
render(<ReviewPanel {...defaultProps} onReset={onReset} />);
const startOverButton = screen.getByText('Start Over');
await user.click(startOverButton);
expect(onReset).toHaveBeenCalled();
});
});
describe('preview generation', () => {
it('should use image preview URL when form is not PDF', async () => {
const imageFormFile: FileData = {
file: new File([''], 'form.png', { type: 'image/png' }),
previewUrl: 'data:image/png;base64,imagedata',
base64: 'imagedata',
type: 'image/png'
};
render(<ReviewPanel {...defaultProps} formFile={imageFormFile} />);
// Wait for the useEffect to set the preview URL
await waitFor(() => {
expect(screen.getByAltText('Form Document')).toBeInTheDocument();
});
});
});
describe('source context', () => {
it('should display source context when available and field not verified', () => {
const fieldsWithContext: ExtractedField[] = [
{
key: 'name',
label: 'Name',
value: 'John',
sourceContext: 'Patient name: John Smith',
validation: { status: 'WARNING', message: 'Check name' },
isVerified: false
}
];
render(<ReviewPanel {...defaultProps} fields={fieldsWithContext} />);
expect(screen.getByText(/"Patient name: John Smith"/)).toBeInTheDocument();
});
it('should hide source context when field is verified', async () => {
const user = userEvent.setup();
const fieldsWithContext: ExtractedField[] = [
{
key: 'name',
label: 'Name',
value: 'John',
sourceContext: 'Patient name: John Smith',
validation: { status: 'WARNING', message: 'Check name' },
isVerified: false
}
];
render(<ReviewPanel {...defaultProps} fields={fieldsWithContext} />);
const verifyButton = screen.getByTitle('Mark as verified');
await user.click(verifyButton);
await waitFor(() => {
expect(screen.queryByText(/"Patient name: John Smith"/)).not.toBeInTheDocument();
});
});
});
describe('empty state', () => {
it('should not show Needs Review button when all fields are valid', () => {
const allValidFields: ExtractedField[] = [
{ key: 'a', label: 'A', value: 'val1', validation: { status: 'VALID' }, isVerified: true }
];
render(<ReviewPanel {...defaultProps} fields={allValidFields} />);
expect(screen.queryByText(/Needs Review/)).not.toBeInTheDocument();
});
});
describe('progress indicator', () => {
it('should show Ready to Download when all fields verified', () => {
const allVerifiedFields: ExtractedField[] = [
{ key: 'a', label: 'A', value: 'val1', validation: { status: 'VALID' }, isVerified: true }
];
render(<ReviewPanel {...defaultProps} fields={allVerifiedFields} />);
expect(screen.getByText('Ready to Download')).toBeInTheDocument();
});
it('should show Review in progress when not all fields verified', () => {
render(<ReviewPanel {...defaultProps} />);
expect(screen.getByText('Review in progress')).toBeInTheDocument();
});
});
describe('field key fallback', () => {
it('should display key as label fallback when label is missing', () => {
const fieldsWithoutLabel: ExtractedField[] = [
{ key: 'fieldKey', label: '', value: 'test', validation: { status: 'VALID' }, isVerified: false }
];
render(<ReviewPanel {...defaultProps} fields={fieldsWithoutLabel} />);
expect(screen.getByText('fieldKey')).toBeInTheDocument();
});
it('should display Unknown Field when both key and label are missing', () => {
const fieldsWithoutBoth: ExtractedField[] = [
{ label: '', value: 'test', validation: { status: 'VALID' }, isVerified: false }
];
render(<ReviewPanel {...defaultProps} fields={fieldsWithoutBoth} />);
expect(screen.getByText('Unknown Field')).toBeInTheDocument();
});
});
});

View file

@ -1,344 +0,0 @@
#!/usr/bin/env python3
"""
Tests for fill_pdf.py - PDF Form Filler utility
Run with: pytest tests/fill_pdf_test.py -v
"""
import json
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch, mock_open
import pytest
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from fill_pdf import extract_fields, fill_pdf, main
class TestExtractFields:
"""Tests for the extract_fields function"""
def test_extract_fields_returns_empty_for_no_fields(self):
"""Should return empty list when PDF has no form fields"""
with patch('fill_pdf.PdfReader') as mock_reader:
mock_reader.return_value.get_fields.return_value = None
result = extract_fields('test.pdf')
assert result == []
def test_extract_fields_returns_field_info(self):
"""Should return list of field info dicts"""
mock_fields = {
'txtName': {'/FT': '/Tx', '/V': 'John'},
'txtDate': {'/FT': '/Tx', '/V': '2025-01-28'}
}
with patch('fill_pdf.PdfReader') as mock_reader:
mock_reader.return_value.get_fields.return_value = mock_fields
result = extract_fields('test.pdf')
assert len(result) == 2
assert result[0]['field_id'] == 'txtName'
assert result[0]['type'] == '/Tx'
assert result[0]['value'] == 'John'
def test_extract_fields_handles_missing_type(self):
"""Should handle fields without /FT type"""
mock_fields = {
'field1': {'/V': 'value1'} # No /FT
}
with patch('fill_pdf.PdfReader') as mock_reader:
mock_reader.return_value.get_fields.return_value = mock_fields
result = extract_fields('test.pdf')
assert result[0]['type'] == ''
def test_extract_fields_handles_missing_value(self):
"""Should handle fields without /V value"""
mock_fields = {
'field1': {'/FT': '/Tx'} # No /V
}
with patch('fill_pdf.PdfReader') as mock_reader:
mock_reader.return_value.get_fields.return_value = mock_fields
result = extract_fields('test.pdf')
assert result[0]['value'] == ''
def test_extract_fields_raises_on_invalid_pdf(self):
"""Should raise exception for invalid PDF file"""
with patch('fill_pdf.PdfReader') as mock_reader:
mock_reader.side_effect = Exception('Invalid PDF')
with pytest.raises(Exception, match='Invalid PDF'):
extract_fields('invalid.pdf')
class TestFillPdf:
"""Tests for the fill_pdf function"""
def test_fill_pdf_writes_output_file(self):
"""Should create output PDF file"""
with patch('fill_pdf.PdfReader') as mock_reader, \
patch('fill_pdf.PdfWriter') as mock_writer, \
patch('builtins.open', mock_open()) as mock_file:
mock_writer_instance = MagicMock()
mock_writer.return_value = mock_writer_instance
mock_writer_instance.pages = [MagicMock()]
fill_pdf('input.pdf', {'field1': 'value1'}, 'output.pdf')
mock_file.assert_called_once_with('output.pdf', 'wb')
mock_writer_instance.write.assert_called_once()
def test_fill_pdf_appends_reader_to_writer(self):
"""Should append input PDF to writer"""
with patch('fill_pdf.PdfReader') as mock_reader, \
patch('fill_pdf.PdfWriter') as mock_writer, \
patch('builtins.open', mock_open()):
mock_reader_instance = MagicMock()
mock_reader.return_value = mock_reader_instance
mock_writer_instance = MagicMock()
mock_writer.return_value = mock_writer_instance
mock_writer_instance.pages = [MagicMock()]
fill_pdf('input.pdf', {}, 'output.pdf')
mock_writer_instance.append.assert_called_once_with(mock_reader_instance)
def test_fill_pdf_updates_all_pages(self):
"""Should update form fields on all pages"""
with patch('fill_pdf.PdfReader'), \
patch('fill_pdf.PdfWriter') as mock_writer, \
patch('builtins.open', mock_open()):
mock_writer_instance = MagicMock()
mock_writer.return_value = mock_writer_instance
# Simulate 3 pages
mock_pages = [MagicMock(), MagicMock(), MagicMock()]
mock_writer_instance.pages = mock_pages
field_values = {'field1': 'value1'}
fill_pdf('input.pdf', field_values, 'output.pdf')
assert mock_writer_instance.update_page_form_field_values.call_count == 3
def test_fill_pdf_passes_field_values(self):
"""Should pass correct field values to update method"""
with patch('fill_pdf.PdfReader'), \
patch('fill_pdf.PdfWriter') as mock_writer, \
patch('builtins.open', mock_open()):
mock_writer_instance = MagicMock()
mock_writer.return_value = mock_writer_instance
mock_page = MagicMock()
mock_writer_instance.pages = [mock_page]
field_values = {'txtName': 'John Doe', 'txtDate': '2025-01-28'}
fill_pdf('input.pdf', field_values, 'output.pdf')
mock_writer_instance.update_page_form_field_values.assert_called_with(
mock_page,
field_values
)
class TestMain:
"""Tests for the main CLI function"""
def test_main_extraction_mode(self, capsys):
"""Should extract and print fields in --extract mode"""
test_fields = [{'field_id': 'test', 'type': '/Tx', 'value': ''}]
with patch.object(sys, 'argv', ['fill_pdf.py', '--extract', 'input.pdf']), \
patch('fill_pdf.extract_fields', return_value=test_fields) as mock_extract:
main()
mock_extract.assert_called_once_with('input.pdf')
captured = capsys.readouterr()
output = json.loads(captured.out)
assert output == test_fields
def test_main_fill_mode_with_dict_json(self, capsys):
"""Should fill PDF with dict-format JSON"""
json_data = {'field1': 'value1', 'field2': 'value2'}
with patch.object(sys, 'argv', ['fill_pdf.py', 'in.pdf', 'values.json', 'out.pdf']), \
patch('builtins.open', mock_open(read_data=json.dumps(json_data))), \
patch('fill_pdf.fill_pdf') as mock_fill:
main()
mock_fill.assert_called_once_with('in.pdf', json_data, 'out.pdf')
captured = capsys.readouterr()
assert 'erfolgreich' in captured.out
def test_main_fill_mode_with_list_json(self, capsys):
"""Should convert list-format JSON to dict and fill PDF"""
json_list = [
{'field_id': 'field1', 'value': 'value1'},
{'field_id': 'field2', 'value': 'value2'}
]
expected_dict = {'field1': 'value1', 'field2': 'value2'}
with patch.object(sys, 'argv', ['fill_pdf.py', 'in.pdf', 'values.json', 'out.pdf']), \
patch('builtins.open', mock_open(read_data=json.dumps(json_list))), \
patch('fill_pdf.fill_pdf') as mock_fill:
main()
mock_fill.assert_called_once_with('in.pdf', expected_dict, 'out.pdf')
def test_main_shows_usage_on_wrong_args(self, capsys):
"""Should print usage and exit with code 1 on wrong arguments"""
with patch.object(sys, 'argv', ['fill_pdf.py', 'only_one_arg']):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert 'Usage:' in captured.out
def test_main_shows_usage_on_no_args(self, capsys):
"""Should print usage when no arguments provided"""
with patch.object(sys, 'argv', ['fill_pdf.py']):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
class TestIntegration:
"""Integration tests using real temporary files"""
def test_fill_pdf_with_real_temporary_files(self):
"""Integration test with actual file operations"""
# This test requires pypdf to be installed
# Skip if not available
pytest.importorskip('pypdf')
from pypdf import PdfWriter
# Create a simple PDF with form fields
with tempfile.TemporaryDirectory() as tmpdir:
input_path = os.path.join(tmpdir, 'input.pdf')
output_path = os.path.join(tmpdir, 'output.pdf')
# Create minimal test PDF
writer = PdfWriter()
writer.add_blank_page(width=612, height=792)
with open(input_path, 'wb') as f:
writer.write(f)
# PDFs without AcroForm will raise an error when trying to fill
# This is expected behavior from pypdf
from pypdf.errors import PyPdfError
with pytest.raises(PyPdfError):
fill_pdf(input_path, {}, output_path)
def test_extract_fields_with_real_pdf(self):
"""Integration test for field extraction"""
pytest.importorskip('pypdf')
from pypdf import PdfWriter
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, 'test.pdf')
# Create PDF without form fields
writer = PdfWriter()
writer.add_blank_page(width=612, height=792)
with open(pdf_path, 'wb') as f:
writer.write(f)
result = extract_fields(pdf_path)
assert result == []
class TestEdgeCases:
"""Edge case tests"""
def test_extract_fields_with_empty_string_value(self):
"""Should handle fields with empty string values"""
mock_fields = {
'emptyField': {'/FT': '/Tx', '/V': ''}
}
with patch('fill_pdf.PdfReader') as mock_reader:
mock_reader.return_value.get_fields.return_value = mock_fields
result = extract_fields('test.pdf')
assert result[0]['value'] == ''
def test_fill_pdf_with_empty_dict(self):
"""Should handle empty field values dict"""
with patch('fill_pdf.PdfReader'), \
patch('fill_pdf.PdfWriter') as mock_writer, \
patch('builtins.open', mock_open()):
mock_writer_instance = MagicMock()
mock_writer.return_value = mock_writer_instance
mock_writer_instance.pages = [MagicMock()]
# Should not raise
fill_pdf('input.pdf', {}, 'output.pdf')
mock_writer_instance.update_page_form_field_values.assert_called_once()
def test_main_with_unicode_filename(self, capsys):
"""Should handle unicode characters in filenames"""
with patch.object(sys, 'argv', ['fill_pdf.py', '--extract', 'über.pdf']), \
patch('fill_pdf.extract_fields', return_value=[]) as mock_extract:
main()
mock_extract.assert_called_once_with('über.pdf')
def test_fill_pdf_with_special_characters_in_values(self):
"""Should handle special characters in field values"""
with patch('fill_pdf.PdfReader'), \
patch('fill_pdf.PdfWriter') as mock_writer, \
patch('builtins.open', mock_open()):
mock_writer_instance = MagicMock()
mock_writer.return_value = mock_writer_instance
mock_writer_instance.pages = [MagicMock()]
special_values = {
'field1': 'Müller, François & José',
'field2': '日本語テスト',
'field3': '<script>alert("xss")</script>'
}
# Should not raise
fill_pdf('input.pdf', special_values, 'output.pdf')
def test_main_with_json_encoding_utf8(self, capsys):
"""Should handle UTF-8 encoded JSON files"""
json_data = {'name': 'Müller', 'city': '東京'}
with patch.object(sys, 'argv', ['fill_pdf.py', 'in.pdf', 'values.json', 'out.pdf']), \
patch('builtins.open', mock_open(read_data=json.dumps(json_data, ensure_ascii=False))), \
patch('fill_pdf.fill_pdf') as mock_fill:
main()
mock_fill.assert_called_once()
call_args = mock_fill.call_args[0]
assert call_args[1]['name'] == 'Müller'
assert call_args[1]['city'] == '東京'

View file

@ -1,489 +0,0 @@
#!/usr/bin/env python3
"""
Tests for latex_service.py - LaTeX Form Generation Service
Run with: pytest tests/latex_service_test.py -v
"""
import json
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch, mock_open
from io import StringIO
import pytest
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from latex_service import (
escape_latex,
checkbox,
format_date,
load_template,
fill_template,
compile_latex,
generate_form,
list_templates,
TEMPLATE_DIR,
)
class TestEscapeLatex:
"""Tests for the escape_latex function"""
def test_escape_empty_string(self):
"""Should return empty string for empty input"""
assert escape_latex('') == ''
def test_escape_none(self):
"""Should return empty string for None input"""
assert escape_latex(None) == ''
def test_escape_ampersand(self):
"""Should escape ampersand"""
assert escape_latex('Tom & Jerry') == r'Tom \& Jerry'
def test_escape_percent(self):
"""Should escape percent sign"""
assert escape_latex('100%') == r'100\%'
def test_escape_dollar(self):
"""Should escape dollar sign"""
assert escape_latex('$100') == r'\$100'
def test_escape_hash(self):
"""Should escape hash sign"""
assert escape_latex('#1') == r'\#1'
def test_escape_underscore(self):
"""Should escape underscore"""
assert escape_latex('file_name') == r'file\_name'
def test_escape_braces(self):
"""Should escape curly braces"""
assert escape_latex('{test}') == r'\{test\}'
def test_escape_backslash(self):
"""Should escape backslash"""
result = escape_latex('path\\file')
assert 'textbackslash' in result
def test_escape_tilde(self):
"""Should escape tilde"""
result = escape_latex('~test')
assert 'textasciitilde' in result
def test_escape_caret(self):
"""Should escape caret"""
result = escape_latex('^2')
assert 'textasciicircum' in result
def test_escape_multiple_special_chars(self):
"""Should escape multiple special characters"""
result = escape_latex('Test & 100% $50')
assert r'\&' in result
assert r'\%' in result
assert r'\$' in result
def test_no_escape_regular_text(self):
"""Should not modify regular text"""
assert escape_latex('Hello World') == 'Hello World'
def test_escape_german_umlauts(self):
"""Should preserve German umlauts (they don't need escaping in UTF-8 LaTeX)"""
assert escape_latex('Müller') == 'Müller'
class TestCheckbox:
"""Tests for the checkbox function"""
def test_checkbox_empty_returns_unchecked(self):
"""Should return unchecked box for empty value"""
assert checkbox('') == r'$\square$'
def test_checkbox_none_returns_unchecked(self):
"""Should return unchecked box for None value"""
assert checkbox(None) == r'$\square$'
def test_checkbox_true_returns_checked(self):
"""Should return checked box for 'true'"""
assert checkbox('true') == r'$\boxtimes$'
def test_checkbox_yes_returns_checked(self):
"""Should return checked box for 'yes'"""
assert checkbox('yes') == r'$\boxtimes$'
def test_checkbox_ja_returns_checked(self):
"""Should return checked box for 'ja' (German yes)"""
assert checkbox('ja') == r'$\boxtimes$'
def test_checkbox_x_returns_checked(self):
"""Should return checked box for 'x'"""
assert checkbox('x') == r'$\boxtimes$'
assert checkbox('X') == r'$\boxtimes$'
def test_checkbox_1_returns_checked(self):
"""Should return checked box for '1'"""
assert checkbox('1') == r'$\boxtimes$'
def test_checkbox_checked_returns_checked(self):
"""Should return checked box for 'checked'"""
assert checkbox('checked') == r'$\boxtimes$'
def test_checkbox_case_insensitive(self):
"""Should be case insensitive"""
assert checkbox('TRUE') == r'$\boxtimes$'
assert checkbox('Yes') == r'$\boxtimes$'
assert checkbox('JA') == r'$\boxtimes$'
def test_checkbox_with_whitespace(self):
"""Should handle whitespace"""
assert checkbox(' true ') == r'$\boxtimes$'
assert checkbox(' ') == r'$\square$'
def test_checkbox_false_returns_unchecked(self):
"""Should return unchecked box for 'false' and other values"""
assert checkbox('false') == r'$\square$'
assert checkbox('no') == r'$\square$'
assert checkbox('random') == r'$\square$'
class TestFormatDate:
"""Tests for the format_date function"""
def test_format_date_empty(self):
"""Should return empty string for empty input"""
assert format_date('') == ''
def test_format_date_none(self):
"""Should return empty string for None input"""
assert format_date(None) == ''
def test_format_date_already_correct(self):
"""Should pass through correctly formatted dates"""
assert format_date('28.01.2025') == '28.01.2025'
def test_format_date_iso_format(self):
"""Should convert ISO format to German format"""
assert format_date('2025-01-28') == '28.01.2025'
def test_format_date_us_format(self):
"""Should convert US format to German format"""
assert format_date('01/28/2025') == '28.01.2025'
def test_format_date_european_slash(self):
"""Should convert European slash format to German format"""
assert format_date('28/01/2025') == '28.01.2025'
def test_format_date_unknown_format(self):
"""Should return escaped input for unknown formats"""
result = format_date('January 28, 2025')
assert 'January' in result
def test_format_date_escapes_special_chars(self):
"""Should escape special characters in unrecognized formats"""
result = format_date('28.01.2025 & more')
# The already correct format part should work
assert '28.01.2025' in result
class TestLoadTemplate:
"""Tests for the load_template function"""
def test_load_template_success(self):
"""Should load template content from file"""
mock_content = r'\documentclass{article}'
with patch.object(Path, 'exists', return_value=True), \
patch.object(Path, 'read_text', return_value=mock_content):
result = load_template('test')
assert result == mock_content
def test_load_template_not_found(self):
"""Should raise FileNotFoundError for missing template"""
with patch.object(Path, 'exists', return_value=False):
with pytest.raises(FileNotFoundError, match='Template not found'):
load_template('nonexistent')
class TestFillTemplate:
"""Tests for the fill_template function"""
def test_fill_template_basic(self):
"""Should replace basic placeholders"""
template = 'Hello {{name}}!'
result = fill_template(template, {'name': 'World'})
assert result == 'Hello World!'
def test_fill_template_escapes_by_default(self):
"""Should escape special characters by default"""
template = '{{text}}'
result = fill_template(template, {'text': 'Test & Value'})
assert r'\&' in result
def test_fill_template_raw_modifier(self):
"""Should not escape with |raw modifier"""
template = '{{text|raw}}'
result = fill_template(template, {'text': 'Test & Value'})
assert result == 'Test & Value'
def test_fill_template_checkbox_modifier(self):
"""Should convert to checkbox with |checkbox modifier"""
template = '{{checked|checkbox}}'
result = fill_template(template, {'checked': 'true'})
assert result == r'$\boxtimes$'
def test_fill_template_date_modifier(self):
"""Should format date with |date modifier"""
template = '{{date|date}}'
result = fill_template(template, {'date': '2025-01-28'})
assert result == '28.01.2025'
def test_fill_template_multiple_fields(self):
"""Should replace multiple fields"""
template = '{{first}} {{last}}'
result = fill_template(template, {'first': 'John', 'last': 'Doe'})
assert result == 'John Doe'
def test_fill_template_removes_unfilled_placeholders(self):
"""Should remove placeholders without values"""
template = 'Hello {{name}} {{missing}}!'
result = fill_template(template, {'name': 'World'})
assert result == 'Hello World !'
assert '{{' not in result
def test_fill_template_handles_none_values(self):
"""Should handle None values"""
template = '{{field}}'
result = fill_template(template, {'field': None})
assert result == ''
def test_fill_template_handles_numeric_values(self):
"""Should handle numeric values"""
template = '{{number}}'
result = fill_template(template, {'number': 42})
assert result == '42'
class TestCompileLatex:
"""Tests for the compile_latex function"""
def test_compile_latex_success(self):
"""Should compile LaTeX and return PDF bytes"""
mock_pdf_content = b'%PDF-1.4 test content'
with patch('latex_service.subprocess.run') as mock_run, \
patch('latex_service.tempfile.TemporaryDirectory') as mock_tmpdir:
# Setup mocks
mock_run.return_value = MagicMock(returncode=0)
mock_tmpdir.return_value.__enter__ = MagicMock(return_value='/tmp/test')
mock_tmpdir.return_value.__exit__ = MagicMock(return_value=False)
with patch.object(Path, 'write_text'), \
patch.object(Path, 'exists', return_value=True), \
patch.object(Path, 'read_bytes', return_value=mock_pdf_content):
result = compile_latex(r'\documentclass{article}\begin{document}Test\end{document}')
assert result == mock_pdf_content
def test_compile_latex_runs_pdflatex_twice(self):
"""Should run pdflatex twice for references"""
with patch('latex_service.subprocess.run') as mock_run, \
patch('latex_service.tempfile.TemporaryDirectory') as mock_tmpdir:
mock_run.return_value = MagicMock(returncode=0)
mock_tmpdir.return_value.__enter__ = MagicMock(return_value='/tmp/test')
mock_tmpdir.return_value.__exit__ = MagicMock(return_value=False)
with patch.object(Path, 'write_text'), \
patch.object(Path, 'exists', return_value=True), \
patch.object(Path, 'read_bytes', return_value=b'pdf'):
compile_latex(r'\documentclass{article}')
assert mock_run.call_count == 2
def test_compile_latex_failure_raises_error(self):
"""Should raise RuntimeError on compilation failure"""
with patch('latex_service.subprocess.run') as mock_run, \
patch('latex_service.tempfile.TemporaryDirectory') as mock_tmpdir:
mock_run.return_value = MagicMock(returncode=1, stderr='Error message')
mock_tmpdir.return_value.__enter__ = MagicMock(return_value='/tmp/test')
mock_tmpdir.return_value.__exit__ = MagicMock(return_value=False)
with patch.object(Path, 'write_text'), \
patch.object(Path, 'exists', return_value=False):
with pytest.raises(RuntimeError, match='compilation failed'):
compile_latex(r'\documentclass{article}')
def test_compile_latex_no_pdf_raises_error(self):
"""Should raise RuntimeError if PDF is not created"""
with patch('latex_service.subprocess.run') as mock_run, \
patch('latex_service.tempfile.TemporaryDirectory') as mock_tmpdir:
mock_run.return_value = MagicMock(returncode=0)
mock_tmpdir.return_value.__enter__ = MagicMock(return_value='/tmp/test')
mock_tmpdir.return_value.__exit__ = MagicMock(return_value=False)
with patch.object(Path, 'write_text'), \
patch.object(Path, 'exists', return_value=False):
with pytest.raises(RuntimeError, match='PDF file was not created'):
compile_latex(r'\documentclass{article}')
class TestGenerateForm:
"""Tests for the generate_form function"""
def test_generate_form_success(self):
"""Should generate filled PDF from template and fields"""
mock_template = r'\documentclass{article}\begin{document}{{name}}\end{document}'
mock_pdf = b'%PDF-1.4 generated'
with patch('latex_service.load_template', return_value=mock_template), \
patch('latex_service.compile_latex', return_value=mock_pdf):
result = generate_form('test', {'name': 'John'})
assert result == mock_pdf
def test_generate_form_calls_fill_template(self):
"""Should fill template with provided fields"""
with patch('latex_service.load_template', return_value='{{field}}') as mock_load, \
patch('latex_service.fill_template', return_value='filled') as mock_fill, \
patch('latex_service.compile_latex', return_value=b'pdf'):
generate_form('test', {'field': 'value'})
mock_fill.assert_called_once()
assert mock_fill.call_args[0][1] == {'field': 'value'}
class TestListTemplates:
"""Tests for the list_templates function"""
def test_list_templates_returns_template_names(self):
"""Should return list of template names without extension"""
mock_files = [
MagicMock(stem='G2210-11'),
MagicMock(stem='S0051'),
]
with patch.object(Path, 'exists', return_value=True), \
patch.object(Path, 'glob', return_value=mock_files):
result = list_templates()
assert result == ['G2210-11', 'S0051']
def test_list_templates_empty_directory(self):
"""Should return empty list when no templates exist"""
with patch.object(Path, 'exists', return_value=True), \
patch.object(Path, 'glob', return_value=[]):
result = list_templates()
assert result == []
def test_list_templates_directory_not_exists(self):
"""Should return empty list when template directory doesn't exist"""
with patch.object(Path, 'exists', return_value=False):
result = list_templates()
assert result == []
class TestCLI:
"""Tests for the CLI interface"""
def test_cli_list_command(self, capsys):
"""Should list templates with 'list' command"""
with patch.object(sys, 'argv', ['latex_service.py', 'list']), \
patch('latex_service.list_templates', return_value=['G2210-11', 'S0051']):
# Import and run main
import latex_service
if hasattr(latex_service, '__name__') and latex_service.__name__ == '__main__':
pass # Skip if module guard prevents execution
else:
# Run the CLI code manually
from latex_service import list_templates
import argparse
templates = list_templates()
print(json.dumps(templates))
captured = capsys.readouterr()
# The actual test would need to run the CLI properly
def test_cli_preview_requires_args(self, capsys):
"""Should require --template and --fields for preview"""
with patch.object(sys, 'argv', ['latex_service.py', 'preview']):
with pytest.raises(SystemExit):
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('command', choices=['generate', 'list', 'preview'])
parser.add_argument('--template', '-t')
parser.add_argument('--fields', '-f')
args = parser.parse_args()
if args.command == 'preview' and (not args.template or not args.fields):
print("Error: --template and --fields required", file=sys.stderr)
sys.exit(1)
class TestIntegration:
"""Integration tests"""
def test_fill_template_integration(self):
"""Integration test for template filling"""
template = r'''
\documentclass{article}
\begin{document}
Name: {{name}}
Checked: {{active|checkbox}}
Date: {{date|date}}
\end{document}
'''
fields = {
'name': 'Test User',
'active': 'true',
'date': '2025-01-28'
}
result = fill_template(template, fields)
assert 'Test User' in result
assert r'$\boxtimes$' in result
assert '28.01.2025' in result
class TestEdgeCases:
"""Edge case tests"""
def test_escape_latex_with_all_special_chars(self):
"""Should handle string with all special characters"""
text = r'\ & % $ # _ { } ~ ^'
result = escape_latex(text)
# Verify escaping happened
assert '&' not in result or r'\&' in result
assert result != text
def test_fill_template_with_empty_fields(self):
"""Should handle empty fields dict"""
template = '{{field1}} {{field2}}'
result = fill_template(template, {})
assert result == ' ' # Placeholders removed
def test_checkbox_with_numeric_input(self):
"""Should handle numeric input as string"""
# The checkbox function expects strings, so pass '1' as string
assert checkbox('1') == r'$\boxtimes$' # String '1' is checked
assert checkbox('0') == r'$\square$' # String '0' is unchecked

View file

@ -1,467 +0,0 @@
#!/usr/bin/env python3
"""
Tests for server.py - Flask API for LaTeX Form Generation
Run with: pytest tests/server_test.py -v
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import base64
import pytest
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from server import app, normalize_label, map_fields_to_template, G2210_FIELD_MAPPING
@pytest.fixture
def client():
"""Create a test client for the Flask app"""
app.config['TESTING'] = True
with app.test_client() as client:
yield client
class TestNormalizeLabel:
"""Tests for the normalize_label function"""
def test_normalize_lowercase(self):
"""Should convert to lowercase"""
assert normalize_label('NAME') == 'name'
def test_normalize_strip(self):
"""Should strip whitespace"""
assert normalize_label(' name ') == 'name'
def test_normalize_remove_colon(self):
"""Should remove colons"""
assert normalize_label('Name:') == 'name'
def test_normalize_replace_underscore(self):
"""Should replace underscores with spaces"""
assert normalize_label('first_name') == 'first name'
def test_normalize_combined(self):
"""Should handle combined transformations"""
assert normalize_label(' First_Name: ') == 'first name'
class TestMapFieldsToTemplate:
"""Tests for the map_fields_to_template function"""
def test_map_direct_match(self):
"""Should map fields with direct label match"""
mapping = {
'template_field': ['label1', 'label2']
}
fields = [{'label': 'label1', 'value': 'test_value'}]
result = map_fields_to_template(fields, mapping)
assert result == {'template_field': 'test_value'}
def test_map_case_insensitive(self):
"""Should match labels case-insensitively"""
mapping = {
'name': ['name', 'vorname']
}
fields = [{'label': 'NAME', 'value': 'John'}]
result = map_fields_to_template(fields, mapping)
assert result == {'name': 'John'}
def test_map_fuzzy_match(self):
"""Should fuzzy match when label contains mapping label"""
mapping = {
'name': ['name']
}
fields = [{'label': 'Patient Name', 'value': 'John'}]
result = map_fields_to_template(fields, mapping)
assert result == {'name': 'John'}
def test_map_empty_fields(self):
"""Should return empty dict for empty fields"""
result = map_fields_to_template([], G2210_FIELD_MAPPING)
assert result == {}
def test_map_skip_empty_values(self):
"""Should skip fields with empty values"""
mapping = {'field': ['label']}
fields = [{'label': 'label', 'value': ''}]
result = map_fields_to_template(fields, mapping)
assert result == {}
def test_map_skip_missing_labels(self):
"""Should skip fields without labels"""
mapping = {'field': ['label']}
fields = [{'value': 'test'}]
result = map_fields_to_template(fields, mapping)
assert result == {}
def test_map_multiple_fields(self):
"""Should map multiple fields correctly"""
mapping = {
'name': ['name'],
'date': ['datum', 'date']
}
fields = [
{'label': 'Name', 'value': 'John'},
{'label': 'Datum', 'value': '2025-01-28'}
]
result = map_fields_to_template(fields, mapping)
assert result == {'name': 'John', 'date': '2025-01-28'}
class TestHealthEndpoint:
"""Tests for the /api/health endpoint"""
def test_health_returns_ok(self, client):
"""Should return ok status"""
response = client.get('/api/health')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'ok'
assert data['service'] == 'latex-form-generator'
class TestTemplatesEndpoint:
"""Tests for the /api/templates endpoint"""
def test_templates_returns_list(self, client):
"""Should return list of templates"""
with patch('server.list_templates', return_value=['G2210-11', 'S0051']):
response = client.get('/api/templates')
assert response.status_code == 200
data = json.loads(response.data)
assert data['templates'] == ['G2210-11', 'S0051']
def test_templates_returns_empty_list(self, client):
"""Should return empty list when no templates"""
with patch('server.list_templates', return_value=[]):
response = client.get('/api/templates')
assert response.status_code == 200
data = json.loads(response.data)
assert data['templates'] == []
class TestGenerateEndpoint:
"""Tests for the /api/generate endpoint"""
def test_generate_success(self, client):
"""Should generate PDF and return base64"""
mock_pdf = b'%PDF-1.4 test content'
with patch('server.generate_form', return_value=mock_pdf):
response = client.post(
'/api/generate',
json={
'template': 'G2210-11',
'fields': [{'label': 'Name', 'value': 'John'}]
}
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['success'] is True
assert data['pdf'] == base64.b64encode(mock_pdf).decode('ascii')
def test_generate_returns_mapped_fields(self, client):
"""Should return mapped fields in response"""
with patch('server.generate_form', return_value=b'pdf'):
response = client.post(
'/api/generate',
json={
'template': 'G2210-11',
'fields': [{'label': 'Name, Vorname', 'value': 'Müller, Hans'}]
}
)
data = json.loads(response.data)
assert 'mapped_fields' in data
def test_generate_no_json_returns_error(self, client):
"""Should return error for missing JSON"""
response = client.post('/api/generate')
# Server may return 400 or 500 depending on Flask version behavior
assert response.status_code in [400, 500]
data = json.loads(response.data)
assert 'error' in data
def test_generate_template_not_found_returns_404(self, client):
"""Should return 404 for missing template"""
with patch('server.generate_form', side_effect=FileNotFoundError('Template not found')):
response = client.post(
'/api/generate',
json={'template': 'nonexistent', 'fields': []}
)
assert response.status_code == 404
def test_generate_error_returns_500(self, client):
"""Should return 500 for generation errors"""
with patch('server.generate_form', side_effect=Exception('Compilation failed')):
response = client.post(
'/api/generate',
json={'template': 'G2210-11', 'fields': []}
)
assert response.status_code == 500
data = json.loads(response.data)
assert 'Compilation failed' in data['error']
def test_generate_default_template(self, client):
"""Should use G2210-11 as default template"""
with patch('server.generate_form', return_value=b'pdf') as mock_generate:
response = client.post(
'/api/generate',
json={'fields': []}
)
# Check that G2210-11 mapping was used (via generate_form call)
assert response.status_code == 200
def test_generate_file_format(self, client):
"""Should return file when format=file"""
mock_pdf = b'%PDF-1.4 test'
with patch('server.generate_form', return_value=mock_pdf):
response = client.post(
'/api/generate',
json={
'template': 'G2210-11',
'fields': [],
'format': 'file'
}
)
assert response.status_code == 200
assert response.content_type == 'application/pdf'
assert response.data == mock_pdf
class TestPreviewEndpoint:
"""Tests for the /api/preview endpoint"""
def test_preview_success(self, client):
"""Should return filled LaTeX source"""
mock_template = r'\documentclass{article}'
mock_filled = r'\documentclass{article}\n% filled'
with patch('server.load_template', return_value=mock_template), \
patch('server.fill_template', return_value=mock_filled):
response = client.post(
'/api/preview',
json={
'template': 'G2210-11',
'fields': []
}
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['success'] is True
assert data['latex'] == mock_filled
def test_preview_returns_mapped_fields(self, client):
"""Should return mapped fields"""
with patch('server.load_template', return_value=''), \
patch('server.fill_template', return_value=''):
response = client.post(
'/api/preview',
json={
'template': 'G2210-11',
'fields': [{'label': 'Geburtsdatum', 'value': '01.01.1990'}]
}
)
data = json.loads(response.data)
assert 'mapped_fields' in data
def test_preview_no_json_returns_error(self, client):
"""Should return error for missing JSON"""
response = client.post('/api/preview')
# Server may return 400 or 500 depending on Flask version behavior
assert response.status_code in [400, 500]
def test_preview_error_returns_500(self, client):
"""Should return 500 for errors"""
with patch('server.load_template', side_effect=Exception('Template error')):
response = client.post(
'/api/preview',
json={'template': 'G2210-11', 'fields': []}
)
assert response.status_code == 500
class TestFieldMappingEndpoint:
"""Tests for the /api/field-mapping/<template_name> endpoint"""
def test_field_mapping_g2210(self, client):
"""Should return field mapping for G2210-11"""
response = client.get('/api/field-mapping/G2210-11')
assert response.status_code == 200
data = json.loads(response.data)
assert data['template'] == 'G2210-11'
assert 'fields' in data
assert 'mapping' in data
assert 'versicherungsnummer' in data['fields']
def test_field_mapping_unknown_returns_404(self, client):
"""Should return 404 for unknown template"""
response = client.get('/api/field-mapping/unknown')
assert response.status_code == 404
data = json.loads(response.data)
assert 'error' in data
class TestG2210FieldMapping:
"""Tests for the G2210_FIELD_MAPPING constant"""
def test_mapping_has_patient_fields(self):
"""Should have patient data fields"""
assert 'versicherungsnummer' in G2210_FIELD_MAPPING
assert 'name_vorname' in G2210_FIELD_MAPPING
assert 'geburtsdatum' in G2210_FIELD_MAPPING
def test_mapping_has_diagnosis_fields(self):
"""Should have diagnosis fields"""
assert 'diagnose_1' in G2210_FIELD_MAPPING
assert 'diagnose_1_icd' in G2210_FIELD_MAPPING
def test_mapping_has_doctor_fields(self):
"""Should have doctor/signature fields"""
assert 'arzt_name' in G2210_FIELD_MAPPING
assert 'bsnr' in G2210_FIELD_MAPPING
assert 'lanr' in G2210_FIELD_MAPPING
def test_mapping_labels_are_lists(self):
"""All mapping values should be lists of possible labels"""
for key, value in G2210_FIELD_MAPPING.items():
assert isinstance(value, list), f'{key} should map to a list'
assert len(value) > 0, f'{key} should have at least one label'
class TestCORS:
"""Tests for CORS configuration"""
def test_cors_headers_present(self, client):
"""Should include CORS headers"""
response = client.get('/api/health')
# Flask-CORS adds these headers
# The exact headers depend on the request
assert response.status_code == 200
class TestIntegration:
"""Integration tests"""
def test_full_workflow(self, client):
"""Test complete workflow: health -> templates -> generate"""
# 1. Check health
health_response = client.get('/api/health')
assert health_response.status_code == 200
# 2. Get templates
with patch('server.list_templates', return_value=['G2210-11']):
templates_response = client.get('/api/templates')
assert templates_response.status_code == 200
# 3. Generate PDF
with patch('server.generate_form', return_value=b'%PDF-1.4'):
generate_response = client.post(
'/api/generate',
json={
'template': 'G2210-11',
'fields': [
{'label': 'Name, Vorname', 'value': 'Test, User'},
{'label': 'Geburtsdatum', 'value': '01.01.1990'}
]
}
)
assert generate_response.status_code == 200
data = json.loads(generate_response.data)
assert data['success'] is True
class TestEdgeCases:
"""Edge case tests"""
def test_generate_with_empty_fields_list(self, client):
"""Should handle empty fields list"""
with patch('server.generate_form', return_value=b'pdf'):
response = client.post(
'/api/generate',
json={'template': 'G2210-11', 'fields': []}
)
assert response.status_code == 200
def test_generate_with_unicode_values(self, client):
"""Should handle Unicode values"""
with patch('server.generate_form', return_value=b'pdf'):
response = client.post(
'/api/generate',
json={
'template': 'G2210-11',
'fields': [
{'label': 'Name', 'value': 'Müller'},
{'label': 'Stadt', 'value': '東京'}
]
}
)
assert response.status_code == 200
def test_generate_with_special_characters(self, client):
"""Should handle special characters in values"""
with patch('server.generate_form', return_value=b'pdf'):
response = client.post(
'/api/generate',
json={
'template': 'G2210-11',
'fields': [
{'label': 'Notes', 'value': 'Test & notes with $pecial chars'}
]
}
)
assert response.status_code == 200
def test_map_fields_with_unknown_template(self, client):
"""Should handle unknown template with direct field mapping"""
with patch('server.generate_form', return_value=b'pdf'):
response = client.post(
'/api/generate',
json={
'template': 'custom-template',
'fields': [
{'label': 'custom_field', 'value': 'custom_value'}
]
}
)
# Should still work, just with direct mapping
assert response.status_code in [200, 404, 500] # Depends on template existence

View file

@ -1,125 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { getApiKey, setApiKey, clearApiKey, hasApiKey } from '../../services/apiKeyService';
describe('apiKeyService', () => {
const STORAGE_KEY = 'gemini_api_key';
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
describe('getApiKey', () => {
it('should return null when no key is stored', () => {
const result = getApiKey();
expect(result).toBeNull();
});
it('should return the stored API key', () => {
localStorage.setItem(STORAGE_KEY, 'AItest123');
const result = getApiKey();
expect(result).toBe('AItest123');
});
it('should return empty string if empty string was stored', () => {
localStorage.setItem(STORAGE_KEY, '');
const result = getApiKey();
expect(result).toBe('');
});
});
describe('setApiKey', () => {
it('should store the API key in localStorage', () => {
setApiKey('AItest456');
expect(localStorage.getItem(STORAGE_KEY)).toBe('AItest456');
});
it('should overwrite existing key', () => {
localStorage.setItem(STORAGE_KEY, 'AIold');
setApiKey('AInew');
expect(localStorage.getItem(STORAGE_KEY)).toBe('AInew');
});
it('should allow storing empty string', () => {
setApiKey('');
expect(localStorage.getItem(STORAGE_KEY)).toBe('');
});
});
describe('clearApiKey', () => {
it('should remove the API key from localStorage', () => {
localStorage.setItem(STORAGE_KEY, 'AItest');
clearApiKey();
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('should not throw when no key exists', () => {
expect(() => clearApiKey()).not.toThrow();
});
});
describe('hasApiKey', () => {
it('should return false when no key is stored', () => {
const result = hasApiKey();
expect(result).toBe(false);
});
it('should return false when empty string is stored', () => {
localStorage.setItem(STORAGE_KEY, '');
const result = hasApiKey();
expect(result).toBe(false);
});
it('should return true when a non-empty key is stored', () => {
localStorage.setItem(STORAGE_KEY, 'AItest789');
const result = hasApiKey();
expect(result).toBe(true);
});
it('should return true for single character key', () => {
localStorage.setItem(STORAGE_KEY, 'A');
const result = hasApiKey();
expect(result).toBe(true);
});
});
describe('integration', () => {
it('should work correctly with set and get', () => {
setApiKey('AIintegration');
expect(getApiKey()).toBe('AIintegration');
expect(hasApiKey()).toBe(true);
});
it('should work correctly with set, clear, and get', () => {
setApiKey('AItest');
expect(hasApiKey()).toBe(true);
clearApiKey();
expect(getApiKey()).toBeNull();
expect(hasApiKey()).toBe(false);
});
});
});

View file

@ -1,320 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { FileData, FormResponse } from '../../types';
import { PdfFieldInfo } from '../../services/pdfService';
// Mock the apiKeyService module - MUST be before geminiService import
vi.mock('../../services/apiKeyService', () => ({
getApiKey: vi.fn(() => 'AItest123'),
setApiKey: vi.fn(),
hasApiKey: vi.fn(() => true),
clearApiKey: vi.fn(),
}));
// Create mock function in module scope that will be hoisted properly
const mockGenerateContent = vi.fn();
// Mock the @google/genai module using factory that references mockGenerateContent
vi.mock('@google/genai', () => {
return {
GoogleGenAI: class MockGoogleGenAI {
models = {
generateContent: mockGenerateContent
};
},
Type: {
OBJECT: 'OBJECT',
STRING: 'STRING',
ARRAY: 'ARRAY',
INTEGER: 'INTEGER'
},
Schema: {}
};
});
// Import after mocking
import { processDocuments } from '../../services/geminiService';
describe('geminiService', () => {
const mockBlankForm: FileData = {
file: new File([''], 'form.pdf', { type: 'application/pdf' }),
previewUrl: null,
base64: 'base64FormContent',
type: 'application/pdf'
};
const mockSourceDocument: FileData = {
file: new File([''], 'source.pdf', { type: 'application/pdf' }),
previewUrl: null,
base64: 'base64SourceContent',
type: 'application/pdf'
};
const mockPdfFields: PdfFieldInfo[] = [
{ name: 'firstName', type: 'PDFTextField' },
{ name: 'lastName', type: 'PDFTextField' }
];
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('processDocuments', () => {
it('should successfully process documents and return FormResponse', async () => {
const mockResponse: FormResponse = {
summary: 'Processed medical letter',
fields: [
{
key: 'firstName',
label: 'First Name',
value: 'John',
validation: { status: 'VALID' }
},
{
key: 'lastName',
label: 'Last Name',
value: 'Doe',
validation: { status: 'VALID' }
}
]
};
mockGenerateContent.mockResolvedValue({
text: JSON.stringify(mockResponse)
});
const result = await processDocuments(mockBlankForm, mockSourceDocument, mockPdfFields);
expect(result).toEqual(mockResponse);
expect(mockGenerateContent).toHaveBeenCalledOnce();
});
it('should include field names in prompt when pdfFields are provided', async () => {
const mockResponse: FormResponse = {
summary: 'Test',
fields: []
};
mockGenerateContent.mockResolvedValue({
text: JSON.stringify(mockResponse)
});
await processDocuments(mockBlankForm, mockSourceDocument, mockPdfFields);
const callArgs = mockGenerateContent.mock.calls[0][0];
expect(callArgs.config.systemInstruction).toContain('FILLABLE PDF');
expect(callArgs.config.systemInstruction).toContain('firstName');
expect(callArgs.config.systemInstruction).toContain('lastName');
});
it('should use visual mode when no pdfFields are provided', async () => {
const mockResponse: FormResponse = {
summary: 'Test',
fields: []
};
mockGenerateContent.mockResolvedValue({
text: JSON.stringify(mockResponse)
});
await processDocuments(mockBlankForm, mockSourceDocument, []);
const callArgs = mockGenerateContent.mock.calls[0][0];
expect(callArgs.config.systemInstruction).toContain('VISUAL FILLING');
expect(callArgs.config.systemInstruction).toContain('0 to 1000');
});
it('should throw error when API returns no response', async () => {
mockGenerateContent.mockResolvedValue({
text: null
});
await expect(
processDocuments(mockBlankForm, mockSourceDocument, mockPdfFields)
).rejects.toThrow('No response from Gemini');
});
it('should throw error when API returns empty string', async () => {
mockGenerateContent.mockResolvedValue({
text: ''
});
await expect(
processDocuments(mockBlankForm, mockSourceDocument, mockPdfFields)
).rejects.toThrow();
});
it('should throw error on API failure', async () => {
const apiError = new Error('API rate limit exceeded');
mockGenerateContent.mockRejectedValue(apiError);
await expect(
processDocuments(mockBlankForm, mockSourceDocument, mockPdfFields)
).rejects.toThrow('API rate limit exceeded');
});
it('should throw error on invalid JSON response', async () => {
mockGenerateContent.mockResolvedValue({
text: 'not valid json'
});
await expect(
processDocuments(mockBlankForm, mockSourceDocument, mockPdfFields)
).rejects.toThrow();
});
it('should handle fields with validation warnings', async () => {
const mockResponse: FormResponse = {
summary: 'Processed with warnings',
fields: [
{
key: 'date',
label: 'Date',
value: '28-01-2025',
validation: {
status: 'WARNING',
message: 'Date format might be incorrect',
suggestion: '28.01.2025'
}
}
]
};
mockGenerateContent.mockResolvedValue({
text: JSON.stringify(mockResponse)
});
const result = await processDocuments(mockBlankForm, mockSourceDocument, mockPdfFields);
expect(result.fields[0].validation?.status).toBe('WARNING');
expect(result.fields[0].validation?.suggestion).toBe('28.01.2025');
});
it('should handle fields with validation errors', async () => {
const mockResponse: FormResponse = {
summary: 'Processed with errors',
fields: [
{
key: 'required_field',
label: 'Required Field',
value: '',
validation: {
status: 'INVALID',
message: 'This field is required but was not found in source'
}
}
]
};
mockGenerateContent.mockResolvedValue({
text: JSON.stringify(mockResponse)
});
const result = await processDocuments(mockBlankForm, mockSourceDocument, mockPdfFields);
expect(result.fields[0].validation?.status).toBe('INVALID');
});
it('should handle visual mode with coordinates', async () => {
const mockResponse: FormResponse = {
summary: 'Visual mode response',
fields: [
{
label: 'Name',
value: 'John Doe',
validation: { status: 'VALID' },
coordinates: {
pageIndex: 0,
x: 150,
y: 200
}
}
]
};
mockGenerateContent.mockResolvedValue({
text: JSON.stringify(mockResponse)
});
const result = await processDocuments(mockBlankForm, mockSourceDocument, []);
expect(result.fields[0].coordinates).toEqual({
pageIndex: 0,
x: 150,
y: 200
});
});
it('should include sourceContext in response', async () => {
const mockResponse: FormResponse = {
summary: 'Test',
fields: [
{
key: 'name',
label: 'Name',
value: 'John Doe',
sourceContext: 'Patient: John Doe, DOB: 01.01.1990',
validation: { status: 'VALID' }
}
]
};
mockGenerateContent.mockResolvedValue({
text: JSON.stringify(mockResponse)
});
const result = await processDocuments(mockBlankForm, mockSourceDocument, mockPdfFields);
expect(result.fields[0].sourceContext).toBe('Patient: John Doe, DOB: 01.01.1990');
});
it('should pass correct MIME types in request', async () => {
const mockResponse: FormResponse = {
summary: 'Test',
fields: []
};
mockGenerateContent.mockResolvedValue({
text: JSON.stringify(mockResponse)
});
const imageSource: FileData = {
...mockSourceDocument,
type: 'image/png'
};
await processDocuments(mockBlankForm, imageSource, []);
const callArgs = mockGenerateContent.mock.calls[0][0];
expect(callArgs.contents.parts[0].inlineData.mimeType).toBe('application/pdf');
expect(callArgs.contents.parts[2].inlineData.mimeType).toBe('image/png');
});
it('should handle network timeout', async () => {
mockGenerateContent.mockRejectedValue(new Error('Network timeout'));
await expect(
processDocuments(mockBlankForm, mockSourceDocument, mockPdfFields)
).rejects.toThrow('Network timeout');
});
it('should handle empty fields array response', async () => {
const mockResponse: FormResponse = {
summary: 'No fields found',
fields: []
};
mockGenerateContent.mockResolvedValue({
text: JSON.stringify(mockResponse)
});
const result = await processDocuments(mockBlankForm, mockSourceDocument, mockPdfFields);
expect(result.fields).toEqual([]);
expect(result.summary).toBe('No fields found');
});
});
});

View file

@ -1,393 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
isLatexServiceAvailable,
getAvailableTemplates,
getTemplateFieldMapping,
generateLatexPdf,
previewLatexSource,
base64ToBlob,
detectTemplate,
getExpectedFields,
} from '../../services/latexService';
import { ExtractedField } from '../../types';
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('latexService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('isLatexServiceAvailable', () => {
it('should return true when health endpoint responds with ok', async () => {
mockFetch.mockResolvedValue({ ok: true });
const result = await isLatexServiceAvailable();
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/health'),
expect.objectContaining({ method: 'GET' })
);
});
it('should return false when health endpoint returns error', async () => {
mockFetch.mockResolvedValue({ ok: false });
const result = await isLatexServiceAvailable();
expect(result).toBe(false);
});
it('should return false when fetch throws error', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
const result = await isLatexServiceAvailable();
expect(result).toBe(false);
});
it('should use timeout signal', async () => {
mockFetch.mockResolvedValue({ ok: true });
await isLatexServiceAvailable();
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
signal: expect.any(AbortSignal),
})
);
});
});
describe('getAvailableTemplates', () => {
it('should return templates array on success', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ templates: ['G2210-11', 'S0051'] }),
});
const result = await getAvailableTemplates();
expect(result).toEqual(['G2210-11', 'S0051']);
});
it('should return empty array when response has no templates', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});
const result = await getAvailableTemplates();
expect(result).toEqual([]);
});
it('should return empty array on fetch error', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
const result = await getAvailableTemplates();
expect(result).toEqual([]);
});
it('should return empty array on non-ok response', async () => {
mockFetch.mockResolvedValue({ ok: false });
const result = await getAvailableTemplates();
expect(result).toEqual([]);
});
});
describe('getTemplateFieldMapping', () => {
it('should return mapping on success', async () => {
const mockMapping = { field1: ['alias1', 'alias2'] };
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ mapping: mockMapping }),
});
const result = await getTemplateFieldMapping('G2210-11');
expect(result).toEqual(mockMapping);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/field-mapping/G2210-11')
);
});
it('should return null when response has no mapping', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});
const result = await getTemplateFieldMapping('G2210-11');
expect(result).toBeNull();
});
it('should return null on non-ok response', async () => {
mockFetch.mockResolvedValue({ ok: false });
const result = await getTemplateFieldMapping('unknown');
expect(result).toBeNull();
});
it('should return null on fetch error', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
const result = await getTemplateFieldMapping('G2210-11');
expect(result).toBeNull();
});
});
describe('generateLatexPdf', () => {
const mockFields: ExtractedField[] = [
{ label: 'Name', value: 'John Doe', key: 'name' },
{ label: 'Date', value: '2025-01-28', key: 'date' },
];
it('should return success result with PDF on success', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
success: true,
pdf: 'base64pdfcontent',
mapped_fields: { name: 'John Doe' },
}),
});
const result = await generateLatexPdf('G2210-11', mockFields);
expect(result.success).toBe(true);
expect(result.pdf).toBe('base64pdfcontent');
expect(result.mappedFields).toEqual({ name: 'John Doe' });
});
it('should send correct request body', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
});
await generateLatexPdf('G2210-11', mockFields);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/generate'),
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: expect.stringContaining('"template":"G2210-11"'),
})
);
});
it('should map fields correctly in request', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
});
await generateLatexPdf('G2210-11', mockFields);
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.fields).toEqual([
{ label: 'Name', value: 'John Doe', key: 'name' },
{ label: 'Date', value: '2025-01-28', key: 'date' },
]);
expect(callBody.format).toBe('base64');
});
it('should return error result on non-ok response', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ error: 'Template not found' }),
});
const result = await generateLatexPdf('unknown', mockFields);
expect(result.success).toBe(false);
expect(result.error).toBe('Template not found');
});
it('should return error result with HTTP status when no error message', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
json: () => Promise.reject(new Error('Invalid JSON')),
});
const result = await generateLatexPdf('unknown', mockFields);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
it('should return error result on fetch error', async () => {
mockFetch.mockRejectedValue(new Error('Network timeout'));
const result = await generateLatexPdf('G2210-11', mockFields);
expect(result.success).toBe(false);
expect(result.error).toBe('Network timeout');
});
});
describe('previewLatexSource', () => {
const mockFields: ExtractedField[] = [
{ label: 'Name', value: 'Test' },
];
it('should return latex source on success', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ latex: '\\documentclass{article}' }),
});
const result = await previewLatexSource('G2210-11', mockFields);
expect(result.latex).toBe('\\documentclass{article}');
expect(result.error).toBeUndefined();
});
it('should return error on non-ok response', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
json: () => Promise.resolve({ error: 'Template not found' }),
});
const result = await previewLatexSource('unknown', mockFields);
expect(result.error).toBe('Template not found');
expect(result.latex).toBeUndefined();
});
it('should return error on fetch failure', async () => {
mockFetch.mockRejectedValue(new Error('Connection refused'));
const result = await previewLatexSource('G2210-11', mockFields);
expect(result.error).toBe('Connection refused');
});
});
describe('base64ToBlob', () => {
it('should convert base64 to Blob with correct mime type', () => {
const base64 = btoa('test content');
const result = base64ToBlob(base64, 'application/pdf');
expect(result).toBeInstanceOf(Blob);
expect(result.type).toBe('application/pdf');
});
it('should use application/pdf as default mime type', () => {
const base64 = btoa('test');
const result = base64ToBlob(base64);
expect(result.type).toBe('application/pdf');
});
it('should correctly decode base64 content', () => {
const originalContent = 'Hello, World!';
const base64 = btoa(originalContent);
const result = base64ToBlob(base64, 'text/plain');
// Verify the blob has correct size (original content length)
expect(result.size).toBe(originalContent.length);
});
it('should handle binary content', () => {
// PDF magic bytes in base64
const pdfHeader = btoa('%PDF-1.4');
const result = base64ToBlob(pdfHeader, 'application/pdf');
expect(result.size).toBeGreaterThan(0);
});
});
describe('detectTemplate', () => {
it('should detect G2210-11 from filename with g2210', () => {
expect(detectTemplate('G2210-11.pdf')).toBe('G2210-11');
expect(detectTemplate('g2210_form.pdf')).toBe('G2210-11');
expect(detectTemplate('G2210.pdf')).toBe('G2210-11');
});
it('should detect G2210-11 from filename with befundbericht', () => {
expect(detectTemplate('befundbericht.pdf')).toBe('G2210-11');
expect(detectTemplate('Aerztlicher_Befundbericht.pdf')).toBe('G2210-11');
});
it('should detect G2210-11 from filename with aerztlicher', () => {
expect(detectTemplate('aerztlicher_bericht.pdf')).toBe('G2210-11');
expect(detectTemplate('Aerztlicher_form.pdf')).toBe('G2210-11');
});
it('should detect G2210-11 from filename with ärztlicher (umlaut)', () => {
expect(detectTemplate('ärztlicher_bericht.pdf')).toBe('G2210-11');
});
it('should be case insensitive', () => {
expect(detectTemplate('G2210-11.PDF')).toBe('G2210-11');
expect(detectTemplate('BEFUNDBERICHT.pdf')).toBe('G2210-11');
expect(detectTemplate('AERZTLICHER.pdf')).toBe('G2210-11');
});
it('should return null for unrecognized filenames', () => {
expect(detectTemplate('random_form.pdf')).toBeNull();
expect(detectTemplate('document.pdf')).toBeNull();
expect(detectTemplate('scan.jpg')).toBeNull();
});
});
describe('getExpectedFields', () => {
it('should return expected fields for G2210-11 template', () => {
const fields = getExpectedFields('G2210-11');
expect(fields).toBeInstanceOf(Array);
expect(fields.length).toBeGreaterThan(0);
expect(fields).toContain('Versicherungsnummer');
expect(fields).toContain('Name, Vorname');
expect(fields).toContain('Geburtsdatum');
expect(fields).toContain('Diagnose 1');
});
it('should return empty array for unknown template', () => {
const fields = getExpectedFields('unknown');
expect(fields).toEqual([]);
});
it('should include medical fields for G2210-11', () => {
const fields = getExpectedFields('G2210-11');
expect(fields).toContain('Diagnose 1 ICD');
expect(fields).toContain('Anamnese/Beschwerden');
expect(fields).toContain('Körperlicher Befund');
});
it('should include doctor fields for G2210-11', () => {
const fields = getExpectedFields('G2210-11');
expect(fields).toContain('Arzt Name');
expect(fields).toContain('Facharztbezeichnung');
expect(fields).toContain('BSNR');
expect(fields).toContain('LANR');
});
});
});

View file

@ -1,316 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getPdfFields, createFilledPdf } from '../../services/pdfService';
import { ExtractedField } from '../../types';
import { PDFDocument, PDFTextField, PDFCheckBox } from 'pdf-lib';
// Helper to create a minimal valid PDF base64 for testing
async function createTestPdfBase64(withForm = false, fieldNames: string[] = []): Promise<string> {
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([612, 792]); // Letter size
if (withForm) {
const form = pdfDoc.getForm();
fieldNames.forEach((name, index) => {
const textField = form.createTextField(name);
textField.addToPage(page, { x: 50, y: 700 - (index * 30), width: 200, height: 20 });
});
}
const pdfBytes = await pdfDoc.save();
return Buffer.from(pdfBytes).toString('base64');
}
async function createTestPdfWithCheckbox(): Promise<string> {
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([612, 792]);
const form = pdfDoc.getForm();
const checkbox = form.createCheckBox('testCheckbox');
checkbox.addToPage(page, { x: 50, y: 700, width: 20, height: 20 });
const pdfBytes = await pdfDoc.save();
return Buffer.from(pdfBytes).toString('base64');
}
describe('pdfService', () => {
describe('getPdfFields', () => {
it('should extract fields from a fillable PDF', async () => {
const base64 = await createTestPdfBase64(true, ['firstName', 'lastName', 'email']);
const fields = await getPdfFields(base64);
expect(fields).toHaveLength(3);
expect(fields[0].name).toBe('firstName');
expect(fields[1].name).toBe('lastName');
expect(fields[2].name).toBe('email');
expect(fields[0].type).toBe('PDFTextField');
});
it('should return empty array for PDF without form fields', async () => {
const base64 = await createTestPdfBase64(false);
const fields = await getPdfFields(base64);
expect(fields).toEqual([]);
});
it('should return empty array for invalid/corrupted PDF', async () => {
const invalidBase64 = Buffer.from('not a valid pdf').toString('base64');
const fields = await getPdfFields(invalidBase64);
expect(fields).toEqual([]);
});
it('should return empty array for empty string input', async () => {
const fields = await getPdfFields('');
expect(fields).toEqual([]);
});
it('should detect checkbox fields correctly', async () => {
const base64 = await createTestPdfWithCheckbox();
const fields = await getPdfFields(base64);
expect(fields).toHaveLength(1);
expect(fields[0].name).toBe('testCheckbox');
expect(fields[0].type).toBe('PDFCheckBox');
});
});
describe('createFilledPdf', () => {
describe('fillable PDF mode (isFillable=true)', () => {
it('should fill text fields in a fillable PDF', async () => {
const base64 = await createTestPdfBase64(true, ['name', 'city']);
const fields: ExtractedField[] = [
{ key: 'name', label: 'Name', value: 'John Doe', validation: { status: 'VALID' } },
{ key: 'city', label: 'City', value: 'Berlin', validation: { status: 'VALID' } }
];
const result = await createFilledPdf(base64, fields, true);
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBeGreaterThan(0);
// Verify the filled values by loading the result
const filledDoc = await PDFDocument.load(result);
const form = filledDoc.getForm();
const nameField = form.getTextField('name');
const cityField = form.getTextField('city');
expect(nameField.getText()).toBe('John Doe');
expect(cityField.getText()).toBe('Berlin');
});
it('should handle checkbox fields with true/yes values', async () => {
const base64 = await createTestPdfWithCheckbox();
const fields: ExtractedField[] = [
{ key: 'testCheckbox', label: 'Test', value: 'true', validation: { status: 'VALID' } }
];
const result = await createFilledPdf(base64, fields, true);
const filledDoc = await PDFDocument.load(result);
const form = filledDoc.getForm();
const checkbox = form.getCheckBox('testCheckbox');
expect(checkbox.isChecked()).toBe(true);
});
it('should uncheck checkbox fields with false/no values', async () => {
const base64 = await createTestPdfWithCheckbox();
const fields: ExtractedField[] = [
{ key: 'testCheckbox', label: 'Test', value: 'false', validation: { status: 'VALID' } }
];
const result = await createFilledPdf(base64, fields, true);
const filledDoc = await PDFDocument.load(result);
const form = filledDoc.getForm();
const checkbox = form.getCheckBox('testCheckbox');
expect(checkbox.isChecked()).toBe(false);
});
it('should skip fields without a key', async () => {
const base64 = await createTestPdfBase64(true, ['name']);
const fields: ExtractedField[] = [
{ label: 'Name', value: 'John Doe', validation: { status: 'VALID' } } // no key
];
const result = await createFilledPdf(base64, fields, true);
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBeGreaterThan(0);
});
it('should skip non-existent fields gracefully', async () => {
const base64 = await createTestPdfBase64(true, ['name']);
const fields: ExtractedField[] = [
{ key: 'nonexistent', label: 'Non-existent', value: 'test', validation: { status: 'VALID' } }
];
// Should not throw
const result = await createFilledPdf(base64, fields, true);
expect(result).toBeInstanceOf(Uint8Array);
});
it('should handle empty field values', async () => {
const base64 = await createTestPdfBase64(true, ['name']);
const fields: ExtractedField[] = [
{ key: 'name', label: 'Name', value: '', validation: { status: 'VALID' } }
];
const result = await createFilledPdf(base64, fields, true);
const filledDoc = await PDFDocument.load(result);
const form = filledDoc.getForm();
const nameField = form.getTextField('name');
// pdf-lib returns undefined for empty text fields, not empty string
expect(nameField.getText()).toBeUndefined();
});
});
describe('visual overlay mode (isFillable=false)', () => {
it('should draw text at specified coordinates', async () => {
const base64 = await createTestPdfBase64(false);
const fields: ExtractedField[] = [
{
label: 'Name',
value: 'John Doe',
validation: { status: 'VALID' },
coordinates: { pageIndex: 0, x: 100, y: 100 }
}
];
const result = await createFilledPdf(base64, fields, false);
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBeGreaterThan(0);
});
it('should skip fields without coordinates', async () => {
const base64 = await createTestPdfBase64(false);
const fields: ExtractedField[] = [
{ label: 'Name', value: 'John Doe', validation: { status: 'VALID' } } // no coordinates
];
const result = await createFilledPdf(base64, fields, false);
expect(result).toBeInstanceOf(Uint8Array);
});
it('should skip fields without values', async () => {
const base64 = await createTestPdfBase64(false);
const fields: ExtractedField[] = [
{
label: 'Name',
value: '',
validation: { status: 'VALID' },
coordinates: { pageIndex: 0, x: 100, y: 100 }
}
];
const result = await createFilledPdf(base64, fields, false);
expect(result).toBeInstanceOf(Uint8Array);
});
it('should skip fields with invalid page index', async () => {
const base64 = await createTestPdfBase64(false);
const fields: ExtractedField[] = [
{
label: 'Name',
value: 'John Doe',
validation: { status: 'VALID' },
coordinates: { pageIndex: 99, x: 100, y: 100 } // invalid page
}
];
// Should not throw
const result = await createFilledPdf(base64, fields, false);
expect(result).toBeInstanceOf(Uint8Array);
});
it('should skip fields with negative page index', async () => {
const base64 = await createTestPdfBase64(false);
const fields: ExtractedField[] = [
{
label: 'Name',
value: 'John Doe',
validation: { status: 'VALID' },
coordinates: { pageIndex: -1, x: 100, y: 100 }
}
];
const result = await createFilledPdf(base64, fields, false);
expect(result).toBeInstanceOf(Uint8Array);
});
it('should convert 0-1000 coordinates to PDF points correctly', async () => {
const base64 = await createTestPdfBase64(false);
const fields: ExtractedField[] = [
{
label: 'Corner',
value: 'Test',
validation: { status: 'VALID' },
coordinates: { pageIndex: 0, x: 0, y: 0 } // top-left
},
{
label: 'Bottom Right',
value: 'Test2',
validation: { status: 'VALID' },
coordinates: { pageIndex: 0, x: 1000, y: 1000 } // bottom-right
}
];
// Should handle edge coordinates without error
const result = await createFilledPdf(base64, fields, false);
expect(result).toBeInstanceOf(Uint8Array);
});
it('should handle multiple fields on the same page', async () => {
const base64 = await createTestPdfBase64(false);
const fields: ExtractedField[] = [
{
label: 'Field1',
value: 'Value1',
validation: { status: 'VALID' },
coordinates: { pageIndex: 0, x: 100, y: 100 }
},
{
label: 'Field2',
value: 'Value2',
validation: { status: 'VALID' },
coordinates: { pageIndex: 0, x: 100, y: 200 }
},
{
label: 'Field3',
value: 'Value3',
validation: { status: 'VALID' },
coordinates: { pageIndex: 0, x: 100, y: 300 }
}
];
const result = await createFilledPdf(base64, fields, false);
expect(result).toBeInstanceOf(Uint8Array);
});
});
describe('error handling', () => {
it('should throw error for invalid base64 input', async () => {
const fields: ExtractedField[] = [];
await expect(createFilledPdf('invalid-base64', fields, true)).rejects.toThrow();
});
it('should handle empty fields array', async () => {
const base64 = await createTestPdfBase64(false);
const fields: ExtractedField[] = [];
const result = await createFilledPdf(base64, fields, false);
expect(result).toBeInstanceOf(Uint8Array);
});
});
});
});

View file

@ -1,21 +0,0 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Mock URL.createObjectURL and URL.revokeObjectURL
global.URL.createObjectURL = vi.fn(() => 'blob:mock-url');
global.URL.revokeObjectURL = vi.fn();
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

View file

@ -1,29 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View file

@ -1,23 +1,14 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
const env = loadEnv(mode, '.', '');
return {
plugins: [react()],
define: {
// This is necessary to make process.env.API_KEY work in the browser
// usually Vite uses import.meta.env
'process.env.API_KEY': JSON.stringify(env.API_KEY)
}
};
});

View file

@ -1,23 +0,0 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['services/**/*.ts', 'components/**/*.tsx'],
exclude: ['**/node_modules/**', '**/tests/**']
}
},
resolve: {
alias: {
'@': '/home/user/Rentenversicherer'
}
}
});