feat(tests): fully implemented test suite

This commit is contained in:
2025-05-26 05:16:32 +00:00
parent 1d52ce1211
commit 113ae22c42
40 changed files with 3747 additions and 37 deletions

View File

@ -0,0 +1,147 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EInvoice } from '../../../ts/index.js';
import { InvoiceFormat, ValidationLevel } from '../../../ts/interfaces/common.js';
import { CorpusLoader, PerformanceTracker } from '../../helpers/test-utils.js';
/**
* Test ID: CORP-01
* Test Description: XML-Rechnung Corpus Processing
* Priority: High
*
* This test validates processing of all XML-Rechnung format files (both CII and UBL)
* from the test corpus to ensure real-world compatibility.
*/
tap.test('CORP-01: XML-Rechnung Corpus Processing - should process all XML-Rechnung files', async (t) => {
// Load XML-Rechnung test files
const ciiFiles = await CorpusLoader.loadCategory('XML_RECHNUNG_CII');
const ublFiles = await CorpusLoader.loadCategory('XML_RECHNUNG_UBL');
const allFiles = [...ciiFiles, ...ublFiles];
console.log(`Testing ${allFiles.length} XML-Rechnung files`);
console.log(` CII files: ${ciiFiles.length}`);
console.log(` UBL files: ${ublFiles.length}`);
const results = {
total: allFiles.length,
successful: 0,
failed: 0,
parseErrors: 0,
validationErrors: 0,
conversionErrors: 0,
processingTimes: [] as number[]
};
const failures: Array<{
file: string;
error: string;
stage: 'parse' | 'validate' | 'convert';
}> = [];
for (const file of allFiles) {
try {
const xmlBuffer = await CorpusLoader.loadFile(file.path);
const xmlString = xmlBuffer.toString('utf-8');
// Track performance
const { result: invoice, metric } = await PerformanceTracker.track(
'xml-rechnung-processing',
async () => {
const einvoice = new EInvoice();
await einvoice.fromXmlString(xmlString);
return einvoice;
},
{ file: file.path, size: file.size }
);
results.processingTimes.push(metric.duration);
// Validate the parsed invoice
try {
const validationResult = await invoice.validate(ValidationLevel.EXTENDED);
if (validationResult.valid) {
results.successful++;
t.pass(`${file.path}: Successfully processed and validated`);
} else {
results.validationErrors++;
failures.push({
file: file.path,
error: `Validation failed: ${validationResult.errors?.[0]?.message || 'Unknown error'}`,
stage: 'validate'
});
t.fail(`${file.path}: Validation failed`);
}
} catch (validationError: any) {
results.validationErrors++;
failures.push({
file: file.path,
error: validationError.message,
stage: 'validate'
});
}
// Test format conversion
try {
const targetFormat = file.path.includes('.cii.') ? 'ubl' : 'cii';
const converted = await invoice.toXmlString(targetFormat as any);
if (converted) {
t.pass(`${file.path}: Successfully converted to ${targetFormat}`);
}
} catch (conversionError: any) {
results.conversionErrors++;
failures.push({
file: file.path,
error: conversionError.message,
stage: 'convert'
});
}
} catch (error: any) {
results.failed++;
results.parseErrors++;
failures.push({
file: file.path,
error: error.message,
stage: 'parse'
});
t.fail(`${file.path}: Failed to parse`);
}
}
// Summary report
console.log('\n=== XML-Rechnung Corpus Processing Summary ===');
console.log(`Total files: ${results.total}`);
console.log(`Successful: ${results.successful} (${(results.successful/results.total*100).toFixed(1)}%)`);
console.log(`Failed: ${results.failed}`);
console.log(` - Parse errors: ${results.parseErrors}`);
console.log(` - Validation errors: ${results.validationErrors}`);
console.log(` - Conversion errors: ${results.conversionErrors}`);
if (failures.length > 0) {
console.log('\nFailure Details (first 10):');
failures.slice(0, 10).forEach(f => {
console.log(` ${f.file} [${f.stage}]: ${f.error}`);
});
}
// Performance metrics
if (results.processingTimes.length > 0) {
const avgTime = results.processingTimes.reduce((a, b) => a + b, 0) / results.processingTimes.length;
const maxTime = Math.max(...results.processingTimes);
const minTime = Math.min(...results.processingTimes);
console.log('\nPerformance Metrics:');
console.log(` Average processing time: ${avgTime.toFixed(2)}ms`);
console.log(` Min time: ${minTime.toFixed(2)}ms`);
console.log(` Max time: ${maxTime.toFixed(2)}ms`);
}
// Success criteria: at least 90% should pass
const successRate = results.successful / results.total;
expect(successRate).toBeGreaterThan(0.9);
});
tap.start();

View File

@ -0,0 +1,169 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EInvoice } from '../../../ts/index.js';
import { InvoiceFormat, ValidationLevel } from '../../../ts/interfaces/common.js';
import { CorpusLoader, PerformanceTracker } from '../../helpers/test-utils.js';
import * as path from 'path';
/**
* Test ID: CORP-02
* Test Description: ZUGFeRD v1 Corpus Processing
* Priority: High
*
* This test validates processing of all ZUGFeRD v1 format files
* from the test corpus, including PDF extraction and XML validation.
*/
tap.test('CORP-02: ZUGFeRD v1 Corpus Processing - should process all ZUGFeRD v1 files', async (t) => {
// Load ZUGFeRD v1 test files
const zugferdV1Files = await CorpusLoader.loadCategory('ZUGFERD_V1_CORRECT');
console.log(`Testing ${zugferdV1Files.length} ZUGFeRD v1 files`);
const results = {
total: zugferdV1Files.length,
successful: 0,
failed: 0,
pdfFiles: 0,
xmlFiles: 0,
extractionErrors: 0,
validationErrors: 0,
processingTimes: [] as number[]
};
const failures: Array<{
file: string;
error: string;
type: 'extraction' | 'validation' | 'parse';
}> = [];
for (const file of zugferdV1Files) {
const isPdf = file.path.toLowerCase().endsWith('.pdf');
const isXml = file.path.toLowerCase().endsWith('.xml');
if (isPdf) results.pdfFiles++;
if (isXml) results.xmlFiles++;
try {
const fileBuffer = await CorpusLoader.loadFile(file.path);
// Track performance
const { result: invoice, metric } = await PerformanceTracker.track(
'zugferd-v1-processing',
async () => {
const einvoice = new EInvoice();
if (isPdf) {
// Extract XML from PDF
await einvoice.fromFile(file.path);
} else {
// Parse XML directly
const xmlString = fileBuffer.toString('utf-8');
await einvoice.fromXmlString(xmlString);
}
return einvoice;
},
{ file: file.path, size: file.size, type: isPdf ? 'pdf' : 'xml' }
);
results.processingTimes.push(metric.duration);
// Validate the invoice
try {
const validationResult = await invoice.validate(ValidationLevel.EXTENDED);
if (validationResult.valid) {
results.successful++;
t.pass(`${path.basename(file.path)}: Successfully processed`);
// Check ZUGFeRD v1 specific fields
if (invoice.metadata?.format === InvoiceFormat.ZUGFERD) {
t.pass(` - Correctly identified as ZUGFeRD format`);
}
if (invoice.metadata?.version?.startsWith('1.')) {
t.pass(` - Version ${invoice.metadata.version} detected`);
}
} else {
results.validationErrors++;
failures.push({
file: path.basename(file.path),
error: validationResult.errors?.[0]?.message || 'Validation failed',
type: 'validation'
});
t.fail(`${path.basename(file.path)}: Validation failed`);
}
} catch (validationError: any) {
results.validationErrors++;
failures.push({
file: path.basename(file.path),
error: validationError.message,
type: 'validation'
});
}
} catch (error: any) {
results.failed++;
if (isPdf && error.message.includes('extract')) {
results.extractionErrors++;
failures.push({
file: path.basename(file.path),
error: error.message,
type: 'extraction'
});
} else {
failures.push({
file: path.basename(file.path),
error: error.message,
type: 'parse'
});
}
t.fail(`${path.basename(file.path)}: ${error.message}`);
}
}
// Summary report
console.log('\n=== ZUGFeRD v1 Corpus Processing Summary ===');
console.log(`Total files: ${results.total}`);
console.log(` - PDF files: ${results.pdfFiles}`);
console.log(` - XML files: ${results.xmlFiles}`);
console.log(`Successful: ${results.successful} (${(results.successful/results.total*100).toFixed(1)}%)`);
console.log(`Failed: ${results.failed}`);
console.log(` - Extraction errors: ${results.extractionErrors}`);
console.log(` - Validation errors: ${results.validationErrors}`);
if (failures.length > 0) {
console.log('\nFailure Details:');
failures.forEach(f => {
console.log(` ${f.file} [${f.type}]: ${f.error}`);
});
}
// Performance metrics
if (results.processingTimes.length > 0) {
const avgTime = results.processingTimes.reduce((a, b) => a + b, 0) / results.processingTimes.length;
const pdfTimes = results.processingTimes.filter((_, i) => zugferdV1Files[i].path.endsWith('.pdf'));
const xmlTimes = results.processingTimes.filter((_, i) => zugferdV1Files[i].path.endsWith('.xml'));
console.log('\nPerformance Metrics:');
console.log(` Average processing time: ${avgTime.toFixed(2)}ms`);
if (pdfTimes.length > 0) {
const avgPdfTime = pdfTimes.reduce((a, b) => a + b, 0) / pdfTimes.length;
console.log(` Average PDF processing: ${avgPdfTime.toFixed(2)}ms`);
}
if (xmlTimes.length > 0) {
const avgXmlTime = xmlTimes.reduce((a, b) => a + b, 0) / xmlTimes.length;
console.log(` Average XML processing: ${avgXmlTime.toFixed(2)}ms`);
}
}
// Success criteria: at least 80% should pass (ZUGFeRD v1 is legacy)
const successRate = results.successful / results.total;
expect(successRate).toBeGreaterThan(0.8);
});
tap.start();

View File

