feat(compliance): improve compliance
This commit is contained in:
@ -3,488 +3,495 @@
|
||||
* @description Performance benchmarks for format conversion operations
|
||||
*/
|
||||
|
||||
import { tap } from '@git.zone/tstest/tapbundle';
|
||||
import { expect, 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('CONV-12: Conversion Performance');
|
||||
|
||||
tap.test('CONV-12: Conversion Performance - should meet performance targets for conversion operations', async (t) => {
|
||||
// Test 1: Single conversion performance benchmarks
|
||||
const singleConversionBenchmarks = await performanceTracker.measureAsync(
|
||||
'single-conversion-benchmarks',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const benchmarks = [];
|
||||
|
||||
// Define conversion scenarios
|
||||
const scenarios = [
|
||||
{ from: 'ubl', to: 'cii', name: 'UBL to CII' },
|
||||
{ from: 'cii', to: 'ubl', name: 'CII to UBL' },
|
||||
{ from: 'ubl', to: 'xrechnung', name: 'UBL to XRechnung' },
|
||||
{ from: 'cii', to: 'zugferd', name: 'CII to ZUGFeRD' },
|
||||
{ from: 'zugferd', to: 'xrechnung', name: 'ZUGFeRD to XRechnung' }
|
||||
];
|
||||
|
||||
// Create test invoices for each format
|
||||
const testInvoices = {
|
||||
ubl: {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'PERF-UBL-001',
|
||||
issueDate: '2024-01-30',
|
||||
seller: { name: 'UBL Seller', address: 'UBL Street', country: 'US', taxId: 'US123456789' },
|
||||
buyer: { name: 'UBL Buyer', address: 'UBL Avenue', country: 'US', taxId: 'US987654321' },
|
||||
items: [{ description: 'Product', quantity: 1, unitPrice: 100, vatRate: 10, lineTotal: 100 }],
|
||||
totals: { netAmount: 100, vatAmount: 10, grossAmount: 110 }
|
||||
}
|
||||
},
|
||||
cii: {
|
||||
format: 'cii' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'PERF-CII-001',
|
||||
issueDate: '2024-01-30',
|
||||
seller: { name: 'CII Seller', address: 'CII Street', country: 'DE', taxId: 'DE123456789' },
|
||||
buyer: { name: 'CII Buyer', address: 'CII Avenue', country: 'DE', taxId: 'DE987654321' },
|
||||
items: [{ description: 'Service', quantity: 1, unitPrice: 200, vatRate: 19, lineTotal: 200 }],
|
||||
totals: { netAmount: 200, vatAmount: 38, grossAmount: 238 }
|
||||
}
|
||||
},
|
||||
zugferd: {
|
||||
format: 'zugferd' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'PERF-ZF-001',
|
||||
issueDate: '2024-01-30',
|
||||
seller: { name: 'ZF Seller', address: 'ZF Street', country: 'DE', taxId: 'DE111222333' },
|
||||
buyer: { name: 'ZF Buyer', address: 'ZF Avenue', country: 'DE', taxId: 'DE444555666' },
|
||||
items: [{ description: 'Goods', quantity: 5, unitPrice: 50, vatRate: 19, lineTotal: 250 }],
|
||||
totals: { netAmount: 250, vatAmount: 47.50, grossAmount: 297.50 }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Run benchmarks
|
||||
for (const scenario of scenarios) {
|
||||
if (!testInvoices[scenario.from]) continue;
|
||||
|
||||
const iterations = 10;
|
||||
const times = [];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const startTime = process.hrtime.bigint();
|
||||
|
||||
try {
|
||||
await einvoice.convertFormat(testInvoices[scenario.from], scenario.to);
|
||||
const endTime = process.hrtime.bigint();
|
||||
const duration = Number(endTime - startTime) / 1_000_000; // Convert to milliseconds
|
||||
times.push(duration);
|
||||
} catch (error) {
|
||||
// Conversion not supported
|
||||
}
|
||||
}
|
||||
|
||||
if (times.length > 0) {
|
||||
times.sort((a, b) => a - b);
|
||||
benchmarks.push({
|
||||
scenario: scenario.name,
|
||||
min: times[0],
|
||||
max: times[times.length - 1],
|
||||
avg: times.reduce((a, b) => a + b, 0) / times.length,
|
||||
median: times[Math.floor(times.length / 2)],
|
||||
p95: times[Math.floor(times.length * 0.95)] || times[times.length - 1]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
}
|
||||
);
|
||||
tap.test('CONV-12: Performance - should measure single XML load/export performance', async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const benchmarks = [];
|
||||
|
||||
// Test 2: Complex invoice conversion performance
|
||||
const complexInvoicePerformance = await performanceTracker.measureAsync(
|
||||
'complex-invoice-performance',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
// Define test scenarios
|
||||
const scenarios = [
|
||||
{ format: 'ubl', name: 'UBL Load/Export' },
|
||||
{ format: 'cii', name: 'CII Load/Export' }
|
||||
];
|
||||
|
||||
// Create test invoices for each format
|
||||
const testInvoices = {
|
||||
ubl: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
|
||||
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
|
||||
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
|
||||
<cbc:ID>PERF-UBL-001</cbc:ID>
|
||||
<cbc:IssueDate>2024-01-30</cbc:IssueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>UBL Performance Test Seller</cbc:Name>
|
||||
</cac:PartyName>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>UBL Performance Test Buyer</cbc:Name>
|
||||
</cac:PartyName>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
<cac:InvoiceLine>
|
||||
<cbc:ID>1</cbc:ID>
|
||||
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
|
||||
<cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount>
|
||||
<cac:Item>
|
||||
<cbc:Name>Product</cbc:Name>
|
||||
</cac:Item>
|
||||
<cac:Price>
|
||||
<cbc:PriceAmount currencyID="EUR">100.00</cbc:PriceAmount>
|
||||
</cac:Price>
|
||||
</cac:InvoiceLine>
|
||||
<cac:LegalMonetaryTotal>
|
||||
<cbc:PayableAmount currencyID="EUR">110.00</cbc:PayableAmount>
|
||||
</cac:LegalMonetaryTotal>
|
||||
</Invoice>`,
|
||||
cii: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rsm:CrossIndustryInvoice
|
||||
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
||||
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
|
||||
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
|
||||
<rsm:ExchangedDocument>
|
||||
<ram:ID>PERF-CII-001</ram:ID>
|
||||
<ram:TypeCode>380</ram:TypeCode>
|
||||
<ram:IssueDateTime>
|
||||
<udt:DateTimeString format="102">20240130</udt:DateTimeString>
|
||||
</ram:IssueDateTime>
|
||||
</rsm:ExchangedDocument>
|
||||
<rsm:SupplyChainTradeTransaction>
|
||||
<ram:ApplicableHeaderTradeAgreement>
|
||||
<ram:SellerTradeParty>
|
||||
<ram:Name>CII Performance Test Seller</ram:Name>
|
||||
</ram:SellerTradeParty>
|
||||
<ram:BuyerTradeParty>
|
||||
<ram:Name>CII Performance Test Buyer</ram:Name>
|
||||
</ram:BuyerTradeParty>
|
||||
</ram:ApplicableHeaderTradeAgreement>
|
||||
<ram:ApplicableHeaderTradeSettlement>
|
||||
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
|
||||
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
<ram:GrandTotalAmount>238.00</ram:GrandTotalAmount>
|
||||
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
</ram:ApplicableHeaderTradeSettlement>
|
||||
</rsm:SupplyChainTradeTransaction>
|
||||
</rsm:CrossIndustryInvoice>`
|
||||
};
|
||||
|
||||
// Run benchmarks
|
||||
for (const scenario of scenarios) {
|
||||
const iterations = 10;
|
||||
const times = [];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const startTime = process.hrtime.bigint();
|
||||
|
||||
// Create complex invoice with many items
|
||||
const complexInvoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'PERF-COMPLEX-001',
|
||||
issueDate: '2024-01-30',
|
||||
dueDate: '2024-02-29',
|
||||
currency: 'EUR',
|
||||
seller: {
|
||||
name: 'Complex International Trading Company Ltd.',
|
||||
address: 'Global Business Center, Tower A, Floor 25',
|
||||
city: 'London',
|
||||
postalCode: 'EC2M 7PY',
|
||||
country: 'GB',
|
||||
taxId: 'GB123456789',
|
||||
email: 'invoicing@complex-trading.com',
|
||||
phone: '+44 20 7123 4567',
|
||||
registrationNumber: 'UK12345678'
|
||||
},
|
||||
buyer: {
|
||||
name: 'Multinational Buyer Corporation GmbH',
|
||||
address: 'Industriestraße 100-200',
|
||||
city: 'Frankfurt',
|
||||
postalCode: '60311',
|
||||
country: 'DE',
|
||||
taxId: 'DE987654321',
|
||||
email: 'ap@buyer-corp.de',
|
||||
phone: '+49 69 9876 5432'
|
||||
},
|
||||
items: Array.from({ length: 100 }, (_, i) => ({
|
||||
description: `Product Line Item ${i + 1} - Detailed description with technical specifications and compliance information`,
|
||||
quantity: Math.floor(Math.random() * 100) + 1,
|
||||
unitPrice: Math.random() * 1000,
|
||||
vatRate: [7, 19, 21][Math.floor(Math.random() * 3)],
|
||||
lineTotal: 0, // Will be calculated
|
||||
itemId: `ITEM-${String(i + 1).padStart(4, '0')}`,
|
||||
additionalInfo: {
|
||||
weight: `${Math.random() * 10}kg`,
|
||||
dimensions: `${Math.random() * 100}x${Math.random() * 100}x${Math.random() * 100}cm`,
|
||||
countryOfOrigin: ['DE', 'FR', 'IT', 'CN', 'US'][Math.floor(Math.random() * 5)]
|
||||
}
|
||||
})),
|
||||
totals: {
|
||||
netAmount: 0,
|
||||
vatAmount: 0,
|
||||
grossAmount: 0
|
||||
},
|
||||
paymentTerms: 'Net 30 days, 2% discount for payment within 10 days',
|
||||
notes: 'This is a complex invoice with 100 line items for performance testing purposes. All items are subject to standard terms and conditions.'
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate totals
|
||||
complexInvoice.data.items.forEach(item => {
|
||||
item.lineTotal = item.quantity * item.unitPrice;
|
||||
complexInvoice.data.totals.netAmount += item.lineTotal;
|
||||
complexInvoice.data.totals.vatAmount += item.lineTotal * (item.vatRate / 100);
|
||||
});
|
||||
complexInvoice.data.totals.grossAmount = complexInvoice.data.totals.netAmount + complexInvoice.data.totals.vatAmount;
|
||||
|
||||
// Test conversions
|
||||
const conversions = ['cii', 'zugferd', 'xrechnung'];
|
||||
const results = [];
|
||||
|
||||
for (const targetFormat of conversions) {
|
||||
const startTime = process.hrtime.bigint();
|
||||
let success = false;
|
||||
let error = null;
|
||||
try {
|
||||
// Load XML
|
||||
await einvoice.loadXml(testInvoices[scenario.format]);
|
||||
|
||||
try {
|
||||
const converted = await einvoice.convertFormat(complexInvoice, targetFormat);
|
||||
success = converted !== null;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
// Export back to XML
|
||||
await einvoice.toXmlString(scenario.format as any);
|
||||
|
||||
const endTime = process.hrtime.bigint();
|
||||
const duration = Number(endTime - startTime) / 1_000_000; // Convert to milliseconds
|
||||
times.push(duration);
|
||||
} catch (error) {
|
||||
console.log(`Error in ${scenario.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (times.length > 0) {
|
||||
times.sort((a, b) => a - b);
|
||||
benchmarks.push({
|
||||
scenario: scenario.name,
|
||||
min: times[0],
|
||||
max: times[times.length - 1],
|
||||
avg: times.reduce((a, b) => a + b, 0) / times.length,
|
||||
median: times[Math.floor(times.length / 2)],
|
||||
p95: times[Math.floor(times.length * 0.95)] || times[times.length - 1]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nSingle Operation Benchmarks (10 iterations each):');
|
||||
benchmarks.forEach(bench => {
|
||||
console.log(` ${bench.scenario}:`);
|
||||
console.log(` - Min: ${bench.min.toFixed(2)}ms, Max: ${bench.max.toFixed(2)}ms`);
|
||||
console.log(` - Average: ${bench.avg.toFixed(2)}ms, Median: ${bench.median.toFixed(2)}ms, P95: ${bench.p95.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
expect(benchmarks.length).toBeGreaterThan(0);
|
||||
benchmarks.forEach(bench => {
|
||||
expect(bench.avg).toBeLessThan(100); // Should process in less than 100ms on average
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('CONV-12: Performance - should handle complex invoice with many items', async () => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Create complex invoice with many items
|
||||
const itemCount = 100;
|
||||
const complexInvoice = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
|
||||
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
|
||||
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
|
||||
<cbc:ID>PERF-COMPLEX-001</cbc:ID>
|
||||
<cbc:IssueDate>2024-01-30</cbc:IssueDate>
|
||||
<cbc:DueDate>2024-02-29</cbc:DueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
<cbc:Note>This is a complex invoice with ${itemCount} line items for performance testing purposes.</cbc:Note>
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>Complex International Trading Company Ltd.</cbc:Name>
|
||||
</cac:PartyName>
|
||||
<cac:PostalAddress>
|
||||
<cbc:StreetName>Global Business Center, Tower A, Floor 25</cbc:StreetName>
|
||||
<cbc:CityName>London</cbc:CityName>
|
||||
<cbc:PostalZone>EC2M 7PY</cbc:PostalZone>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>GB</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
<cac:PartyTaxScheme>
|
||||
<cbc:CompanyID>GB123456789</cbc:CompanyID>
|
||||
<cac:TaxScheme>
|
||||
<cbc:ID>VAT</cbc:ID>
|
||||
</cac:TaxScheme>
|
||||
</cac:PartyTaxScheme>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>Multinational Buyer Corporation GmbH</cbc:Name>
|
||||
</cac:PartyName>
|
||||
<cac:PostalAddress>
|
||||
<cbc:StreetName>Industriestraße 100-200</cbc:StreetName>
|
||||
<cbc:CityName>Frankfurt</cbc:CityName>
|
||||
<cbc:PostalZone>60311</cbc:PostalZone>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>DE</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
${Array.from({ length: itemCount }, (_, i) => `
|
||||
<cac:InvoiceLine>
|
||||
<cbc:ID>${i + 1}</cbc:ID>
|
||||
<cbc:InvoicedQuantity unitCode="EA">${Math.floor(Math.random() * 100) + 1}</cbc:InvoicedQuantity>
|
||||
<cbc:LineExtensionAmount currencyID="EUR">${(Math.random() * 1000).toFixed(2)}</cbc:LineExtensionAmount>
|
||||
<cac:Item>
|
||||
<cbc:Name>Product Line Item ${i + 1} - Detailed description with technical specifications</cbc:Name>
|
||||
</cac:Item>
|
||||
<cac:Price>
|
||||
<cbc:PriceAmount currencyID="EUR">${(Math.random() * 100).toFixed(2)}</cbc:PriceAmount>
|
||||
</cac:Price>
|
||||
</cac:InvoiceLine>`).join('')}
|
||||
<cac:LegalMonetaryTotal>
|
||||
<cbc:LineExtensionAmount currencyID="EUR">50000.00</cbc:LineExtensionAmount>
|
||||
<cbc:TaxExclusiveAmount currencyID="EUR">50000.00</cbc:TaxExclusiveAmount>
|
||||
<cbc:TaxInclusiveAmount currencyID="EUR">59500.00</cbc:TaxInclusiveAmount>
|
||||
<cbc:PayableAmount currencyID="EUR">59500.00</cbc:PayableAmount>
|
||||
</cac:LegalMonetaryTotal>
|
||||
</Invoice>`;
|
||||
|
||||
const results = [];
|
||||
const operations = ['load', 'export'];
|
||||
|
||||
for (const operation of operations) {
|
||||
const startTime = process.hrtime.bigint();
|
||||
let success = false;
|
||||
|
||||
try {
|
||||
if (operation === 'load') {
|
||||
await einvoice.loadXml(complexInvoice);
|
||||
success = einvoice.id === 'PERF-COMPLEX-001';
|
||||
} else {
|
||||
const exported = await einvoice.toXmlString('ubl');
|
||||
success = exported.includes('PERF-COMPLEX-001');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Error in ${operation}:`, e);
|
||||
}
|
||||
|
||||
const endTime = process.hrtime.bigint();
|
||||
const duration = Number(endTime - startTime) / 1_000_000;
|
||||
|
||||
results.push({
|
||||
operation,
|
||||
duration,
|
||||
success,
|
||||
itemsPerSecond: success ? (itemCount / (duration / 1000)).toFixed(2) : 'N/A'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\nComplex Invoice Performance (100 items):');
|
||||
results.forEach(result => {
|
||||
console.log(` ${result.operation}: ${result.duration.toFixed(2)}ms (${result.itemsPerSecond} items/sec) - ${result.success ? 'SUCCESS' : 'FAILED'}`);
|
||||
});
|
||||
|
||||
expect(results.filter(r => r.success).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('CONV-12: Performance - should analyze memory usage during operations', async () => {
|
||||
const memorySnapshots = [];
|
||||
|
||||
// Force garbage collection if available
|
||||
if (global.gc) global.gc();
|
||||
const baselineMemory = process.memoryUsage();
|
||||
|
||||
// Create invoices of increasing size
|
||||
const sizes = [1, 10, 50, 100];
|
||||
|
||||
for (const size of sizes) {
|
||||
const einvoice = new EInvoice();
|
||||
const invoice = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
|
||||
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
|
||||
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
|
||||
<cbc:ID>MEM-TEST-${size}</cbc:ID>
|
||||
<cbc:IssueDate>2024-01-30</cbc:IssueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>Memory Test Seller</cbc:Name>
|
||||
</cac:PartyName>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>Memory Test Buyer</cbc:Name>
|
||||
</cac:PartyName>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
${Array.from({ length: size }, (_, i) => `
|
||||
<cac:InvoiceLine>
|
||||
<cbc:ID>${i + 1}</cbc:ID>
|
||||
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
|
||||
<cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount>
|
||||
<cac:Item>
|
||||
<cbc:Name>Item ${i + 1} with a reasonably long description to simulate real-world data</cbc:Name>
|
||||
</cac:Item>
|
||||
<cac:Price>
|
||||
<cbc:PriceAmount currencyID="EUR">100.00</cbc:PriceAmount>
|
||||
</cac:Price>
|
||||
</cac:InvoiceLine>`).join('')}
|
||||
<cac:LegalMonetaryTotal>
|
||||
<cbc:PayableAmount currencyID="EUR">${size * 110}.00</cbc:PayableAmount>
|
||||
</cac:LegalMonetaryTotal>
|
||||
</Invoice>`;
|
||||
|
||||
// Measure memory before and after operations
|
||||
const beforeOperation = process.memoryUsage();
|
||||
|
||||
try {
|
||||
await einvoice.loadXml(invoice);
|
||||
await einvoice.toXmlString('ubl');
|
||||
|
||||
const afterOperation = process.memoryUsage();
|
||||
|
||||
memorySnapshots.push({
|
||||
items: size,
|
||||
heapUsedBefore: Math.round((beforeOperation.heapUsed - baselineMemory.heapUsed) / 1024 / 1024 * 100) / 100,
|
||||
heapUsedAfter: Math.round((afterOperation.heapUsed - baselineMemory.heapUsed) / 1024 / 1024 * 100) / 100,
|
||||
heapIncrease: Math.round((afterOperation.heapUsed - beforeOperation.heapUsed) / 1024 / 1024 * 100) / 100,
|
||||
external: Math.round((afterOperation.external - baselineMemory.external) / 1024 / 1024 * 100) / 100
|
||||
});
|
||||
} catch (error) {
|
||||
// Skip if operation fails
|
||||
}
|
||||
}
|
||||
|
||||
// Force garbage collection and measure final state
|
||||
if (global.gc) global.gc();
|
||||
const finalMemory = process.memoryUsage();
|
||||
|
||||
const totalMemoryIncrease = Math.round((finalMemory.heapUsed - baselineMemory.heapUsed) / 1024 / 1024 * 100) / 100;
|
||||
const memoryPerItem = memorySnapshots.length > 0 ?
|
||||
(memorySnapshots[memorySnapshots.length - 1].heapIncrease / sizes[sizes.length - 1]).toFixed(3) : 'N/A';
|
||||
|
||||
console.log('\nMemory Usage Analysis:');
|
||||
memorySnapshots.forEach(snap => {
|
||||
console.log(` ${snap.items} items: ${snap.heapIncrease}MB heap increase`);
|
||||
});
|
||||
console.log(` Total memory increase: ${totalMemoryIncrease}MB`);
|
||||
console.log(` Average memory per item: ${memoryPerItem}MB`);
|
||||
|
||||
expect(memorySnapshots.length).toBeGreaterThan(0);
|
||||
// Memory increase should be reasonable
|
||||
expect(totalMemoryIncrease).toBeLessThan(50);
|
||||
});
|
||||
|
||||
tap.test('CONV-12: Performance - should handle concurrent operations', async () => {
|
||||
const concurrencyLevels = [1, 5, 10];
|
||||
const results = [];
|
||||
|
||||
// Create test invoice
|
||||
const testInvoice = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
|
||||
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
|
||||
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
|
||||
<cbc:ID>CONC-TEST-001</cbc:ID>
|
||||
<cbc:IssueDate>2024-01-30</cbc:IssueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>Concurrent Seller</cbc:Name>
|
||||
</cac:PartyName>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>Concurrent Buyer</cbc:Name>
|
||||
</cac:PartyName>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
<cac:LegalMonetaryTotal>
|
||||
<cbc:PayableAmount currencyID="EUR">1100.00</cbc:PayableAmount>
|
||||
</cac:LegalMonetaryTotal>
|
||||
</Invoice>`;
|
||||
|
||||
for (const concurrency of concurrencyLevels) {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create concurrent load/export tasks
|
||||
const tasks = Array.from({ length: concurrency }, async () => {
|
||||
try {
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadXml(testInvoice);
|
||||
await einvoice.toXmlString('ubl');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const taskResults = await Promise.all(tasks);
|
||||
const endTime = Date.now();
|
||||
|
||||
const successful = taskResults.filter(r => r).length;
|
||||
const duration = endTime - startTime;
|
||||
const throughput = (successful / (duration / 1000)).toFixed(2);
|
||||
|
||||
results.push({
|
||||
concurrency,
|
||||
duration,
|
||||
successful,
|
||||
failed: concurrency - successful,
|
||||
throughput: `${throughput} operations/sec`
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\nConcurrent Operations Performance:');
|
||||
results.forEach(result => {
|
||||
console.log(` ${result.concurrency} concurrent: ${result.duration}ms total, ${result.throughput}`);
|
||||
});
|
||||
|
||||
expect(results.every(r => r.successful > 0)).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('CONV-12: Performance - should analyze corpus file processing performance', async () => {
|
||||
const corpusDir = plugins.path.join(process.cwd(), 'test/assets/corpus');
|
||||
const performanceData = {
|
||||
totalFiles: 0,
|
||||
successfulLoads: 0,
|
||||
processingTimes: [] as number[],
|
||||
sizeCategories: {
|
||||
small: { count: 0, avgTime: 0, totalTime: 0 }, // < 10KB
|
||||
medium: { count: 0, avgTime: 0, totalTime: 0 }, // 10KB - 100KB
|
||||
large: { count: 0, avgTime: 0, totalTime: 0 } // > 100KB
|
||||
}
|
||||
};
|
||||
|
||||
// Sample a few known corpus files
|
||||
const testFiles = [
|
||||
'XML-Rechnung/UBL/EN16931_Einfach.ubl.xml',
|
||||
'XML-Rechnung/CII/EN16931_Einfach.cii.xml',
|
||||
'XML-Rechnung/UBL/EN16931_Rabatte.ubl.xml',
|
||||
'XML-Rechnung/CII/EN16931_Rabatte.cii.xml',
|
||||
'PEPPOL/Valid/billing-3.0-invoice-full-sample.xml'
|
||||
];
|
||||
|
||||
for (const file of testFiles) {
|
||||
const fullPath = plugins.path.join(corpusDir, file);
|
||||
try {
|
||||
const content = await plugins.fs.readFile(fullPath, 'utf-8');
|
||||
const fileSize = Buffer.byteLength(content, 'utf-8');
|
||||
performanceData.totalFiles++;
|
||||
|
||||
// Categorize by size
|
||||
const sizeCategory = fileSize < 10240 ? 'small' :
|
||||
fileSize < 102400 ? 'medium' : 'large';
|
||||
|
||||
// Measure load time
|
||||
const startTime = process.hrtime.bigint();
|
||||
|
||||
try {
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadXml(content);
|
||||
|
||||
const endTime = process.hrtime.bigint();
|
||||
const duration = Number(endTime - startTime) / 1_000_000;
|
||||
|
||||
results.push({
|
||||
targetFormat,
|
||||
duration,
|
||||
success,
|
||||
error,
|
||||
itemsPerSecond: success ? (100 / (duration / 1000)).toFixed(2) : 'N/A'
|
||||
});
|
||||
if (einvoice.id) {
|
||||
performanceData.successfulLoads++;
|
||||
performanceData.processingTimes.push(duration);
|
||||
|
||||
// Update size category stats
|
||||
performanceData.sizeCategories[sizeCategory].count++;
|
||||
performanceData.sizeCategories[sizeCategory].totalTime += duration;
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip files that can't be loaded
|
||||
}
|
||||
|
||||
return {
|
||||
invoiceSize: {
|
||||
items: complexInvoice.data.items.length,
|
||||
netAmount: complexInvoice.data.totals.netAmount.toFixed(2),
|
||||
grossAmount: complexInvoice.data.totals.grossAmount.toFixed(2)
|
||||
},
|
||||
conversions: results
|
||||
};
|
||||
} catch (error) {
|
||||
// File doesn't exist
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Test 3: Memory usage during conversion
|
||||
const memoryUsageAnalysis = await performanceTracker.measureAsync(
|
||||
'memory-usage-analysis',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const memorySnapshots = [];
|
||||
|
||||
// Force garbage collection if available
|
||||
if (global.gc) global.gc();
|
||||
const baselineMemory = process.memoryUsage();
|
||||
|
||||
// Create invoices of increasing size
|
||||
const sizes = [1, 10, 50, 100, 200];
|
||||
|
||||
for (const size of sizes) {
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `MEM-TEST-${size}`,
|
||||
issueDate: '2024-01-30',
|
||||
seller: { name: 'Memory Test Seller', address: 'Test Street', country: 'US', taxId: 'US123456789' },
|
||||
buyer: { name: 'Memory Test Buyer', address: 'Test Avenue', country: 'US', taxId: 'US987654321' },
|
||||
items: Array.from({ length: size }, (_, i) => ({
|
||||
description: `Item ${i + 1} with a reasonably long description to simulate real-world data`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 10,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: { netAmount: size * 100, vatAmount: size * 10, grossAmount: size * 110 }
|
||||
}
|
||||
};
|
||||
|
||||
// Perform conversion and measure memory
|
||||
const beforeConversion = process.memoryUsage();
|
||||
|
||||
try {
|
||||
const converted = await einvoice.convertFormat(invoice, 'cii');
|
||||
|
||||
const afterConversion = process.memoryUsage();
|
||||
|
||||
memorySnapshots.push({
|
||||
items: size,
|
||||
heapUsedBefore: Math.round((beforeConversion.heapUsed - baselineMemory.heapUsed) / 1024 / 1024 * 100) / 100,
|
||||
heapUsedAfter: Math.round((afterConversion.heapUsed - baselineMemory.heapUsed) / 1024 / 1024 * 100) / 100,
|
||||
heapIncrease: Math.round((afterConversion.heapUsed - beforeConversion.heapUsed) / 1024 / 1024 * 100) / 100,
|
||||
external: Math.round((afterConversion.external - baselineMemory.external) / 1024 / 1024 * 100) / 100
|
||||
});
|
||||
} catch (error) {
|
||||
// Skip if conversion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Force garbage collection and measure final state
|
||||
if (global.gc) global.gc();
|
||||
const finalMemory = process.memoryUsage();
|
||||
|
||||
return {
|
||||
snapshots: memorySnapshots,
|
||||
totalMemoryIncrease: Math.round((finalMemory.heapUsed - baselineMemory.heapUsed) / 1024 / 1024 * 100) / 100,
|
||||
memoryPerItem: memorySnapshots.length > 0 ?
|
||||
(memorySnapshots[memorySnapshots.length - 1].heapIncrease / sizes[sizes.length - 1]).toFixed(3) : 'N/A'
|
||||
};
|
||||
// Calculate averages
|
||||
for (const category of Object.keys(performanceData.sizeCategories)) {
|
||||
const cat = performanceData.sizeCategories[category];
|
||||
if (cat.count > 0) {
|
||||
cat.avgTime = cat.totalTime / cat.count;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Test 4: Concurrent conversion performance
|
||||
const concurrentPerformance = await performanceTracker.measureAsync(
|
||||
'concurrent-conversion-performance',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const concurrencyLevels = [1, 5, 10, 20];
|
||||
const results = [];
|
||||
|
||||
// Create test invoice
|
||||
const testInvoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'CONC-TEST-001',
|
||||
issueDate: '2024-01-30',
|
||||
seller: { name: 'Concurrent Seller', address: 'Parallel Street', country: 'US', taxId: 'US123456789' },
|
||||
buyer: { name: 'Concurrent Buyer', address: 'Async Avenue', country: 'US', taxId: 'US987654321' },
|
||||
items: Array.from({ length: 10 }, (_, i) => ({
|
||||
description: `Concurrent Item ${i + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 10,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: { netAmount: 1000, vatAmount: 100, grossAmount: 1100 }
|
||||
}
|
||||
};
|
||||
|
||||
for (const concurrency of concurrencyLevels) {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create concurrent conversion tasks
|
||||
const tasks = Array.from({ length: concurrency }, () =>
|
||||
einvoice.convertFormat(testInvoice, 'cii').catch(() => null)
|
||||
);
|
||||
|
||||
const taskResults = await Promise.all(tasks);
|
||||
const endTime = Date.now();
|
||||
|
||||
const successful = taskResults.filter(r => r !== null).length;
|
||||
const duration = endTime - startTime;
|
||||
const throughput = (successful / (duration / 1000)).toFixed(2);
|
||||
|
||||
results.push({
|
||||
concurrency,
|
||||
duration,
|
||||
successful,
|
||||
failed: concurrency - successful,
|
||||
throughput: `${throughput} conversions/sec`
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
const avgProcessingTime = performanceData.processingTimes.length > 0 ?
|
||||
performanceData.processingTimes.reduce((a, b) => a + b, 0) / performanceData.processingTimes.length : 0;
|
||||
|
||||
console.log('\nCorpus File Processing Performance:');
|
||||
console.log(` Files tested: ${performanceData.totalFiles}`);
|
||||
console.log(` Successfully loaded: ${performanceData.successfulLoads}`);
|
||||
console.log(` Average processing time: ${avgProcessingTime.toFixed(2)}ms`);
|
||||
console.log(' By size:');
|
||||
Object.entries(performanceData.sizeCategories).forEach(([size, data]) => {
|
||||
if (data.count > 0) {
|
||||
console.log(` - ${size}: ${data.count} files, avg ${data.avgTime.toFixed(2)}ms`);
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Corpus conversion performance analysis
|
||||
const corpusPerformance = await performanceTracker.measureAsync(
|
||||
'corpus-conversion-performance',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const performanceData = {
|
||||
formatStats: new Map<string, { count: number; totalTime: number; minTime: number; maxTime: number }>(),
|
||||
sizeCategories: {
|
||||
small: { count: 0, avgTime: 0, totalTime: 0 }, // < 10KB
|
||||
medium: { count: 0, avgTime: 0, totalTime: 0 }, // 10KB - 100KB
|
||||
large: { count: 0, avgTime: 0, totalTime: 0 } // > 100KB
|
||||
},
|
||||
totalConversions: 0,
|
||||
failedConversions: 0
|
||||
};
|
||||
|
||||
// Sample files for performance testing
|
||||
const sampleFiles = files.slice(0, 50);
|
||||
|
||||
for (const file of sampleFiles) {
|
||||
try {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
const fileSize = Buffer.byteLength(content, 'utf-8');
|
||||
|
||||
// Categorize by size
|
||||
const sizeCategory = fileSize < 10240 ? 'small' :
|
||||
fileSize < 102400 ? 'medium' : 'large';
|
||||
|
||||
// Detect format and parse
|
||||
const format = await einvoice.detectFormat(content);
|
||||
if (!format || format === 'unknown') continue;
|
||||
|
||||
const parsed = await einvoice.parseInvoice(content, format);
|
||||
|
||||
// Measure conversion time
|
||||
const targetFormat = format === 'ubl' ? 'cii' : 'ubl';
|
||||
const startTime = process.hrtime.bigint();
|
||||
|
||||
try {
|
||||
await einvoice.convertFormat(parsed, targetFormat);
|
||||
const endTime = process.hrtime.bigint();
|
||||
const duration = Number(endTime - startTime) / 1_000_000;
|
||||
|
||||
// Update format stats
|
||||
if (!performanceData.formatStats.has(format)) {
|
||||
performanceData.formatStats.set(format, {
|
||||
count: 0,
|
||||
totalTime: 0,
|
||||
minTime: Infinity,
|
||||
maxTime: 0
|
||||
});
|
||||
}
|
||||
|
||||
const stats = performanceData.formatStats.get(format)!;
|
||||
stats.count++;
|
||||
stats.totalTime += duration;
|
||||
stats.minTime = Math.min(stats.minTime, duration);
|
||||
stats.maxTime = Math.max(stats.maxTime, duration);
|
||||
|
||||
// Update size category stats
|
||||
performanceData.sizeCategories[sizeCategory].count++;
|
||||
performanceData.sizeCategories[sizeCategory].totalTime += duration;
|
||||
|
||||
performanceData.totalConversions++;
|
||||
|
||||
} catch (convError) {
|
||||
performanceData.failedConversions++;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Skip files that can't be processed
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
for (const category of Object.keys(performanceData.sizeCategories)) {
|
||||
const cat = performanceData.sizeCategories[category];
|
||||
if (cat.count > 0) {
|
||||
cat.avgTime = cat.totalTime / cat.count;
|
||||
}
|
||||
}
|
||||
|
||||
// Format statistics
|
||||
const formatStatsSummary = Array.from(performanceData.formatStats.entries()).map(([format, stats]) => ({
|
||||
format,
|
||||
count: stats.count,
|
||||
avgTime: stats.count > 0 ? (stats.totalTime / stats.count).toFixed(2) : 'N/A',
|
||||
minTime: stats.minTime === Infinity ? 'N/A' : stats.minTime.toFixed(2),
|
||||
maxTime: stats.maxTime.toFixed(2)
|
||||
}));
|
||||
|
||||
return {
|
||||
totalConversions: performanceData.totalConversions,
|
||||
failedConversions: performanceData.failedConversions,
|
||||
successRate: ((performanceData.totalConversions - performanceData.failedConversions) / performanceData.totalConversions * 100).toFixed(2) + '%',
|
||||
formatStats: formatStatsSummary,
|
||||
sizeCategories: {
|
||||
small: { ...performanceData.sizeCategories.small, avgTime: performanceData.sizeCategories.small.avgTime.toFixed(2) },
|
||||
medium: { ...performanceData.sizeCategories.medium, avgTime: performanceData.sizeCategories.medium.avgTime.toFixed(2) },
|
||||
large: { ...performanceData.sizeCategories.large, avgTime: performanceData.sizeCategories.large.avgTime.toFixed(2) }
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== CONV-12: Conversion Performance Test Summary ===');
|
||||
|
||||
t.comment('\nSingle Conversion Benchmarks (10 iterations each):');
|
||||
singleConversionBenchmarks.result.forEach(bench => {
|
||||
t.comment(` ${bench.scenario}:`);
|
||||
t.comment(` - Min: ${bench.min.toFixed(2)}ms, Max: ${bench.max.toFixed(2)}ms`);
|
||||
t.comment(` - Average: ${bench.avg.toFixed(2)}ms, Median: ${bench.median.toFixed(2)}ms, P95: ${bench.p95.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
t.comment('\nComplex Invoice Performance (100 items):');
|
||||
t.comment(` Invoice size: ${complexInvoicePerformance.result.invoiceSize.items} items, €${complexInvoicePerformance.result.invoiceSize.grossAmount}`);
|
||||
complexInvoicePerformance.result.conversions.forEach(conv => {
|
||||
t.comment(` ${conv.targetFormat}: ${conv.duration.toFixed(2)}ms (${conv.itemsPerSecond} items/sec) - ${conv.success ? 'SUCCESS' : 'FAILED'}`);
|
||||
});
|
||||
|
||||
t.comment('\nMemory Usage Analysis:');
|
||||
memoryUsageAnalysis.result.snapshots.forEach(snap => {
|
||||
t.comment(` ${snap.items} items: ${snap.heapIncrease}MB heap increase`);
|
||||
});
|
||||
t.comment(` Average memory per item: ${memoryUsageAnalysis.result.memoryPerItem}MB`);
|
||||
|
||||
t.comment('\nConcurrent Conversion Performance:');
|
||||
concurrentPerformance.result.forEach(result => {
|
||||
t.comment(` ${result.concurrency} concurrent: ${result.duration}ms total, ${result.throughput}`);
|
||||
});
|
||||
|
||||
t.comment('\nCorpus Performance Analysis:');
|
||||
t.comment(` Total conversions: ${corpusPerformance.result.totalConversions}`);
|
||||
t.comment(` Success rate: ${corpusPerformance.result.successRate}`);
|
||||
t.comment(' By format:');
|
||||
corpusPerformance.result.formatStats.forEach(stat => {
|
||||
t.comment(` - ${stat.format}: ${stat.count} files, avg ${stat.avgTime}ms (min: ${stat.minTime}ms, max: ${stat.maxTime}ms)`);
|
||||
});
|
||||
t.comment(' By size:');
|
||||
Object.entries(corpusPerformance.result.sizeCategories).forEach(([size, data]: [string, any]) => {
|
||||
t.comment(` - ${size}: ${data.count} files, avg ${data.avgTime}ms`);
|
||||
});
|
||||
|
||||
// Performance summary
|
||||
t.comment('\n=== Overall Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
expect(performanceData.successfulLoads).toBeGreaterThan(0);
|
||||
// Average processing time should be reasonable
|
||||
expect(avgProcessingTime).toBeLessThan(500);
|
||||
});
|
||||
|
||||
tap.start();
|
Reference in New Issue
Block a user