/** * @file test.perf-04.conversion-throughput.ts * @description Performance tests for format conversion throughput */ import { tap } from '@git.zone/tstest/tapbundle'; import * as plugins from '../../plugins.js'; import { EInvoice } from '../../../ts/index.js'; import { CorpusLoader } from '../../suite/corpus.loader.js'; import { PerformanceTracker } from '../../suite/performance.tracker.js'; const corpusLoader = new CorpusLoader(); const performanceTracker = new PerformanceTracker('PERF-04: Conversion Throughput'); tap.test('PERF-04: Conversion Throughput - should achieve target throughput for format conversions', async (t) => { // Test 1: Single-threaded conversion throughput const singleThreadThroughput = await performanceTracker.measureAsync( 'single-thread-throughput', async () => { const einvoice = new EInvoice(); const results = { conversions: [], totalTime: 0, totalInvoices: 0, totalBytes: 0 }; // Create test invoices of varying complexity const testInvoices = [ // Simple invoice ...Array(20).fill(null).map((_, i) => ({ format: 'ubl' as const, targetFormat: 'cii' as const, complexity: 'simple', data: { documentType: 'INVOICE', invoiceNumber: `SIMPLE-${i + 1}`, issueDate: '2024-02-05', seller: { name: 'Simple Seller', address: 'Address', country: 'US', taxId: 'US123' }, buyer: { name: 'Simple Buyer', address: 'Address', country: 'US', taxId: 'US456' }, items: [{ description: 'Item', quantity: 1, unitPrice: 100, vatRate: 10, lineTotal: 100 }], totals: { netAmount: 100, vatAmount: 10, grossAmount: 110 } } })), // Medium complexity ...Array(10).fill(null).map((_, i) => ({ format: 'cii' as const, targetFormat: 'ubl' as const, complexity: 'medium', data: { documentType: 'INVOICE', invoiceNumber: `MEDIUM-${i + 1}`, issueDate: '2024-02-05', dueDate: '2024-03-05', seller: { name: 'Medium Complexity Seller GmbH', address: 'Hauptstraße 123', city: 'Berlin', postalCode: '10115', country: 'DE', taxId: 'DE123456789' }, buyer: { name: 'Medium Complexity Buyer Ltd', address: 'Business Street 456', city: 'Munich', postalCode: '80331', country: 'DE', taxId: 'DE987654321' }, items: Array.from({ length: 10 }, (_, j) => ({ description: `Product ${j + 1}`, quantity: j + 1, unitPrice: 50 + j * 10, vatRate: 19, lineTotal: (j + 1) * (50 + j * 10) })), totals: { netAmount: 1650, vatAmount: 313.50, grossAmount: 1963.50 } } })), // Complex invoice ...Array(5).fill(null).map((_, i) => ({ format: 'ubl' as const, targetFormat: 'zugferd' as const, complexity: 'complex', data: { documentType: 'INVOICE', invoiceNumber: `COMPLEX-${i + 1}`, issueDate: '2024-02-05', seller: { name: 'Complex International Corporation', address: 'Global Plaza 1', city: 'New York', country: 'US', taxId: 'US12-3456789', email: 'billing@complex.com', phone: '+1-212-555-0100' }, buyer: { name: 'Complex Buyer Enterprises', address: 'Commerce Center 2', city: 'London', country: 'GB', taxId: 'GB123456789', email: 'ap@buyer.co.uk' }, items: Array.from({ length: 50 }, (_, j) => ({ description: `Complex Product ${j + 1} with detailed specifications`, quantity: Math.floor(Math.random() * 20) + 1, unitPrice: Math.random() * 500, vatRate: [0, 5, 10, 20][Math.floor(Math.random() * 4)], lineTotal: 0 })), totals: { netAmount: 0, vatAmount: 0, grossAmount: 0 } } })) ]; // Calculate totals for complex invoices testInvoices.filter(inv => inv.complexity === 'complex').forEach(invoice => { invoice.data.items.forEach(item => { item.lineTotal = item.quantity * item.unitPrice; invoice.data.totals.netAmount += item.lineTotal; invoice.data.totals.vatAmount += item.lineTotal * (item.vatRate / 100); }); invoice.data.totals.grossAmount = invoice.data.totals.netAmount + invoice.data.totals.vatAmount; }); // Process all conversions const startTime = Date.now(); for (const testInvoice of testInvoices) { const invoice = { format: testInvoice.format, data: testInvoice.data }; const invoiceSize = JSON.stringify(invoice).length; const conversionStart = process.hrtime.bigint(); try { const converted = await einvoice.convertFormat(invoice, testInvoice.targetFormat); const conversionEnd = process.hrtime.bigint(); const duration = Number(conversionEnd - conversionStart) / 1_000_000; results.conversions.push({ complexity: testInvoice.complexity, from: testInvoice.format, to: testInvoice.targetFormat, duration, size: invoiceSize, success: true }); results.totalBytes += invoiceSize; } catch (error) { results.conversions.push({ complexity: testInvoice.complexity, from: testInvoice.format, to: testInvoice.targetFormat, duration: 0, size: invoiceSize, success: false }); } results.totalInvoices++; } results.totalTime = Date.now() - startTime; // Calculate throughput metrics const successfulConversions = results.conversions.filter(c => c.success); const throughputStats = { invoicesPerSecond: (successfulConversions.length / (results.totalTime / 1000)).toFixed(2), bytesPerSecond: (results.totalBytes / (results.totalTime / 1000) / 1024).toFixed(2), // KB/s avgConversionTime: successfulConversions.length > 0 ? (successfulConversions.reduce((sum, c) => sum + c.duration, 0) / successfulConversions.length).toFixed(3) : 'N/A' }; // Group by complexity const complexityStats = ['simple', 'medium', 'complex'].map(complexity => { const conversions = successfulConversions.filter(c => c.complexity === complexity); return { complexity, count: conversions.length, avgTime: conversions.length > 0 ? (conversions.reduce((sum, c) => sum + c.duration, 0) / conversions.length).toFixed(3) : 'N/A' }; }); return { ...results, throughputStats, complexityStats }; } ); // Test 2: Parallel conversion throughput const parallelThroughput = await performanceTracker.measureAsync( 'parallel-throughput', async () => { const einvoice = new EInvoice(); const results = []; // Create a batch of invoices const batchSize = 50; const testInvoices = Array.from({ length: batchSize }, (_, i) => ({ format: i % 2 === 0 ? 'ubl' : 'cii' as const, data: { documentType: 'INVOICE', invoiceNumber: `PARALLEL-${i + 1}`, issueDate: '2024-02-05', seller: { name: `Seller ${i + 1}`, address: 'Address', country: 'US', taxId: `US${i}` }, buyer: { name: `Buyer ${i + 1}`, address: 'Address', country: 'US', taxId: `US${i + 100}` }, items: Array.from({ length: 5 }, (_, j) => ({ description: `Item ${j + 1}`, quantity: 1, unitPrice: 100, vatRate: 10, lineTotal: 100 })), totals: { netAmount: 500, vatAmount: 50, grossAmount: 550 } } })); // Test different parallelism levels const parallelismLevels = [1, 2, 5, 10, 20]; for (const parallelism of parallelismLevels) { const startTime = Date.now(); let completed = 0; let failed = 0; // Process in batches for (let i = 0; i < testInvoices.length; i += parallelism) { const batch = testInvoices.slice(i, i + parallelism); const conversionPromises = batch.map(async (invoice) => { try { const targetFormat = invoice.format === 'ubl' ? 'cii' : 'ubl'; await einvoice.convertFormat(invoice, targetFormat); return true; } catch { return false; } }); const batchResults = await Promise.all(conversionPromises); completed += batchResults.filter(r => r).length; failed += batchResults.filter(r => !r).length; } const totalTime = Date.now() - startTime; const throughput = (completed / (totalTime / 1000)).toFixed(2); results.push({ parallelism, totalTime, completed, failed, throughput: `${throughput} conversions/sec`, avgTimePerConversion: (totalTime / batchSize).toFixed(3) }); } return results; } ); // Test 3: Corpus conversion throughput const corpusThroughput = await performanceTracker.measureAsync( 'corpus-throughput', async () => { const files = await corpusLoader.getFilesByPattern('**/*.xml'); const einvoice = new EInvoice(); const results = { formatPairs: new Map(), overallStats: { totalConversions: 0, successfulConversions: 0, totalTime: 0, totalBytes: 0 } }; // Sample corpus files const sampleFiles = files.slice(0, 40); const startTime = Date.now(); for (const file of sampleFiles) { try { const content = await plugins.fs.readFile(file, 'utf-8'); const fileSize = Buffer.byteLength(content, 'utf-8'); // Detect and parse const format = await einvoice.detectFormat(content); if (!format || format === 'unknown') continue; const invoice = await einvoice.parseInvoice(content, format); // Determine target format const targetFormat = format === 'ubl' ? 'cii' : format === 'cii' ? 'ubl' : format === 'zugferd' ? 'xrechnung' : 'ubl'; const pairKey = `${format}->${targetFormat}`; // Measure conversion const conversionStart = process.hrtime.bigint(); try { await einvoice.convertFormat(invoice, targetFormat); const conversionEnd = process.hrtime.bigint(); const duration = Number(conversionEnd - conversionStart) / 1_000_000; // Update statistics if (!results.formatPairs.has(pairKey)) { results.formatPairs.set(pairKey, { count: 0, totalTime: 0, totalSize: 0 }); } const pairStats = results.formatPairs.get(pairKey)!; pairStats.count++; pairStats.totalTime += duration; pairStats.totalSize += fileSize; results.overallStats.successfulConversions++; results.overallStats.totalBytes += fileSize; } catch (error) { // Conversion failed } results.overallStats.totalConversions++; } catch (error) { // File processing failed } } results.overallStats.totalTime = Date.now() - startTime; // Calculate throughput by format pair const formatPairStats = Array.from(results.formatPairs.entries()).map(([pair, stats]) => ({ pair, count: stats.count, avgTime: (stats.totalTime / stats.count).toFixed(3), avgSize: (stats.totalSize / stats.count / 1024).toFixed(2), // KB throughput: ((stats.totalSize / 1024) / (stats.totalTime / 1000)).toFixed(2) // KB/s })); return { ...results.overallStats, successRate: ((results.overallStats.successfulConversions / results.overallStats.totalConversions) * 100).toFixed(1), overallThroughput: { invoicesPerSecond: (results.overallStats.successfulConversions / (results.overallStats.totalTime / 1000)).toFixed(2), kbPerSecond: ((results.overallStats.totalBytes / 1024) / (results.overallStats.totalTime / 1000)).toFixed(2) }, formatPairStats }; } ); // Test 4: Streaming conversion throughput const streamingThroughput = await performanceTracker.measureAsync( 'streaming-throughput', async () => { const einvoice = new EInvoice(); const results = { streamSize: 0, processedInvoices: 0, totalTime: 0, peakMemory: 0, errors: 0 }; // Simulate streaming scenario const invoiceStream = Array.from({ length: 100 }, (_, i) => ({ format: 'ubl' as const, data: { documentType: 'INVOICE', invoiceNumber: `STREAM-${i + 1}`, issueDate: '2024-02-05', seller: { name: `Stream Seller ${i + 1}`, address: 'Address', country: 'US', taxId: `US${i}` }, buyer: { name: `Stream Buyer ${i + 1}`, address: 'Address', country: 'US', taxId: `US${i + 1000}` }, items: Array.from({ length: Math.floor(Math.random() * 10) + 1 }, (_, j) => ({ description: `Stream Item ${j + 1}`, quantity: Math.random() * 10, unitPrice: Math.random() * 100, vatRate: [5, 10, 20][Math.floor(Math.random() * 3)], lineTotal: 0 })), totals: { netAmount: 0, vatAmount: 0, grossAmount: 0 } } })); // Calculate totals invoiceStream.forEach(invoice => { invoice.data.items.forEach(item => { item.lineTotal = item.quantity * item.unitPrice; invoice.data.totals.netAmount += item.lineTotal; invoice.data.totals.vatAmount += item.lineTotal * (item.vatRate / 100); }); invoice.data.totals.grossAmount = invoice.data.totals.netAmount + invoice.data.totals.vatAmount; results.streamSize += JSON.stringify(invoice).length; }); // Process stream const startTime = Date.now(); const initialMemory = process.memoryUsage().heapUsed; // Simulate streaming with chunks const chunkSize = 10; for (let i = 0; i < invoiceStream.length; i += chunkSize) { const chunk = invoiceStream.slice(i, i + chunkSize); // Process chunk in parallel const chunkPromises = chunk.map(async (invoice) => { try { await einvoice.convertFormat(invoice, 'cii'); results.processedInvoices++; } catch { results.errors++; } }); await Promise.all(chunkPromises); // Check memory usage const currentMemory = process.memoryUsage().heapUsed; if (currentMemory > results.peakMemory) { results.peakMemory = currentMemory; } } results.totalTime = Date.now() - startTime; return { ...results, throughput: { invoicesPerSecond: (results.processedInvoices / (results.totalTime / 1000)).toFixed(2), mbPerSecond: ((results.streamSize / 1024 / 1024) / (results.totalTime / 1000)).toFixed(2) }, memoryIncreaseMB: ((results.peakMemory - initialMemory) / 1024 / 1024).toFixed(2), successRate: ((results.processedInvoices / invoiceStream.length) * 100).toFixed(1) }; } ); // Test 5: Sustained throughput test const sustainedThroughput = await performanceTracker.measureAsync( 'sustained-throughput', async () => { const einvoice = new EInvoice(); const testDuration = 10000; // 10 seconds const results = { secondlyThroughput: [], totalConversions: 0, minThroughput: Infinity, maxThroughput: 0, avgThroughput: 0 }; // Test invoice template const testInvoice = { format: 'ubl' as const, data: { documentType: 'INVOICE', invoiceNumber: 'SUSTAINED-TEST', issueDate: '2024-02-05', seller: { name: 'Sustained Seller', address: 'Address', country: 'US', taxId: 'US123' }, buyer: { name: 'Sustained Buyer', address: 'Address', country: 'US', taxId: 'US456' }, items: [{ description: 'Item', quantity: 1, unitPrice: 100, vatRate: 10, lineTotal: 100 }], totals: { netAmount: 100, vatAmount: 10, grossAmount: 110 } } }; const startTime = Date.now(); let currentSecond = 0; let conversionsInCurrentSecond = 0; while (Date.now() - startTime < testDuration) { const elapsed = Date.now() - startTime; const second = Math.floor(elapsed / 1000); if (second > currentSecond) { // Record throughput for completed second results.secondlyThroughput.push(conversionsInCurrentSecond); if (conversionsInCurrentSecond < results.minThroughput) { results.minThroughput = conversionsInCurrentSecond; } if (conversionsInCurrentSecond > results.maxThroughput) { results.maxThroughput = conversionsInCurrentSecond; } currentSecond = second; conversionsInCurrentSecond = 0; } // Perform conversion try { await einvoice.convertFormat(testInvoice, 'cii'); conversionsInCurrentSecond++; results.totalConversions++; } catch { // Conversion failed } } // Calculate average if (results.secondlyThroughput.length > 0) { results.avgThroughput = results.secondlyThroughput.reduce((a, b) => a + b, 0) / results.secondlyThroughput.length; } return { duration: Math.floor((Date.now() - startTime) / 1000), totalConversions: results.totalConversions, minThroughput: results.minThroughput === Infinity ? 0 : results.minThroughput, maxThroughput: results.maxThroughput, avgThroughput: results.avgThroughput.toFixed(2), variance: results.secondlyThroughput.length > 0 ? Math.sqrt(results.secondlyThroughput.reduce((sum, val) => sum + Math.pow(val - results.avgThroughput, 2), 0) / results.secondlyThroughput.length).toFixed(2) : 0 }; } ); // Summary t.comment('\n=== PERF-04: Conversion Throughput Test Summary ==='); t.comment('\nSingle-Thread Throughput:'); t.comment(` Total conversions: ${singleThreadThroughput.result.totalInvoices}`); t.comment(` Successful: ${singleThreadThroughput.result.conversions.filter(c => c.success).length}`); t.comment(` Total time: ${singleThreadThroughput.result.totalTime}ms`); t.comment(` Throughput: ${singleThreadThroughput.result.throughputStats.invoicesPerSecond} invoices/sec`); t.comment(` Data rate: ${singleThreadThroughput.result.throughputStats.bytesPerSecond} KB/sec`); t.comment(' By complexity:'); singleThreadThroughput.result.complexityStats.forEach(stat => { t.comment(` - ${stat.complexity}: ${stat.count} invoices, avg ${stat.avgTime}ms`); }); t.comment('\nParallel Throughput:'); parallelThroughput.result.forEach(result => { t.comment(` ${result.parallelism} parallel: ${result.throughput}, avg ${result.avgTimePerConversion}ms/conversion`); }); t.comment('\nCorpus Throughput:'); t.comment(` Total conversions: ${corpusThroughput.result.totalConversions}`); t.comment(` Success rate: ${corpusThroughput.result.successRate}%`); t.comment(` Overall: ${corpusThroughput.result.overallThroughput.invoicesPerSecond} invoices/sec, ${corpusThroughput.result.overallThroughput.kbPerSecond} KB/sec`); t.comment(' By format pair:'); corpusThroughput.result.formatPairStats.slice(0, 5).forEach(stat => { t.comment(` - ${stat.pair}: ${stat.count} conversions, ${stat.throughput} KB/sec`); }); t.comment('\nStreaming Throughput:'); t.comment(` Processed: ${streamingThroughput.result.processedInvoices}/${streamingThroughput.result.processedInvoices + streamingThroughput.result.errors} invoices`); t.comment(` Success rate: ${streamingThroughput.result.successRate}%`); t.comment(` Throughput: ${streamingThroughput.result.throughput.invoicesPerSecond} invoices/sec`); t.comment(` Data rate: ${streamingThroughput.result.throughput.mbPerSecond} MB/sec`); t.comment(` Peak memory increase: ${streamingThroughput.result.memoryIncreaseMB} MB`); t.comment('\nSustained Throughput (10 seconds):'); t.comment(` Total conversions: ${sustainedThroughput.result.totalConversions}`); t.comment(` Min throughput: ${sustainedThroughput.result.minThroughput} conversions/sec`); t.comment(` Max throughput: ${sustainedThroughput.result.maxThroughput} conversions/sec`); t.comment(` Avg throughput: ${sustainedThroughput.result.avgThroughput} conversions/sec`); t.comment(` Std deviation: ${sustainedThroughput.result.variance}`); // Performance targets check t.comment('\n=== Performance Targets Check ==='); const avgThroughput = parseFloat(singleThreadThroughput.result.throughputStats.invoicesPerSecond); const targetThroughput = 10; // Target: >10 conversions/sec if (avgThroughput > targetThroughput) { t.comment(`✅ Conversion throughput meets target: ${avgThroughput} > ${targetThroughput} conversions/sec`); } else { t.comment(`⚠️ Conversion throughput below target: ${avgThroughput} < ${targetThroughput} conversions/sec`); } // Overall performance summary t.comment('\n=== Overall Performance Summary ==='); performanceTracker.logSummary(); t.end(); }); tap.start();