@ -0,0 +1,197 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EInvoice } from '../../../ts/index.js';
import { InvoiceFormat, ValidationLevel } from '../../../ts/interfaces/common.js';
import { CorpusLoader, PerformanceTracker } from '../../helpers/test-utils.js';
import * as path from 'path';
/**
* Test ID: CORP-03
* Test Description: ZUGFeRD v2/Factur-X Corpus Processing
* Priority: High
*
* This test validates processing of all ZUGFeRD v2 and Factur-X format files
* from the test corpus, including PDF extraction, XML validation, and profile detection.
*/
tap.test('CORP-03: ZUGFeRD v2/Factur-X Corpus Processing - should process all ZUGFeRD v2 files', async (t) => {
// Load ZUGFeRD v2 test files
const zugferdV2Files = await CorpusLoader.loadCategory('ZUGFERD_V2_CORRECT');
console.log(`Testing ${zugferdV2Files.length} ZUGFeRD v2/Factur-X files`);
const results = {
total: zugferdV2Files.length,
successful: 0,
failed: 0,
profiles: new Map<string, number>(),
pdfFiles: 0,
xmlFiles: 0,
extractionErrors: 0,
validationErrors: 0,
processingTimes: [] as number[]
};
const failures: Array<{
file: string;
error: string;
type: 'extraction' | 'validation' | 'parse';
profile?: string;
}> = [];
for (const file of zugferdV2Files) {
const isPdf = file.path.toLowerCase().endsWith('.pdf');
const isXml = file.path.toLowerCase().endsWith('.xml');
if (isPdf) results.pdfFiles++;
if (isXml) results.xmlFiles++;
try {
const fileBuffer = await CorpusLoader.loadFile(file.path);
// Track performance
const { result: invoice, metric } = await PerformanceTracker.track(
'zugferd-v2-processing',
async () => {
const einvoice = new EInvoice();
if (isPdf) {
// Extract XML from PDF
await einvoice.fromFile(file.path);
} else {
// Parse XML directly
const xmlString = fileBuffer.toString('utf-8');
await einvoice.fromXmlString(xmlString);
}
return einvoice;
},
{ file: file.path, size: file.size, type: isPdf ? 'pdf' : 'xml' }
);
results.processingTimes.push(metric.duration);
// Detect profile from filename or content
let detectedProfile = 'unknown';
const filename = path.basename(file.path).toLowerCase();
if (filename.includes('basic')) detectedProfile = 'basic';
else if (filename.includes('comfort')) detectedProfile = 'comfort';
else if (filename.includes('extended')) detectedProfile = 'extended';
else if (filename.includes('xrechnung')) detectedProfile = 'xrechnung';
else if (filename.includes('minimum')) detectedProfile = 'minimum';
// Track profile distribution
results.profiles.set(detectedProfile, (results.profiles.get(detectedProfile) || 0) + 1);
// Validate the invoice
try {
const validationResult = await invoice.validate(ValidationLevel.EXTENDED);
if (validationResult.valid) {
results.successful++;
t.pass(`${path.basename(file.path)}: Successfully processed (${detectedProfile} profile)`);
// Check format detection
const format = invoice.metadata?.format;
if (format === InvoiceFormat.ZUGFERD || format === InvoiceFormat.FACTURX) {
t.pass(` - Correctly identified as ${format} format`);
}
// Check version
if (invoice.metadata?.version) {
t.pass(` - Version ${invoice.metadata.version} detected`);
}
// Verify key fields based on profile
if (detectedProfile !== 'minimum' && detectedProfile !== 'unknown') {
if (invoice.id) t.pass(` - Invoice ID: ${invoice.id}`);
if (invoice.issueDate) t.pass(` - Issue date present`);
if (invoice.from?.name) t.pass(` - Seller: ${invoice.from.name}`);
if (invoice.to?.name) t.pass(` - Buyer: ${invoice.to.name}`);
}
} else {
results.validationErrors++;
failures.push({
file: path.basename(file.path),
error: validationResult.errors?.[0]?.message || 'Validation failed',
type: 'validation',
profile: detectedProfile
});
t.fail(`${path.basename(file.path)}: Validation failed`);
}
} catch (validationError: any) {
results.validationErrors++;
failures.push({
file: path.basename(file.path),
error: validationError.message,
type: 'validation',
profile: detectedProfile
});
}
} catch (error: any) {
results.failed++;
if (isPdf && error.message.includes('extract')) {
results.extractionErrors++;
failures.push({
file: path.basename(file.path),
error: error.message,
type: 'extraction'
});
} else {
failures.push({
file: path.basename(file.path),
error: error.message,
type: 'parse'
});
}
t.fail(`${path.basename(file.path)}: ${error.message}`);
}
}
// Summary report
console.log('\n=== ZUGFeRD v2/Factur-X Corpus Processing Summary ===');
console.log(`Total files: ${results.total}`);
console.log(` - PDF files: ${results.pdfFiles}`);
console.log(` - XML files: ${results.xmlFiles}`);
console.log(`Successful: ${results.successful} (${(results.successful/results.total*100).toFixed(1)}%)`);
console.log(`Failed: ${results.failed}`);
console.log(` - Extraction errors: ${results.extractionErrors}`);
console.log(` - Validation errors: ${results.validationErrors}`);
console.log('\nProfile Distribution:');
results.profiles.forEach((count, profile) => {
console.log(` - ${profile}: ${count} files (${(count/results.total*100).toFixed(1)}%)`);
});
if (failures.length > 0) {
console.log('\nFailure Details (first 10):');
failures.slice(0, 10).forEach(f => {
console.log(` ${f.file} [${f.type}${f.profile ? `, ${f.profile}` : ''}]: ${f.error}`);
});
if (failures.length > 10) {
console.log(` ... and ${failures.length - 10} more failures`);
}
}
// Performance metrics
if (results.processingTimes.length > 0) {
const avgTime = results.processingTimes.reduce((a, b) => a + b, 0) / results.processingTimes.length;
const sortedTimes = [...results.processingTimes].sort((a, b) => a - b);
const p95Time = sortedTimes[Math.floor(sortedTimes.length * 0.95)];
console.log('\nPerformance Metrics:');
console.log(` Average processing time: ${avgTime.toFixed(2)}ms`);
console.log(` 95th percentile: ${p95Time.toFixed(2)}ms`);
console.log(` Min time: ${Math.min(...results.processingTimes).toFixed(2)}ms`);
console.log(` Max time: ${Math.max(...results.processingTimes).toFixed(2)}ms`);
}
// Success criteria: at least 90% should pass (v2 is current standard)
const successRate = results.successful / results.total;
expect(successRate).toBeGreaterThan(0.9);
});
tap.start();

View File

@ -0,0 +1,212 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EInvoice } from '../../../ts/index.js';
import { ValidationLevel } from '../../../ts/interfaces/common.js';
import { CorpusLoader, PerformanceTracker } from '../../helpers/test-utils.js';
import * as path from 'path';
/**
* Test ID: CORP-04
* Test Description: PEPPOL Large Files Processing
* Priority: High
*
* This test validates processing of large PEPPOL BIS 3.0 files
* to ensure scalability and performance with real-world data volumes.
*/
tap.test('CORP-04: PEPPOL Large Files Processing - should handle large PEPPOL files efficiently', async (t) => {
// Load PEPPOL test files
const peppolFiles = await CorpusLoader.loadCategory('PEPPOL');
// Sort by file size to process largest files
const sortedFiles = peppolFiles.sort((a, b) => b.size - a.size);
console.log(`Testing ${peppolFiles.length} PEPPOL files`);
console.log(`Largest file: ${path.basename(sortedFiles[0].path)} (${(sortedFiles[0].size / 1024).toFixed(1)}KB)`);
const results = {
total: peppolFiles.length,
successful: 0,
failed: 0,
largeFiles: 0, // Files > 100KB
veryLargeFiles: 0, // Files > 500KB
processingTimes: [] as number[],
memorySamples: [] as number[],
fileSizes: [] as number[],
profiles: new Map<string, number>()
};
const failures: Array<{
file: string;
size: number;
error: string;
duration?: number;
}> = [];
// Process files
for (const file of peppolFiles) {
const isLarge = file.size > 100 * 1024;
const isVeryLarge = file.size > 500 * 1024;
if (isLarge) results.largeFiles++;
if (isVeryLarge) results.veryLargeFiles++;
try {
const xmlBuffer = await CorpusLoader.loadFile(file.path);
// Measure memory before processing
const memBefore = process.memoryUsage().heapUsed;
// Track performance
const { result: invoice, metric } = await PerformanceTracker.track(
'peppol-large-processing',
async () => {
const einvoice = new EInvoice();
const xmlString = xmlBuffer.toString('utf-8');
await einvoice.fromXmlString(xmlString);
return einvoice;
},
{ file: file.path, size: file.size }
);
// Measure memory after processing
const memAfter = process.memoryUsage().heapUsed;
const memoryUsed = memAfter - memBefore;
results.processingTimes.push(metric.duration);
results.memorySamples.push(memoryUsed);
results.fileSizes.push(file.size);
// Detect PEPPOL profile
let profile = 'unknown';
if (invoice.metadata?.profile) {
profile = invoice.metadata.profile;
} else if (invoice.metadata?.customizationId) {
// Extract profile from customization ID
if (invoice.metadata.customizationId.includes('billing')) profile = 'billing';
else if (invoice.metadata.customizationId.includes('procurement')) profile = 'procurement';
}
results.profiles.set(profile, (results.profiles.get(profile) || 0) + 1);
// Validate the invoice
try {
const validationResult = await invoice.validate(ValidationLevel.EXTENDED);
if (validationResult.valid) {
results.successful++;
// Log details for large files
if (isLarge) {
t.pass(`✓ Large file ${path.basename(file.path)} (${(file.size/1024).toFixed(0)}KB):`);
t.pass(` - Processing time: ${metric.duration.toFixed(0)}ms`);
t.pass(` - Memory used: ${(memoryUsed/1024/1024).toFixed(1)}MB`);
t.pass(` - Processing rate: ${(file.size/metric.duration).toFixed(0)} bytes/ms`);
} else {
t.pass(`${path.basename(file.path)}: Processed successfully`);
}
} else {
results.failed++;
failures.push({
file: path.basename(file.path),
size: file.size,
error: validationResult.errors?.[0]?.message || 'Validation failed',
duration: metric.duration
});
}
} catch (validationError: any) {
results.failed++;
failures.push({
file: path.basename(file.path),
size: file.size,
error: validationError.message,
duration: metric.duration
});
}
} catch (error: any) {
results.failed++;
failures.push({
file: path.basename(file.path),
size: file.size,
error: error.message
});
t.fail(`${path.basename(file.path)}: ${error.message}`);
}
}
// Calculate performance metrics
const avgProcessingTime = results.processingTimes.reduce((a, b) => a + b, 0) / results.processingTimes.length;
const avgMemoryUsed = results.memorySamples.reduce((a, b) => a + b, 0) / results.memorySamples.length;
// Calculate processing rate (bytes per millisecond)
const processingRates = results.processingTimes.map((time, i) => results.fileSizes[i] / time);
const avgProcessingRate = processingRates.reduce((a, b) => a + b, 0) / processingRates.length;
// Summary report
console.log('\n=== PEPPOL Large Files Processing Summary ===');
console.log(`Total files: ${results.total}`);
console.log(` - Large files (>100KB): ${results.largeFiles}`);
console.log(` - Very large files (>500KB): ${results.veryLargeFiles}`);
console.log(`Successful: ${results.successful} (${(results.successful/results.total*100).toFixed(1)}%)`);
console.log(`Failed: ${results.failed}`);
console.log('\nPEPPOL Profiles:');
results.profiles.forEach((count, profile) => {
console.log(` - ${profile}: ${count} files`);
});
if (failures.length > 0) {
console.log('\nFailures:');
failures.forEach(f => {
console.log(` ${f.file} (${(f.size/1024).toFixed(1)}KB): ${f.error}`);
});
}
console.log('\nPerformance Metrics:');
console.log(` Average processing time: ${avgProcessingTime.toFixed(2)}ms`);
console.log(` Average memory usage: ${(avgMemoryUsed/1024/1024).toFixed(2)}MB`);
console.log(` Average processing rate: ${(avgProcessingRate/1024).toFixed(2)} KB/ms`);
// Performance analysis for large files
if (results.largeFiles > 0) {
const largeFileIndices = results.fileSizes
.map((size, i) => ({ size, i }))
.filter(x => x.size > 100 * 1024)
.map(x => x.i);
const largeFileTimes = largeFileIndices.map(i => results.processingTimes[i]);
const largeFileAvgTime = largeFileTimes.reduce((a, b) => a + b, 0) / largeFileTimes.length;
console.log(`\nLarge File Performance:`);
console.log(` Average time for files >100KB: ${largeFileAvgTime.toFixed(2)}ms`);
// Check linear scaling
const smallFiles = results.fileSizes.filter(s => s < 50 * 1024);
const smallFilesAvgSize = smallFiles.reduce((a, b) => a + b, 0) / smallFiles.length;
const largeFilesAvgSize = results.fileSizes
.filter(s => s > 100 * 1024)
.reduce((a, b) => a + b, 0) / results.largeFiles;
const sizeRatio = largeFilesAvgSize / smallFilesAvgSize;
const timeRatio = largeFileAvgTime / avgProcessingTime;
console.log(` Size ratio (large/small): ${sizeRatio.toFixed(1)}x`);
console.log(` Time ratio (large/small): ${timeRatio.toFixed(1)}x`);
if (timeRatio < sizeRatio * 2) {
console.log(` ✓ Good scaling performance (sub-linear)`);
} else {
console.log(` ⚠ Poor scaling performance`);
}
}
// Success criteria
const successRate = results.successful / results.total;
expect(successRate).toBeGreaterThan(0.9);
// Performance criteria
expect(avgProcessingTime).toBeLessThan(5000); // Average should be under 5 seconds
expect(avgProcessingRate).toBeGreaterThan(10); // At least 10 bytes/ms
});
tap.start();

View File

@ -0,0 +1,262 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EInvoice } from '../../../ts/index.js';
import { InvoiceFormat, ValidationLevel } from '../../../ts/interfaces/common.js';
import { CorpusLoader, PerformanceTracker } from '../../helpers/test-utils.js';
import * as path from 'path';
/**
* Test ID: CORP-05
* Test Description: FatturaPA Corpus Processing
* Priority: Medium
*
* This test validates processing of Italian FatturaPA format files,
* including structure validation and conversion capabilities.
*/
tap.test('CORP-05: FatturaPA Corpus Processing - should process Italian FatturaPA files', async (t) => {
// Load FatturaPA test files
const fatturapaFiles = await CorpusLoader.loadCategory('FATTURAPA');
console.log(`Testing ${fatturapaFiles.length} FatturaPA files`);
const results = {
total: fatturapaFiles.length,
successful: 0,
failed: 0,
parseErrors: 0,
validationErrors: 0,
documentTypes: new Map<string, number>(),
transmissionFormats: new Map<string, number>(),
processingTimes: [] as number[]
};
const failures: Array<{
file: string;
error: string;
type: 'parse' | 'validation' | 'format';
}> = [];
// Italian-specific validation patterns
const italianValidations = {
vatNumber: /^IT\d{11}$/,
fiscalCode: /^[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z]$/,
invoiceNumber: /^\w+\/\d{4}$/, // Common format: PREFIX/YEAR
codiceDestinatario: /^[A-Z0-9]{6,7}$/,
pecEmail: /^[a-zA-Z0-9._%+-]+@pec\.[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
};
for (const file of fatturapaFiles) {
try {
const xmlBuffer = await CorpusLoader.loadFile(file.path);
const xmlString = xmlBuffer.toString('utf-8');
// Track performance
const { result: invoice, metric } = await PerformanceTracker.track(
'fatturapa-processing',
async () => {
const einvoice = new EInvoice();
// FatturaPA has specific XML structure
if (xmlString.includes('FatturaElettronica')) {
// Process as FatturaPA
await einvoice.fromXmlString(xmlString);
einvoice.metadata = {
...einvoice.metadata,
format: InvoiceFormat.FATTURAPA
};
} else {
throw new Error('Not a valid FatturaPA file');
}
return einvoice;
},
{ file: file.path, size: file.size }
);
results.processingTimes.push(metric.duration);
// Extract FatturaPA specific information
const formatMatch = xmlString.match(/<FormatoTrasmissione>([^<]+)<\/FormatoTrasmissione>/);
const typeMatch = xmlString.match(/<TipoDocumento>([^<]+)<\/TipoDocumento>/);
if (formatMatch) {
const format = formatMatch[1];
results.transmissionFormats.set(format, (results.transmissionFormats.get(format) || 0) + 1);
}
if (typeMatch) {
const docType = typeMatch[1];
results.documentTypes.set(docType, (results.documentTypes.get(docType) || 0) + 1);
}
// Validate Italian-specific fields
const vatMatch = xmlString.match(/<IdCodice>(\d{11})<\/IdCodice>/);
const cfMatch = xmlString.match(/<CodiceFiscale>([A-Z0-9]{16})<\/CodiceFiscale>/);
const destMatch = xmlString.match(/<CodiceDestinatario>([A-Z0-9]{6,7})<\/CodiceDestinatario>/);
let italianFieldsValid = true;
if (vatMatch && !italianValidations.vatNumber.test('IT' + vatMatch[1])) {
italianFieldsValid = false;
t.fail(` - Invalid VAT number format: ${vatMatch[1]}`);
}
if (cfMatch && !italianValidations.fiscalCode.test(cfMatch[1])) {
italianFieldsValid = false;
t.fail(` - Invalid Codice Fiscale format: ${cfMatch[1]}`);
}
if (destMatch && !italianValidations.codiceDestinatario.test(destMatch[1])) {
italianFieldsValid = false;
t.fail(` - Invalid Codice Destinatario: ${destMatch[1]}`);
}
// Validate the parsed invoice
try {
const validationResult = await invoice.validate(ValidationLevel.BASIC);
if (validationResult.valid && italianFieldsValid) {
results.successful++;
t.pass(`${path.basename(file.path)}: Successfully processed`);
// Log key information
if (formatMatch) {
t.pass(` - Transmission format: ${formatMatch[1]}`);
}
if (typeMatch) {
const docTypeMap: Record<string, string> = {
'TD01': 'Fattura',
'TD02': 'Acconto/Anticipo',
'TD03': 'Acconto/Anticipo su parcella',
'TD04': 'Nota di Credito',
'TD05': 'Nota di Debito',
'TD06': 'Parcella'
};
t.pass(` - Document type: ${docTypeMap[typeMatch[1]] || typeMatch[1]}`);
}
} else {
results.validationErrors++;
failures.push({
file: path.basename(file.path),
error: validationResult.errors?.[0]?.message || 'Validation failed',
type: 'validation'
});
}
} catch (validationError: any) {
results.validationErrors++;
failures.push({
file: path.basename(file.path),
error: validationError.message,
type: 'validation'
});
}
} catch (error: any) {
results.failed++;
if (error.message.includes('Not a valid FatturaPA')) {
failures.push({
file: path.basename(file.path),
error: 'Invalid FatturaPA format',
type: 'format'
});
} else {
results.parseErrors++;
failures.push({
file: path.basename(file.path),
error: error.message,
type: 'parse'
});
}
t.fail(`${path.basename(file.path)}: ${error.message}`);
}
}
// Summary report
console.log('\n=== FatturaPA Corpus Processing Summary ===');
console.log(`Total files: ${results.total}`);
console.log(`Successful: ${results.successful} (${(results.successful/results.total*100).toFixed(1)}%)`);
console.log(`Failed: ${results.failed}`);
console.log(` - Parse errors: ${results.parseErrors}`);
console.log(` - Validation errors: ${results.validationErrors}`);
console.log('\nTransmission Formats:');
results.transmissionFormats.forEach((count, format) => {
const formatMap: Record<string, string> = {
'FPA12': 'Pubblica Amministrazione',
'FPR12': 'Privati',
'SDI11': 'Sistema di Interscambio v1.1'
};
console.log(` - ${format}: ${formatMap[format] || format} (${count} files)`);
});
console.log('\nDocument Types:');
results.documentTypes.forEach((count, type) => {
const typeMap: Record<string, string> = {
'TD01': 'Fattura (Invoice)',
'TD02': 'Acconto/Anticipo (Advance)',
'TD03': 'Acconto/Anticipo su parcella',
'TD04': 'Nota di Credito (Credit Note)',
'TD05': 'Nota di Debito (Debit Note)',
'TD06': 'Parcella (Fee Note)'
};
console.log(` - ${type}: ${typeMap[type] || type} (${count} files)`);
});
if (failures.length > 0) {
console.log('\nFailure Details:');
failures.forEach(f => {
console.log(` ${f.file} [${f.type}]: ${f.error}`);
});
}
// Performance metrics
if (results.processingTimes.length > 0) {
const avgTime = results.processingTimes.reduce((a, b) => a + b, 0) / results.processingTimes.length;
const minTime = Math.min(...results.processingTimes);
const maxTime = Math.max(...results.processingTimes);
console.log('\nPerformance Metrics:');
console.log(` Average processing time: ${avgTime.toFixed(2)}ms`);
console.log(` Min time: ${minTime.toFixed(2)}ms`);
console.log(` Max time: ${maxTime.toFixed(2)}ms`);
}
// FatturaPA specific features test
t.test('FatturaPA specific features', async (st) => {
if (results.successful > 0) {
// Test a sample file for specific features
const sampleFile = fatturapaFiles[0];
const xmlBuffer = await CorpusLoader.loadFile(sampleFile.path);
const xmlString = xmlBuffer.toString('utf-8');
// Check for mandatory sections
const mandatorySections = [
'FatturaElettronicaHeader',
'CedentePrestatore', // Seller
'CessionarioCommittente', // Buyer
'FatturaElettronicaBody',
'DatiGenerali',
'DatiBeniServizi'
];
for (const section of mandatorySections) {
if (xmlString.includes(section)) {
st.pass(`✓ Contains mandatory section: ${section}`);
}
}
// Check for digital signature block
if (xmlString.includes('<ds:Signature') || xmlString.includes('<Signature')) {
st.pass('✓ Contains digital signature block');
}
}
});
// Success criteria: at least 70% should pass (FatturaPA is complex)
const successRate = results.successful / results.total;
expect(successRate).toBeGreaterThan(0.7);
});
tap.start();

View File

@ -0,0 +1,185 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EInvoice } from '../../../ts/index.js';
import { ValidationLevel } from '../../../ts/interfaces/common.js';
import { CorpusLoader, PerformanceTracker } from '../../helpers/test-utils.js';
import * as path from 'path';
/**
* Test ID: CORP-06
* Test Description: EN16931 Test Suite Execution
* Priority: High
*
* This test executes the official EN16931 validation test suite
* to ensure compliance with the European e-invoicing standard.
*/
tap.test('CORP-06: EN16931 Test Suite Execution - should validate against EN16931 test cases', async (t) => {
// Load EN16931 test files
const en16931Files = await CorpusLoader.loadCategory('EN16931_TEST_CASES');
console.log(`Testing ${en16931Files.length} EN16931 test cases`);
const results = {
total: en16931Files.length,
passed: 0,
failed: 0,
ruleCategories: new Map<string, { passed: number; failed: number }>(),
processingTimes: [] as number[],
businessRules: { passed: 0, failed: 0 },
codelistRules: { passed: 0, failed: 0 },
calculationRules: { passed: 0, failed: 0 },
syntaxRules: { passed: 0, failed: 0 }
};
const failures: Array<{
file: string;
rule: string;
expected: 'pass' | 'fail';
actual: 'pass' | 'fail';
error?: string;
}> = [];
for (const file of en16931Files) {
const filename = path.basename(file.path);
// Determine expected result and rule from filename
// EN16931 test files typically follow pattern: BR-XX.xml, BR-CL-XX.xml, BR-CO-XX.xml
const ruleMatch = filename.match(/^(BR|BR-CL|BR-CO|BR-[A-Z]+)-(\d+)/);
const rule = ruleMatch ? ruleMatch[0] : 'unknown';
const ruleCategory = ruleMatch ? ruleMatch[1] : 'unknown';
// Some test files are designed to fail validation
const shouldFail = filename.includes('fail') || filename.includes('invalid');
try {
const xmlBuffer = await CorpusLoader.loadFile(file.path);
const xmlString = xmlBuffer.toString('utf-8');
// Track performance
const { result: invoice, metric } = await PerformanceTracker.track(
'en16931-validation',
async () => {
const einvoice = new EInvoice();
await einvoice.fromXmlString(xmlString);
return einvoice;
},
{ file: file.path, rule, size: file.size }
);
results.processingTimes.push(metric.duration);
// Validate against EN16931 rules
const validationResult = await invoice.validate(ValidationLevel.EN16931);
// Track rule category
if (!results.ruleCategories.has(ruleCategory)) {
results.ruleCategories.set(ruleCategory, { passed: 0, failed: 0 });
}
// Categorize rules
if (ruleCategory === 'BR-CL') {
if (validationResult.valid) results.codelistRules.passed++;
else results.codelistRules.failed++;
} else if (ruleCategory === 'BR-CO') {
if (validationResult.valid) results.calculationRules.passed++;
else results.calculationRules.failed++;
} else if (ruleCategory === 'BR') {
if (validationResult.valid) results.businessRules.passed++;
else results.businessRules.failed++;
} else {
if (validationResult.valid) results.syntaxRules.passed++;
else results.syntaxRules.failed++;
}
// Check if result matches expectation
const actuallyFailed = !validationResult.valid;
if (shouldFail === actuallyFailed) {
results.passed++;
const category = results.ruleCategories.get(ruleCategory)!;
category.passed++;
t.pass(`${filename} [${rule}]: ${shouldFail ? 'Failed as expected' : 'Passed as expected'}`);
if (actuallyFailed && validationResult.errors?.length) {
t.pass(` - Error: ${validationResult.errors[0].message}`);
}
} else {
results.failed++;
const category = results.ruleCategories.get(ruleCategory)!;
category.failed++;
failures.push({
file: filename,
rule,
expected: shouldFail ? 'fail' : 'pass',
actual: actuallyFailed ? 'fail' : 'pass',
error: validationResult.errors?.[0]?.message
});
t.fail(`${filename} [${rule}]: Expected to ${shouldFail ? 'fail' : 'pass'} but ${actuallyFailed ? 'failed' : 'passed'}`);
}
} catch (error: any) {
// Parse errors might be expected for some test cases
if (shouldFail) {
results.passed++;
t.pass(`${filename} [${rule}]: Failed to parse as expected`);
} else {
results.failed++;
failures.push({
file: filename,
rule,
expected: 'pass',
actual: 'fail',
error: error.message
});
t.fail(`${filename} [${rule}]: Unexpected parse error`);
}
}
}
// Summary report
console.log('\n=== EN16931 Test Suite Execution Summary ===');
console.log(`Total test cases: ${results.total}`);
console.log(`Passed: ${results.passed} (${(results.passed/results.total*100).toFixed(1)}%)`);
console.log(`Failed: ${results.failed}`);
console.log('\nRule Categories:');
results.ruleCategories.forEach((stats, category) => {
const total = stats.passed + stats.failed;
console.log(` ${category}: ${stats.passed}/${total} passed (${(stats.passed/total*100).toFixed(1)}%)`);
});
console.log('\nRule Types:');
console.log(` Business Rules (BR): ${results.businessRules.passed}/${results.businessRules.passed + results.businessRules.failed} passed`);
console.log(` Codelist Rules (BR-CL): ${results.codelistRules.passed}/${results.codelistRules.passed + results.codelistRules.failed} passed`);
console.log(` Calculation Rules (BR-CO): ${results.calculationRules.passed}/${results.calculationRules.passed + results.calculationRules.failed} passed`);
console.log(` Syntax Rules: ${results.syntaxRules.passed}/${results.syntaxRules.passed + results.syntaxRules.failed} passed`);
if (failures.length > 0) {
console.log('\nFailure Details (first 10):');
failures.slice(0, 10).forEach(f => {
console.log(` ${f.file} [${f.rule}]:`);
console.log(` Expected: ${f.expected}, Actual: ${f.actual}`);
if (f.error) console.log(` Error: ${f.error}`);
});
if (failures.length > 10) {
console.log(` ... and ${failures.length - 10} more failures`);
}
}
// Performance metrics
if (results.processingTimes.length > 0) {
const avgTime = results.processingTimes.reduce((a, b) => a + b, 0) / results.processingTimes.length;
console.log('\nPerformance Metrics:');
console.log(` Average validation time: ${avgTime.toFixed(2)}ms`);
console.log(` Total execution time: ${results.processingTimes.reduce((a, b) => a + b, 0).toFixed(0)}ms`);
}
// Success criteria: at least 95% of test cases should behave as expected
const successRate = results.passed / results.total;
expect(successRate).toBeGreaterThan(0.95);
});
tap.start();

View File

@ -0,0 +1,298 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EInvoice } from '../../../ts/index.js';
import { InvoiceFormat, ValidationLevel } from '../../../ts/interfaces/common.js';
import { CorpusLoader, PerformanceTracker } from '../../helpers/test-utils.js';
import * as path from 'path';
/**
* Test ID: CORP-07
* Test Description: Cross-Format Corpus Validation
* Priority: Medium
*
* This test validates cross-format compatibility by converting invoices
* between different formats and ensuring data integrity is maintained.
*/
tap.test('CORP-07: Cross-Format Corpus Validation - should validate format conversions', async (t) => {
// Define format conversion paths
const conversionPaths = [
{ from: 'UBL', to: 'CII', category: 'UBL_XMLRECHNUNG' },
{ from: 'CII', to: 'UBL', category: 'XML_RECHNUNG_CII' },
{ from: 'ZUGFERD', to: 'UBL', category: 'ZUGFERD_V2_CORRECT' },
{ from: 'FACTURX', to: 'CII', category: 'ZUGFERD_V2_CORRECT' }
];
const results = {
totalConversions: 0,
successful: 0,
failed: 0,
dataLoss: 0,
formatPairs: new Map<string, { success: number; failed: number }>(),
fieldPreservation: new Map<string, number>(),
processingTimes: [] as number[]
};
const failures: Array<{
file: string;
conversion: string;
error: string;
lostFields?: string[];
}> = [];
// Critical fields that must be preserved
const criticalFields = [
'id',
'issueDate',
'currency',
'from.name',
'from.vatNumber',
'to.name',
'items[].name',
'items[].quantity',
'items[].unitPrice',
'items[].taxPercent',
'totalNet',
'totalGross'
];
for (const conversion of conversionPaths) {
const conversionKey = `${conversion.from}->${conversion.to}`;
results.formatPairs.set(conversionKey, { success: 0, failed: 0 });
console.log(`\nTesting ${conversionKey} conversion...`);
// Load test files
const files = await CorpusLoader.loadCategory(conversion.category);
const testFiles = files.slice(0, 3); // Test first 3 files per format
for (const file of testFiles) {
try {
const xmlBuffer = await CorpusLoader.loadFile(file.path);
// Track conversion performance
const { result: conversionResult, metric } = await PerformanceTracker.track(
'cross-format-conversion',
async () => {
// Parse original
const originalInvoice = new EInvoice();
const xmlString = xmlBuffer.toString('utf-8');
await originalInvoice.fromXmlString(xmlString);
// Convert to target format
const targetFormat = conversion.to.toLowerCase() as any;
const convertedXml = await originalInvoice.toXmlString(targetFormat);
// Parse converted back
const convertedInvoice = new EInvoice();
await convertedInvoice.fromXmlString(convertedXml);
return {
original: originalInvoice,
converted: convertedInvoice,
xml: convertedXml
};
},
{ file: file.path, conversion: conversionKey }
);
results.processingTimes.push(metric.duration);
results.totalConversions++;
// Validate data preservation
const { original, converted } = conversionResult;
const lostFields: string[] = [];
// Check critical fields
for (const field of criticalFields) {
const originalValue = getNestedValue(original, field);
const convertedValue = getNestedValue(converted, field);
if (originalValue && !convertedValue) {
lostFields.push(field);
} else if (originalValue && convertedValue) {
// Track successful preservation
results.fieldPreservation.set(field,
(results.fieldPreservation.get(field) || 0) + 1
);
}
}
// Additional validation
const validationResult = await converted.validate(ValidationLevel.BASIC);
if (validationResult.valid && lostFields.length === 0) {
results.successful++;
results.formatPairs.get(conversionKey)!.success++;
t.pass(`${path.basename(file.path)} -> ${conversion.to}: Successful conversion`);
// Check amounts preservation
if (original.totalNet && converted.totalNet) {
const amountDiff = Math.abs(original.totalNet - converted.totalNet);
if (amountDiff < 0.01) {
t.pass(` - Amount preservation: ✓ (diff: ${amountDiff.toFixed(4)})`);
} else {
t.fail(` - Amount preservation: ✗ (diff: ${amountDiff.toFixed(2)})`);
}
}
} else {
if (lostFields.length > 0) {
results.dataLoss++;
results.failed++;
results.formatPairs.get(conversionKey)!.failed++;
failures.push({
file: path.basename(file.path),
conversion: conversionKey,
error: 'Data loss during conversion',
lostFields
});
t.fail(`${path.basename(file.path)}: Lost fields: ${lostFields.join(', ')}`);
} else {
results.failed++;
results.formatPairs.get(conversionKey)!.failed++;
failures.push({
file: path.basename(file.path),
conversion: conversionKey,
error: validationResult.errors?.[0]?.message || 'Validation failed'
});
t.fail(`${path.basename(file.path)}: ${validationResult.errors?.[0]?.message}`);
}
}
} catch (error: any) {
results.failed++;
results.totalConversions++;
results.formatPairs.get(conversionKey)!.failed++;
failures.push({
file: path.basename(file.path),
conversion: conversionKey,
error: error.message
});
t.fail(`${path.basename(file.path)}: ${error.message}`);
}
}
}
// Test round-trip conversions
t.test('Round-trip conversion integrity', async (st) => {
const roundTripPaths = [
{ format1: 'UBL', format2: 'CII' },
{ format1: 'CII', format2: 'UBL' }
];
for (const roundTrip of roundTripPaths) {
// Create test invoice
const testInvoice = new EInvoice();
testInvoice.id = `RT-TEST-${roundTrip.format1}-${roundTrip.format2}`;
testInvoice.issueDate = new Date();
testInvoice.currency = 'EUR';
testInvoice.from = {
name: 'Test Seller',
vatNumber: 'DE123456789',
address: { street: 'Main St', city: 'Berlin', postalCode: '10115', country: 'DE' }
};
testInvoice.to = {
name: 'Test Buyer',
address: { street: 'Market St', city: 'Munich', postalCode: '80331', country: 'DE' }
};
testInvoice.items = [{
name: 'Test Product',
quantity: 10,
unitPrice: 100,
taxPercent: 19
}];
try {
// Convert format1 -> format2 -> format1
const format1Xml = await testInvoice.toXmlString(roundTrip.format1.toLowerCase() as any);
const invoice2 = new EInvoice();
await invoice2.fromXmlString(format1Xml);
const format2Xml = await invoice2.toXmlString(roundTrip.format2.toLowerCase() as any);
const invoice3 = new EInvoice();
await invoice3.fromXmlString(format2Xml);
const format1XmlFinal = await invoice3.toXmlString(roundTrip.format1.toLowerCase() as any);
// Compare critical values
expect(invoice3.id).toEqual(testInvoice.id);
expect(invoice3.from?.name).toEqual(testInvoice.from.name);
expect(invoice3.items?.length).toEqual(testInvoice.items.length);
st.pass(`✓ Round-trip ${roundTrip.format1} -> ${roundTrip.format2} -> ${roundTrip.format1} successful`);
} catch (error: any) {
st.fail(`✗ Round-trip failed: ${error.message}`);
}
}
});
// Summary report
console.log('\n=== Cross-Format Corpus Validation Summary ===');
console.log(`Total conversions attempted: ${results.totalConversions}`);
console.log(`Successful: ${results.successful} (${(results.successful/results.totalConversions*100).toFixed(1)}%)`);
console.log(`Failed: ${results.failed}`);
console.log(`Data loss incidents: ${results.dataLoss}`);
console.log('\nConversion Success Rates:');
results.formatPairs.forEach((stats, pair) => {
const total = stats.success + stats.failed;
const rate = total > 0 ? (stats.success / total * 100).toFixed(1) : '0.0';
console.log(` ${pair}: ${stats.success}/${total} (${rate}%)`);
});
console.log('\nField Preservation Rates:');
const totalTests = results.successful + results.dataLoss;
criticalFields.forEach(field => {
const preserved = results.fieldPreservation.get(field) || 0;
const rate = totalTests > 0 ? (preserved / totalTests * 100).toFixed(1) : '0.0';
console.log(` ${field}: ${rate}%`);
});
if (failures.length > 0) {
console.log('\nFailure Details (first 10):');
failures.slice(0, 10).forEach(f => {
console.log(` ${f.file} [${f.conversion}]: ${f.error}`);
if (f.lostFields) {
console.log(` Lost fields: ${f.lostFields.join(', ')}`);
}
});
}
// Performance metrics
if (results.processingTimes.length > 0) {
const avgTime = results.processingTimes.reduce((a, b) => a + b, 0) / results.processingTimes.length;
console.log('\nPerformance Metrics:');
console.log(` Average conversion time: ${avgTime.toFixed(2)}ms`);
}
// Success criteria
const successRate = results.successful / results.totalConversions;
expect(successRate).toBeGreaterThan(0.8); // 80% success rate for conversions
const dataIntegrityRate = (results.successful / (results.successful + results.dataLoss));
expect(dataIntegrityRate).toBeGreaterThan(0.9); // 90% data integrity
});
// Helper function to get nested object values
function getNestedValue(obj: any, path: string): any {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (part.includes('[')) {
// Handle array notation
const [arrayName, indexStr] = part.split('[');
const index = indexStr ? parseInt(indexStr.replace(']', '')) : 0;
current = current?.[arrayName]?.[index];
} else {
current = current?.[part];
}
if (current === undefined) break;
}
return current;
}
tap.start();

View File

@ -0,0 +1,376 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EInvoice } from '../../../ts/index.js';
import { ValidationLevel } from '../../../ts/interfaces/common.js';
import { CorpusLoader, PerformanceTracker } from '../../helpers/test-utils.js';
import * as path from 'path';
import * as fs from 'fs/promises';
/**
* Test ID: CORP-08
* Test Description: Failed Invoice Handling
* Priority: High
*
* This test validates proper error handling and recovery when processing
* invalid or malformed invoices from the corpus.
*/
tap.test('CORP-08: Failed Invoice Handling - should handle invalid invoices gracefully', async (t) => {
// Load failed/invalid test files from various categories
const failCategories = [
'ZUGFERD_V1_FAIL',
'ZUGFERD_V2_FAIL',
'EN16931_INVALID'
];
const failedFiles: Array<{ path: string; size: number; category: string }> = [];
// Collect all failed invoice files
for (const category of failCategories) {
try {
const files = await CorpusLoader.getFiles(category);
failedFiles.push(...files.map(f => ({ ...f, category })));
} catch (e) {
// Category might not exist
console.log(`Category ${category} not found, skipping...`);
}
}
// Also test some synthetic invalid files
const syntheticInvalids = [
{
name: 'empty.xml',
content: '',
expectedError: 'empty'
},
{
name: 'not-xml.xml',
content: 'This is not XML content',
expectedError: 'parse'
},
{
name: 'invalid-structure.xml',
content: '<?xml version="1.0"?><Invoice><Invalid>Structure</Wrong></Invoice>',
expectedError: 'structure'
},
{
name: 'missing-required.xml',
content: '<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"></Invoice>',
expectedError: 'validation'
},
{
name: 'malformed-encoding.xml',
content: '<?xml version="1.0" encoding="UTF-8"?><Invoice>Ä Invalid UTF-8 bytes</Invoice>',
expectedError: 'encoding'
}
];
console.log(`Testing ${failedFiles.length} failed corpus files and ${syntheticInvalids.length} synthetic invalid files`);
const results = {
totalFiles: failedFiles.length + syntheticInvalids.length,
handled: 0,
unhandled: 0,
errorTypes: new Map<string, number>(),
errorMessages: new Map<string, number>(),
recoveryAttempts: 0,
partialRecoveries: 0
};
// Test corpus failed files
t.test('Corpus failed files handling', async (st) => {
for (const file of failedFiles) {
try {
const xmlBuffer = await CorpusLoader.loadFile(file.path);
const xmlString = xmlBuffer.toString('utf-8');
const invoice = new EInvoice();
let error: any = null;
let stage = 'unknown';
try {
// Attempt to parse
stage = 'parse';
await invoice.fromXmlString(xmlString);
// Attempt to validate
stage = 'validate';
const validationResult = await invoice.validate(ValidationLevel.EXTENDED);
if (!validationResult.valid) {
error = new Error(validationResult.errors?.[0]?.message || 'Validation failed');
error.type = 'validation';
error.details = validationResult.errors;
}
} catch (e: any) {
error = e;
error.type = stage;
}
if (error) {
results.handled++;
// Categorize error
const errorType = error.type || 'unknown';
results.errorTypes.set(errorType, (results.errorTypes.get(errorType) || 0) + 1);
// Track common error messages
const errorMsg = error.message.substring(0, 50);
results.errorMessages.set(errorMsg, (results.errorMessages.get(errorMsg) || 0) + 1);
st.pass(`${path.basename(file.path)}: Error handled properly (${errorType})`);
// Test error recovery attempt
if (errorType === 'parse') {
results.recoveryAttempts++;
// Try recovery strategies
const recovered = await attemptRecovery(xmlString, invoice);
if (recovered) {
results.partialRecoveries++;
st.pass(` - Partial recovery successful`);
}
}
} else {
// File was expected to fail but didn't
st.fail(`${path.basename(file.path)}: Expected to fail but succeeded`);
}
} catch (unexpectedError: any) {
results.unhandled++;
st.fail(`${path.basename(file.path)}: Unhandled error - ${unexpectedError.message}`);
}
}
});
// Test synthetic invalid files
t.test('Synthetic invalid files handling', async (st) => {
for (const invalid of syntheticInvalids) {
try {
const invoice = new EInvoice();
let errorOccurred = false;
let errorType = '';
try {
await invoice.fromXmlString(invalid.content);
// If parsing succeeded, try validation
const validationResult = await invoice.validate();
if (!validationResult.valid) {
errorOccurred = true;
errorType = 'validation';
}
} catch (error: any) {
errorOccurred = true;
errorType = determineErrorType(error);
results.handled++;
// Track error type
results.errorTypes.set(errorType, (results.errorTypes.get(errorType) || 0) + 1);
}
if (errorOccurred) {
st.pass(`${invalid.name}: Correctly failed with ${errorType} error`);
if (errorType !== invalid.expectedError && invalid.expectedError !== 'any') {
st.comment(` Note: Expected ${invalid.expectedError} but got ${errorType}`);
}
} else {
st.fail(`${invalid.name}: Should have failed but succeeded`);
}
} catch (unexpectedError: any) {
results.unhandled++;
st.fail(`${invalid.name}: Unhandled error - ${unexpectedError.message}`);
}
}
});
// Test error message quality
t.test('Error message quality', async (st) => {
const testCases = [
{
xml: '<Invoice/>',
check: 'descriptive'
},
{
xml: '<?xml version="1.0"?><Invoice xmlns="bad-namespace"/>',
check: 'namespace'
},
{
xml: '<?xml version="1.0"?><CrossIndustryInvoice><ExchangedDocument><ID></ID></ExchangedDocument></CrossIndustryInvoice>',
check: 'required-field'
}
];
for (const testCase of testCases) {
try {
const invoice = new EInvoice();
await invoice.fromXmlString(testCase.xml);
const result = await invoice.validate();
if (!result.valid && result.errors?.length) {
const error = result.errors[0];
// Check error message quality
const hasErrorCode = !!error.code;
const hasDescription = error.message.length > 20;
const hasContext = !!error.path || !!error.field;
if (hasErrorCode && hasDescription) {
st.pass(`✓ Good error message quality for ${testCase.check}`);
st.comment(` Message: ${error.message.substring(0, 80)}...`);
} else {
st.fail(`✗ Poor error message quality for ${testCase.check}`);
}
}
} catch (error: any) {
// Parse errors should also have good messages
if (error.message && error.message.length > 20) {
st.pass(`✓ Parse error has descriptive message`);
}
}
}
});
// Test error recovery mechanisms
t.test('Error recovery mechanisms', async (st) => {
const recoverableErrors = [
{
name: 'missing-closing-tag',
xml: '<?xml version="1.0"?><Invoice><ID>123</ID>',
recovery: 'auto-close'
},
{
name: 'encoding-issue',
xml: '<?xml version="1.0" encoding="ISO-8859-1"?><Invoice><Name>Café</Name></Invoice>',
recovery: 'encoding-fix'
},
{
name: 'namespace-mismatch',
xml: '<Invoice xmlns="wrong-namespace"><ID>123</ID></Invoice>',
recovery: 'namespace-fix'
}
];
for (const testCase of recoverableErrors) {
const invoice = new EInvoice();
const recovered = await attemptRecovery(testCase.xml, invoice);
if (recovered) {
st.pass(`${testCase.name}: Recovery successful using ${testCase.recovery}`);
} else {
st.comment(` ${testCase.name}: Recovery not implemented`);
}
}
});
// Summary report
console.log('\n=== Failed Invoice Handling Summary ===');
console.log(`Total files tested: ${results.totalFiles}`);
console.log(`Properly handled: ${results.handled} (${(results.handled/results.totalFiles*100).toFixed(1)}%)`);
console.log(`Unhandled errors: ${results.unhandled}`);
console.log('\nError Types Distribution:');
results.errorTypes.forEach((count, type) => {
console.log(` ${type}: ${count} occurrences`);
});
console.log('\nCommon Error Messages:');
const sortedErrors = Array.from(results.errorMessages.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
sortedErrors.forEach(([msg, count]) => {
console.log(` "${msg}...": ${count} times`);
});
console.log('\nRecovery Statistics:');
console.log(` Recovery attempts: ${results.recoveryAttempts}`);
console.log(` Partial recoveries: ${results.partialRecoveries}`);
console.log(` Recovery rate: ${results.recoveryAttempts > 0 ?
(results.partialRecoveries/results.recoveryAttempts*100).toFixed(1) : 0}%`);
// Success criteria
const handlingRate = results.handled / results.totalFiles;
expect(handlingRate).toBeGreaterThan(0.95); // 95% of errors should be handled gracefully
// No unhandled errors in production
expect(results.unhandled).toBeLessThan(results.totalFiles * 0.05); // Less than 5% unhandled
});
// Helper function to determine error type
function determineErrorType(error: Error): string {
const message = error.message.toLowerCase();
if (message.includes('parse') || message.includes('syntax')) return 'parse';
if (message.includes('encoding') || message.includes('utf')) return 'encoding';
if (message.includes('valid')) return 'validation';
if (message.includes('require') || message.includes('missing')) return 'required-field';
if (message.includes('namespace')) return 'namespace';
if (message.includes('empty')) return 'empty';
return 'unknown';
}
// Helper function to attempt recovery
async function attemptRecovery(xml: string, invoice: EInvoice): Promise<boolean> {
// Try various recovery strategies
// 1. Try to fix encoding
if (xml.includes('encoding=') && !xml.includes('UTF-8')) {
try {
const utf8Xml = xml.replace(/encoding="[^"]*"/, 'encoding="UTF-8"');
await invoice.fromXmlString(utf8Xml);
return true;
} catch (e) {
// Continue to next strategy
}
}
// 2. Try to auto-close tags
if (!xml.includes('</') && xml.includes('<')) {
try {
// Simple auto-close attempt
const tags = xml.match(/<([^\/>\s]+)/g);
if (tags) {
let fixedXml = xml;
tags.reverse().forEach(tag => {
const tagName = tag.substring(1);
if (!fixedXml.includes(`</${tagName}>`)) {
fixedXml += `</${tagName}>`;
}
});
await invoice.fromXmlString(fixedXml);
return true;
}
} catch (e) {
// Continue
}
}
// 3. Try namespace fixes
if (xml.includes('xmlns=')) {
try {
// Try with common namespaces
const namespaces = [
'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100'
];
for (const ns of namespaces) {
const fixedXml = xml.replace(/xmlns="[^"]*"/, `xmlns="${ns}"`);
try {
await invoice.fromXmlString(fixedXml);
return true;
} catch (e) {
// Try next namespace
}
}
} catch (e) {
// Failed
}
}
return false;
}
tap.start();

View File

@ -0,0 +1,364 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EInvoice } from '../../../ts/index.js';
import { InvoiceFormat } from '../../../ts/interfaces/common.js';
import { CorpusLoader, PerformanceTracker } from '../../helpers/test-utils.js';
import * as path from 'path';
import * as fs from 'fs/promises';
/**
* Test ID: CORP-09
* Test Description: Corpus Statistics Generation
* Priority: Low
*
* This test generates comprehensive statistics about the test corpus
* to help understand coverage, patterns, and potential gaps.
*/
tap.test('CORP-09: Corpus Statistics Generation - should analyze corpus characteristics', async (t) => {
const startTime = Date.now();
// Initialize statistics collectors
const stats = {
totalFiles: 0,
totalSize: 0,
formats: new Map<string, number>(),
categories: new Map<string, number>(),
fileSizes: {
tiny: 0, // < 10KB
small: 0, // 10-50KB
medium: 0, // 50-200KB
large: 0, // 200KB-1MB
veryLarge: 0 // > 1MB
},
invoiceData: {
currencies: new Map<string, number>(),
countries: new Map<string, number>(),
taxRates: new Map<number, number>(),
itemCounts: new Map<string, number>(),
documentTypes: new Map<string, number>()
},
xmlCharacteristics: {
namespaces: new Map<string, number>(),
rootElements: new Map<string, number>(),
encodings: new Map<string, number>(),
versions: new Map<string, number>()
},
validationResults: {
parseSuccess: 0,
parseFailed: 0,
validationSuccess: 0,
validationFailed: 0
}
};
// Get all corpus categories
const allCategories = [
'XML_RECHNUNG_CII',
'XML_RECHNUNG_UBL',
'ZUGFERD_V1_CORRECT',
'ZUGFERD_V2_CORRECT',
'PEPPOL',
'FATTURAPA',
'EN16931_TEST_CASES'
];
console.log('Analyzing test corpus...\n');
// Process each category
for (const category of allCategories) {
try {
const files = await CorpusLoader.loadCategory(category);
stats.categories.set(category, files.length);
console.log(`Processing ${category}: ${files.length} files`);
for (const file of files) {
stats.totalFiles++;
stats.totalSize += file.size;
// Categorize by size
if (file.size < 10 * 1024) stats.fileSizes.tiny++;
else if (file.size < 50 * 1024) stats.fileSizes.small++;
else if (file.size < 200 * 1024) stats.fileSizes.medium++;
else if (file.size < 1024 * 1024) stats.fileSizes.large++;
else stats.fileSizes.veryLarge++;
// Detect format from filename or content
const format = detectFormatFromFile(file.path, category);
stats.formats.set(format, (stats.formats.get(format) || 0) + 1);
// Analyze XML content
try {
const xmlBuffer = await CorpusLoader.loadFile(file.path);
const xmlString = xmlBuffer.toString('utf-8');
// Extract XML characteristics
const xmlInfo = analyzeXMLCharacteristics(xmlString);
if (xmlInfo.encoding) {
stats.xmlCharacteristics.encodings.set(xmlInfo.encoding,
(stats.xmlCharacteristics.encodings.get(xmlInfo.encoding) || 0) + 1);
}
if (xmlInfo.rootElement) {
stats.xmlCharacteristics.rootElements.set(xmlInfo.rootElement,
(stats.xmlCharacteristics.rootElements.get(xmlInfo.rootElement) || 0) + 1);
}
xmlInfo.namespaces.forEach(ns => {
stats.xmlCharacteristics.namespaces.set(ns,
(stats.xmlCharacteristics.namespaces.get(ns) || 0) + 1);
});
// Try to parse and extract invoice data
try {
const invoice = new EInvoice();
await invoice.fromXmlString(xmlString);
stats.validationResults.parseSuccess++;
// Extract invoice statistics
if (invoice.currency) {
stats.invoiceData.currencies.set(invoice.currency,
(stats.invoiceData.currencies.get(invoice.currency) || 0) + 1);
}
if (invoice.from?.address?.country) {
stats.invoiceData.countries.set(invoice.from.address.country,
(stats.invoiceData.countries.get(invoice.from.address.country) || 0) + 1);
}
if (invoice.items?.length) {
const bucket = getItemCountBucket(invoice.items.length);
stats.invoiceData.itemCounts.set(bucket,
(stats.invoiceData.itemCounts.get(bucket) || 0) + 1);
// Collect tax rates
invoice.items.forEach(item => {
if (item.taxPercent !== undefined) {
stats.invoiceData.taxRates.set(item.taxPercent,
(stats.invoiceData.taxRates.get(item.taxPercent) || 0) + 1);
}
});
}
// Document type
const docType = invoice.invoiceType || 'invoice';
stats.invoiceData.documentTypes.set(docType,
(stats.invoiceData.documentTypes.get(docType) || 0) + 1);
// Try validation
const validationResult = await invoice.validate();
if (validationResult.valid) {
stats.validationResults.validationSuccess++;
} else {
stats.validationResults.validationFailed++;
}
} catch (parseError) {
stats.validationResults.parseFailed++;
}
} catch (readError) {
console.error(` Error reading ${file.path}: ${readError}`);
}
}
} catch (categoryError) {
console.log(` Category ${category} not found or error: ${categoryError}`);
}
}
const totalTime = Date.now() - startTime;
// Generate comprehensive report
console.log('\n=== CORPUS STATISTICS REPORT ===\n');
console.log('GENERAL STATISTICS:');
console.log(`Total files: ${stats.totalFiles}`);
console.log(`Total size: ${(stats.totalSize / 1024 / 1024).toFixed(2)} MB`);
console.log(`Average file size: ${(stats.totalSize / stats.totalFiles / 1024).toFixed(2)} KB`);
console.log(`Analysis time: ${(totalTime / 1000).toFixed(2)} seconds`);
console.log('\nCATEGORY DISTRIBUTION:');
stats.categories.forEach((count, category) => {
const percentage = (count / stats.totalFiles * 100).toFixed(1);
console.log(` ${category}: ${count} files (${percentage}%)`);
});
console.log('\nFORMAT DISTRIBUTION:');
const sortedFormats = Array.from(stats.formats.entries()).sort((a, b) => b[1] - a[1]);
sortedFormats.forEach(([format, count]) => {
const percentage = (count / stats.totalFiles * 100).toFixed(1);
console.log(` ${format}: ${count} files (${percentage}%)`);
});
console.log('\nFILE SIZE DISTRIBUTION:');
console.log(` Tiny (<10KB): ${stats.fileSizes.tiny} files`);
console.log(` Small (10-50KB): ${stats.fileSizes.small} files`);
console.log(` Medium (50-200KB): ${stats.fileSizes.medium} files`);
console.log(` Large (200KB-1MB): ${stats.fileSizes.large} files`);
console.log(` Very Large (>1MB): ${stats.fileSizes.veryLarge} files`);
console.log('\nXML CHARACTERISTICS:');
console.log(' Encodings:');
stats.xmlCharacteristics.encodings.forEach((count, encoding) => {
console.log(` ${encoding}: ${count} files`);
});
console.log(' Root Elements:');
const topRootElements = Array.from(stats.xmlCharacteristics.rootElements.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
topRootElements.forEach(([element, count]) => {
console.log(` ${element}: ${count} files`);
});
console.log(' Top Namespaces:');
const topNamespaces = Array.from(stats.xmlCharacteristics.namespaces.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
topNamespaces.forEach(([ns, count]) => {
console.log(` ${ns.substring(0, 60)}...: ${count} files`);
});
console.log('\nINVOICE DATA STATISTICS:');
console.log(' Currencies:');
const sortedCurrencies = Array.from(stats.invoiceData.currencies.entries())
.sort((a, b) => b[1] - a[1]);
sortedCurrencies.forEach(([currency, count]) => {
console.log(` ${currency}: ${count} invoices`);
});
console.log(' Countries:');
const sortedCountries = Array.from(stats.invoiceData.countries.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
sortedCountries.forEach(([country, count]) => {
console.log(` ${country}: ${count} invoices`);
});
console.log(' Tax Rates:');
const sortedTaxRates = Array.from(stats.invoiceData.taxRates.entries())
.sort((a, b) => a[0] - b[0]);
sortedTaxRates.forEach(([rate, count]) => {
console.log(` ${rate}%: ${count} occurrences`);
});
console.log(' Line Item Counts:');
const sortedItemCounts = Array.from(stats.invoiceData.itemCounts.entries())
.sort((a, b) => {
const aNum = parseInt(a[0].split('-')[0]);
const bNum = parseInt(b[0].split('-')[0]);
return aNum - bNum;
});
sortedItemCounts.forEach(([bucket, count]) => {
console.log(` ${bucket}: ${count} invoices`);
});
console.log(' Document Types:');
stats.invoiceData.documentTypes.forEach((count, type) => {
console.log(` ${type}: ${count} documents`);
});
console.log('\nVALIDATION STATISTICS:');
const parseRate = (stats.validationResults.parseSuccess /
(stats.validationResults.parseSuccess + stats.validationResults.parseFailed) * 100).toFixed(1);
const validationRate = (stats.validationResults.validationSuccess /
(stats.validationResults.validationSuccess + stats.validationResults.validationFailed) * 100).toFixed(1);
console.log(` Parse success rate: ${parseRate}%`);
console.log(` Validation success rate: ${validationRate}%`);
console.log(` Successfully parsed: ${stats.validationResults.parseSuccess}`);
console.log(` Parse failures: ${stats.validationResults.parseFailed}`);
console.log(` Successfully validated: ${stats.validationResults.validationSuccess}`);
console.log(` Validation failures: ${stats.validationResults.validationFailed}`);
// Save statistics to file
const statsReport = {
generatedAt: new Date().toISOString(),
analysisTime: totalTime,
summary: {
totalFiles: stats.totalFiles,
totalSizeMB: stats.totalSize / 1024 / 1024,
parseSuccessRate: parseRate,
validationSuccessRate: validationRate
},
details: stats
};
try {
const reportPath = path.join(process.cwd(), '.nogit', 'corpus-statistics.json');
await fs.mkdir(path.dirname(reportPath), { recursive: true });
await fs.writeFile(reportPath, JSON.stringify(statsReport, null, 2));
console.log(`\nDetailed statistics saved to: ${reportPath}`);
} catch (e) {
console.log('\nCould not save statistics file:', e);
}
// Assertions
expect(stats.totalFiles).toBeGreaterThan(100);
expect(stats.formats.size).toBeGreaterThan(3);
expect(parseFloat(parseRate)).toBeGreaterThan(70);
t.pass('Corpus statistics generated successfully');
});
// Helper function to detect format from file
function detectFormatFromFile(filePath: string, category: string): string {
const filename = path.basename(filePath).toLowerCase();
if (filename.includes('.ubl.')) return 'UBL';
if (filename.includes('.cii.')) return 'CII';
if (filename.includes('zugferd')) return 'ZUGFeRD';
if (filename.includes('factur')) return 'Factur-X';
if (filename.includes('fattura')) return 'FatturaPA';
if (filename.includes('peppol')) return 'PEPPOL';
if (filename.includes('xrechnung')) return 'XRechnung';
// Fallback to category
if (category.includes('UBL')) return 'UBL';
if (category.includes('CII')) return 'CII';
if (category.includes('ZUGFERD')) return 'ZUGFeRD';
if (category.includes('PEPPOL')) return 'PEPPOL';
if (category.includes('FATTURA')) return 'FatturaPA';
return 'Unknown';
}
// Helper function to analyze XML characteristics
function analyzeXMLCharacteristics(xml: string): {
encoding?: string;
rootElement?: string;
namespaces: string[];
} {
const result: any = { namespaces: [] };
// Extract encoding
const encodingMatch = xml.match(/encoding="([^"]+)"/);
if (encodingMatch) {
result.encoding = encodingMatch[1];
}
// Extract root element
const rootMatch = xml.match(/<([^\s>]+)[\s>]/);
if (rootMatch) {
result.rootElement = rootMatch[1].split(':').pop();
}
// Extract namespaces
const nsMatches = xml.matchAll(/xmlns(?::[^=]+)?="([^"]+)"/g);
for (const match of nsMatches) {
result.namespaces.push(match[1]);
}
return result;
}
// Helper function to bucket item counts
function getItemCountBucket(count: number): string {
if (count === 1) return '1';
if (count <= 5) return '2-5';
if (count <= 10) return '6-10';
if (count <= 20) return '11-20';
if (count <= 50) return '21-50';
if (count <= 100) return '51-100';
return '100+';
}
tap.start();

View File

@ -0,0 +1,416 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EInvoice } from '../../../ts/index.js';
import { InvoiceFormat, ValidationLevel } from '../../../ts/interfaces/common.js';
import { CorpusLoader, PerformanceTracker } from '../../helpers/test-utils.js';
import * as path from 'path';
import * as fs from 'fs/promises';
import * as crypto from 'crypto';
/**
* Test ID: CORP-10
* Test Description: Regression Testing
* Priority: High
*
* This test ensures that processing results remain consistent across versions
* by comparing current results with baseline snapshots.
*/
tap.test('CORP-10: Regression Testing - should maintain consistent processing results', async (t) => {
const baselinePath = path.join(process.cwd(), '.nogit', 'regression-baseline.json');
const currentResultsPath = path.join(process.cwd(), '.nogit', 'regression-current.json');
// Load or create baseline
let baseline: RegressionBaseline | null = null;
try {
const baselineData = await fs.readFile(baselinePath, 'utf-8');
baseline = JSON.parse(baselineData);
console.log(`Loaded baseline from ${baseline?.date}`);
} catch (e) {
console.log('No baseline found, will create one');
}
// Select representative test files
const testSets = [
{ category: 'XML_RECHNUNG_UBL', files: 2 },
{ category: 'XML_RECHNUNG_CII', files: 2 },
{ category: 'ZUGFERD_V2_CORRECT', files: 2 },
{ category: 'PEPPOL', files: 1 }
];
const currentResults: RegressionResults = {
date: new Date().toISOString(),
version: process.env.npm_package_version || 'unknown',
files: new Map(),
aggregates: {
totalFiles: 0,
parseSuccesses: 0,
validationSuccesses: 0,
avgParseTime: 0,
fieldCounts: new Map()
}
};
const regressions: RegressionIssue[] = [];
// Process test files
for (const testSet of testSets) {
try {
const files = await CorpusLoader.loadCategory(testSet.category);
const selectedFiles = files.slice(0, testSet.files);
for (const file of selectedFiles) {
currentResults.aggregates.totalFiles++;
const fileResult: FileResult = {
path: file.path,
size: file.size,
hash: '',
parseSuccess: false,
validationSuccess: false,
parseTime: 0,
extractedData: {}
};
try {
// Calculate file hash
const fileBuffer = await CorpusLoader.loadFile(file.path);
fileResult.hash = crypto.createHash('md5').update(fileBuffer).digest('hex');
// Parse and measure time
const startTime = Date.now();
const invoice = new EInvoice();
const xmlString = fileBuffer.toString('utf-8');
await invoice.fromXmlString(xmlString);
fileResult.parseTime = Date.now() - startTime;
fileResult.parseSuccess = true;
currentResults.aggregates.parseSuccesses++;
// Extract key data for comparison
fileResult.extractedData = {
format: invoice.metadata?.format,
id: invoice.id,
issueDate: invoice.issueDate?.toISOString(),
currency: invoice.currency,
sellerName: invoice.from?.name,
sellerVAT: invoice.from?.vatNumber,
buyerName: invoice.to?.name,
itemCount: invoice.items?.length || 0,
totalNet: invoice.totalNet,
totalGross: invoice.totalGross,
taxBreakdown: invoice.taxBreakdown?.map(t => ({
rate: t.taxPercent,
amount: t.taxAmount
}))
};
// Count fields
const fieldCount = countFields(fileResult.extractedData);
currentResults.aggregates.fieldCounts.set(file.path, fieldCount);
// Validate
try {
const validationResult = await invoice.validate(ValidationLevel.EXTENDED);
fileResult.validationSuccess = validationResult.valid;
if (validationResult.valid) {
currentResults.aggregates.validationSuccesses++;
}
fileResult.validationErrors = validationResult.errors?.map(e => e.code || e.message);
} catch (valError: any) {
fileResult.validationSuccess = false;
fileResult.validationErrors = [valError.message];
}
} catch (parseError: any) {
fileResult.parseSuccess = false;
fileResult.parseError = parseError.message;
}
currentResults.files.set(file.path, fileResult);
// Compare with baseline if available
if (baseline) {
const baselineFile = baseline.files.get(file.path);
if (baselineFile) {
const regression = compareResults(file.path, baselineFile, fileResult);
if (regression) {
regressions.push(regression);
}
}
}
}
} catch (e) {
console.log(`Error processing ${testSet.category}: ${e}`);
}
}
// Calculate aggregates
const parseTimes = Array.from(currentResults.files.values())
.filter(f => f.parseSuccess)
.map(f => f.parseTime);
currentResults.aggregates.avgParseTime = parseTimes.length > 0 ?
parseTimes.reduce((a, b) => a + b, 0) / parseTimes.length : 0;
// Report results
console.log('\n=== REGRESSION TEST RESULTS ===\n');
console.log(`Total files tested: ${currentResults.aggregates.totalFiles}`);
console.log(`Parse successes: ${currentResults.aggregates.parseSuccesses}`);
console.log(`Validation successes: ${currentResults.aggregates.validationSuccesses}`);
console.log(`Average parse time: ${currentResults.aggregates.avgParseTime.toFixed(2)}ms`);
if (baseline) {
console.log('\nCOMPARISON WITH BASELINE:');
// Compare aggregates
const parseRateDiff = (currentResults.aggregates.parseSuccesses / currentResults.aggregates.totalFiles) -
(baseline.aggregates.parseSuccesses / baseline.aggregates.totalFiles);
const validationRateDiff = (currentResults.aggregates.validationSuccesses / currentResults.aggregates.totalFiles) -
(baseline.aggregates.validationSuccesses / baseline.aggregates.totalFiles);
const parseTimeDiff = currentResults.aggregates.avgParseTime - baseline.aggregates.avgParseTime;
console.log(` Parse rate change: ${(parseRateDiff * 100).toFixed(2)}%`);
console.log(` Validation rate change: ${(validationRateDiff * 100).toFixed(2)}%`);
console.log(` Parse time change: ${parseTimeDiff > 0 ? '+' : ''}${parseTimeDiff.toFixed(2)}ms`);
// Performance regression check
if (parseTimeDiff > baseline.aggregates.avgParseTime * 0.2) {
regressions.push({
type: 'performance',
file: 'aggregate',
message: `Performance regression: average parse time increased by ${((parseTimeDiff / baseline.aggregates.avgParseTime) * 100).toFixed(1)}%`
});
}
if (regressions.length > 0) {
console.log('\nREGRESSIONS DETECTED:');
regressions.forEach(r => {
console.log(` [${r.type}] ${r.file}: ${r.message}`);
if (r.details) {
console.log(` Details: ${r.details}`);
}
});
} else {
console.log('\n✓ No regressions detected');
}
}
// Save current results
try {
await fs.mkdir(path.dirname(currentResultsPath), { recursive: true });
// Convert Map to object for JSON serialization
const resultsForSave = {
...currentResults,
files: Object.fromEntries(currentResults.files),
aggregates: {
...currentResults.aggregates,
fieldCounts: Object.fromEntries(currentResults.aggregates.fieldCounts)
}
};
await fs.writeFile(currentResultsPath, JSON.stringify(resultsForSave, null, 2));
console.log(`\nCurrent results saved to: ${currentResultsPath}`);
if (!baseline) {
// Create baseline if it doesn't exist
await fs.writeFile(baselinePath, JSON.stringify(resultsForSave, null, 2));
console.log(`Baseline created at: ${baselinePath}`);
}
} catch (e) {
console.error('Error saving results:', e);
}
// Test specific regression scenarios
t.test('Field extraction consistency', async (st) => {
const criticalFields = ['id', 'currency', 'sellerVAT', 'totalNet'];
let fieldConsistency = true;
currentResults.files.forEach((result, filePath) => {
if (result.parseSuccess && baseline) {
const baselineResult = baseline.files.get(filePath);
if (baselineResult?.parseSuccess) {
for (const field of criticalFields) {
const current = result.extractedData[field];
const base = baselineResult.extractedData[field];
if (current !== base && !(current === undefined && base === null)) {
st.fail(`Field ${field} changed in ${path.basename(filePath)}: ${base} -> ${current}`);
fieldConsistency = false;
}
}
}
}
});
if (fieldConsistency) {
st.pass('✓ Critical fields remain consistent');
}
});
t.test('Validation stability', async (st) => {
let validationStable = true;
currentResults.files.forEach((result, filePath) => {
if (baseline) {
const baselineResult = baseline.files.get(filePath);
if (baselineResult) {
if (result.validationSuccess !== baselineResult.validationSuccess) {
st.fail(`Validation result changed for ${path.basename(filePath)}: ${baselineResult.validationSuccess} -> ${result.validationSuccess}`);
validationStable = false;
}
}
}
});
if (validationStable) {
st.pass('✓ Validation results remain stable');
}
});
t.test('Performance benchmarks', async (st) => {
// Test that parsing doesn't exceed thresholds
const performanceThresholds = {
small: 50, // < 50KB files should parse in < 50ms
medium: 100, // < 200KB files should parse in < 100ms
large: 500 // > 200KB files should parse in < 500ms
};
let performanceOk = true;
currentResults.files.forEach((result, filePath) => {
if (result.parseSuccess) {
const threshold = result.size < 50 * 1024 ? performanceThresholds.small :
result.size < 200 * 1024 ? performanceThresholds.medium :
performanceThresholds.large;
if (result.parseTime > threshold) {
st.comment(`Performance warning: ${path.basename(filePath)} (${(result.size/1024).toFixed(0)}KB) took ${result.parseTime}ms (threshold: ${threshold}ms)`);
performanceOk = false;
}
}
});
if (performanceOk) {
st.pass('✓ All files parsed within performance thresholds');
}
});
// Assertions
expect(regressions.length).toBeLessThan(3); // Allow maximum 2 regressions
expect(currentResults.aggregates.parseSuccesses).toBeGreaterThan(currentResults.aggregates.totalFiles * 0.9);
if (baseline) {
// Parse rate should not decrease by more than 5%
const currentParseRate = currentResults.aggregates.parseSuccesses / currentResults.aggregates.totalFiles;
const baselineParseRate = baseline.aggregates.parseSuccesses / baseline.aggregates.totalFiles;
expect(currentParseRate).toBeGreaterThan(baselineParseRate * 0.95);
}
});
// Type definitions
interface RegressionBaseline {
date: string;
version: string;
files: Map<string, FileResult>;
aggregates: {
totalFiles: number;
parseSuccesses: number;
validationSuccesses: number;
avgParseTime: number;
fieldCounts: Map<string, number>;
};
}
interface RegressionResults extends RegressionBaseline {}
interface FileResult {
path: string;
size: number;
hash: string;
parseSuccess: boolean;
parseError?: string;
parseTime: number;
validationSuccess: boolean;
validationErrors?: string[];
extractedData: any;
}
interface RegressionIssue {
type: 'parse' | 'validation' | 'data' | 'performance';
file: string;
message: string;
details?: string;
}
// Helper function to compare results
function compareResults(filePath: string, baseline: FileResult, current: FileResult): RegressionIssue | null {
// Check parse regression
if (baseline.parseSuccess && !current.parseSuccess) {
return {
type: 'parse',
file: path.basename(filePath),
message: 'File no longer parses successfully',
details: current.parseError
};
}
// Check validation regression
if (baseline.validationSuccess && !current.validationSuccess) {
return {
type: 'validation',
file: path.basename(filePath),
message: 'File no longer validates successfully',
details: current.validationErrors?.join(', ')
};
}
// Check data consistency (only for successfully parsed files)
if (baseline.parseSuccess && current.parseSuccess) {
const criticalFields = ['id', 'currency', 'totalNet', 'itemCount'];
for (const field of criticalFields) {
const baseValue = baseline.extractedData[field];
const currValue = current.extractedData[field];
if (baseValue !== currValue && !(baseValue === null && currValue === undefined)) {
return {
type: 'data',
file: path.basename(filePath),
message: `Field '${field}' value changed`,
details: `${baseValue} -> ${currValue}`
};
}
}
}
// Check performance regression (>50% increase)
if (baseline.parseSuccess && current.parseSuccess) {
if (current.parseTime > baseline.parseTime * 1.5) {
return {
type: 'performance',
file: path.basename(filePath),
message: 'Significant performance degradation',
details: `${baseline.parseTime}ms -> ${current.parseTime}ms`
};
}
}
return null;
}
// Helper function to count fields
function countFields(obj: any, depth = 0): number {
if (depth > 5) return 0; // Prevent infinite recursion
let count = 0;
for (const key in obj) {
if (obj[key] !== null && obj[key] !== undefined) {
count++;
if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
count += countFields(obj[key], depth + 1);
}
}
}
return count;
}
tap.start();