update
This commit is contained in:
436
test/suite/einvoice_conversion/test.conv-01.format-conversion.ts
Normal file
436
test/suite/einvoice_conversion/test.conv-01.format-conversion.ts
Normal file
@ -0,0 +1,436 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CorpusLoader } from '../../helpers/corpus.loader.js';
|
||||
import { PerformanceTracker } from '../../helpers/performance.tracker.js';
|
||||
|
||||
tap.test('CONV-01: Format Conversion - should convert between invoice formats', async () => {
|
||||
// Test conversion between CII and UBL using paired files
|
||||
const ciiFiles = await CorpusLoader.getFiles('CII_XMLRECHNUNG');
|
||||
const ublFiles = await CorpusLoader.getFiles('UBL_XMLRECHNUNG');
|
||||
|
||||
// Find paired files (same base name)
|
||||
const pairs: Array<{cii: string, ubl: string, name: string}> = [];
|
||||
|
||||
for (const ciiFile of ciiFiles) {
|
||||
const baseName = path.basename(ciiFile).replace('.cii.xml', '');
|
||||
const matchingUbl = ublFiles.find(ubl =>
|
||||
path.basename(ubl).startsWith(baseName) && ubl.endsWith('.ubl.xml')
|
||||
);
|
||||
|
||||
if (matchingUbl) {
|
||||
pairs.push({ cii: ciiFile, ubl: matchingUbl, name: baseName });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Found ${pairs.length} CII/UBL pairs for conversion testing`);
|
||||
|
||||
const { EInvoice } = await import('../../../ts/index.js');
|
||||
|
||||
let successCount = 0;
|
||||
const conversionIssues: string[] = [];
|
||||
|
||||
for (const pair of pairs.slice(0, 5)) { // Test first 5 pairs
|
||||
try {
|
||||
// Load CII invoice
|
||||
const ciiBuffer = await fs.readFile(pair.cii, 'utf-8');
|
||||
const ciiInvoice = await EInvoice.fromXml(ciiBuffer);
|
||||
|
||||
// Convert to UBL
|
||||
const { result: ublXml, metric } = await PerformanceTracker.track(
|
||||
'cii-to-ubl-conversion',
|
||||
async () => ciiInvoice.exportXml('ubl' as any),
|
||||
{ file: pair.name }
|
||||
);
|
||||
|
||||
expect(ublXml).toBeTruthy();
|
||||
expect(ublXml).toContain('xmlns:cbc=');
|
||||
expect(ublXml).toContain('xmlns:cac=');
|
||||
|
||||
// Load the converted UBL back
|
||||
const convertedInvoice = await EInvoice.fromXml(ublXml);
|
||||
|
||||
// Verify key fields are preserved
|
||||
verifyFieldMapping(ciiInvoice, convertedInvoice, pair.name);
|
||||
|
||||
successCount++;
|
||||
console.log(`✓ ${pair.name}: CII→UBL conversion successful (${metric.duration.toFixed(2)}ms)`);
|
||||
|
||||
} catch (error) {
|
||||
const issue = `${pair.name}: ${error.message}`;
|
||||
conversionIssues.push(issue);
|
||||
console.log(`✗ ${issue}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nCII→UBL Conversion Summary: ${successCount}/${Math.min(pairs.length, 5)} successful`);
|
||||
if (conversionIssues.length > 0) {
|
||||
console.log('Issues:', conversionIssues.slice(0, 3));
|
||||
}
|
||||
|
||||
// Performance summary
|
||||
const perfSummary = await PerformanceTracker.getSummary('cii-to-ubl-conversion');
|
||||
if (perfSummary) {
|
||||
console.log(`\nCII→UBL Conversion Performance:`);
|
||||
console.log(` Average: ${perfSummary.average.toFixed(2)}ms`);
|
||||
console.log(` P95: ${perfSummary.p95.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
expect(successCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('CONV-01: UBL to CII Conversion - should convert UBL invoices to CII format', async () => {
|
||||
const { EInvoice } = await import('../../../ts/index.js');
|
||||
|
||||
const ublFiles = await CorpusLoader.getFiles('UBL_XMLRECHNUNG');
|
||||
const testFiles = ublFiles.filter(f => f.endsWith('.xml')).slice(0, 3);
|
||||
|
||||
console.log(`Testing UBL to CII conversion with ${testFiles.length} files`);
|
||||
|
||||
let successCount = 0;
|
||||
let skipCount = 0;
|
||||
|
||||
for (const filePath of testFiles) {
|
||||
const fileName = path.basename(filePath);
|
||||
|
||||
try {
|
||||
const ublContent = await fs.readFile(filePath, 'utf-8');
|
||||
const ublInvoice = await EInvoice.fromXml(ublContent);
|
||||
|
||||
// Skip if detected as XRechnung (might have special requirements)
|
||||
const format = ublInvoice.getFormat ? ublInvoice.getFormat() : 'unknown';
|
||||
if (format.toString().toLowerCase().includes('xrechnung')) {
|
||||
console.log(`○ ${fileName}: Skipping XRechnung-specific file`);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert to CII (Factur-X)
|
||||
const { result: ciiXml, metric } = await PerformanceTracker.track(
|
||||
'ubl-to-cii-conversion',
|
||||
async () => ublInvoice.exportXml('facturx' as any),
|
||||
{ file: fileName }
|
||||
);
|
||||
|
||||
expect(ciiXml).toBeTruthy();
|
||||
expect(ciiXml).toContain('CrossIndustryInvoice');
|
||||
expect(ciiXml).toContain('ExchangedDocument');
|
||||
|
||||
// Verify round-trip
|
||||
const ciiInvoice = await EInvoice.fromXml(ciiXml);
|
||||
expect(ciiInvoice.invoiceId).toEqual(ublInvoice.invoiceId);
|
||||
|
||||
successCount++;
|
||||
console.log(`✓ ${fileName}: UBL→CII conversion successful (${metric.duration.toFixed(2)}ms)`);
|
||||
|
||||
} catch (error) {
|
||||
console.log(`✗ ${fileName}: Conversion failed - ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nUBL→CII Conversion Summary: ${successCount} successful, ${skipCount} skipped`);
|
||||
|
||||
// Performance summary
|
||||
const perfSummary = await PerformanceTracker.getSummary('ubl-to-cii-conversion');
|
||||
if (perfSummary) {
|
||||
console.log(`\nUBL→CII Conversion Performance:`);
|
||||
console.log(` Average: ${perfSummary.average.toFixed(2)}ms`);
|
||||
console.log(` P95: ${perfSummary.p95.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
expect(successCount + skipCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('CONV-01: ZUGFeRD to XRechnung Conversion - should convert ZUGFeRD PDFs to XRechnung', async () => {
|
||||
const { EInvoice } = await import('../../../ts/index.js');
|
||||
|
||||
const zugferdPdfs = await CorpusLoader.getFiles('ZUGFERD_V2_CORRECT');
|
||||
const pdfFiles = zugferdPdfs.filter(f => f.endsWith('.pdf')).slice(0, 3);
|
||||
|
||||
console.log(`Testing ZUGFeRD to XRechnung conversion with ${pdfFiles.length} PDFs`);
|
||||
|
||||
let tested = 0;
|
||||
let successful = 0;
|
||||
|
||||
for (const filePath of pdfFiles) {
|
||||
const fileName = path.basename(filePath);
|
||||
|
||||
try {
|
||||
// Extract from PDF
|
||||
const pdfBuffer = await fs.readFile(filePath);
|
||||
const zugferdInvoice = await EInvoice.fromPdf(pdfBuffer);
|
||||
|
||||
// Convert to XRechnung
|
||||
const { result: xrechnungXml, metric } = await PerformanceTracker.track(
|
||||
'zugferd-to-xrechnung-conversion',
|
||||
async () => zugferdInvoice.exportXml('xrechnung' as any),
|
||||
{ file: fileName }
|
||||
);
|
||||
|
||||
expect(xrechnungXml).toBeTruthy();
|
||||
|
||||
// XRechnung should be UBL format with specific extensions
|
||||
if (xrechnungXml.includes('Invoice xmlns')) {
|
||||
expect(xrechnungXml).toContain('CustomizationID');
|
||||
expect(xrechnungXml).toContain('urn:cen.eu:en16931');
|
||||
}
|
||||
|
||||
tested++;
|
||||
successful++;
|
||||
console.log(`✓ ${fileName}: ZUGFeRD→XRechnung conversion successful (${metric.duration.toFixed(2)}ms)`);
|
||||
|
||||
} catch (error) {
|
||||
tested++;
|
||||
console.log(`○ ${fileName}: Conversion not available - ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nZUGFeRD→XRechnung Conversion Summary: ${successful}/${tested} successful`);
|
||||
|
||||
if (successful === 0 && tested > 0) {
|
||||
console.log('Note: ZUGFeRD to XRechnung conversion may need implementation');
|
||||
}
|
||||
|
||||
// Performance summary
|
||||
const perfSummary = await PerformanceTracker.getSummary('zugferd-to-xrechnung-conversion');
|
||||
if (perfSummary) {
|
||||
console.log(`\nZUGFeRD→XRechnung Conversion Performance:`);
|
||||
console.log(` Average: ${perfSummary.average.toFixed(2)}ms`);
|
||||
console.log(` P95: ${perfSummary.p95.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
expect(tested).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('CONV-01: Data Preservation During Conversion - should preserve invoice data across formats', async () => {
|
||||
const { EInvoice } = await import('../../../ts/index.js');
|
||||
|
||||
// Create a test invoice with comprehensive data
|
||||
const testInvoice = new EInvoice();
|
||||
testInvoice.id = 'DATA-PRESERVATION-TEST';
|
||||
testInvoice.invoiceId = 'INV-2024-001';
|
||||
testInvoice.date = Date.now();
|
||||
testInvoice.currency = 'EUR';
|
||||
|
||||
testInvoice.from = {
|
||||
name: 'Test Seller GmbH',
|
||||
type: 'company',
|
||||
description: 'Test seller company',
|
||||
address: {
|
||||
streetName: 'Musterstraße',
|
||||
houseNumber: '123',
|
||||
city: 'Berlin',
|
||||
country: 'Germany',
|
||||
postalCode: '10115'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: { year: 2020, month: 1, day: 1 },
|
||||
registrationDetails: {
|
||||
vatId: 'DE123456789',
|
||||
registrationId: 'HRB 12345',
|
||||
registrationName: 'Handelsregister Berlin'
|
||||
}
|
||||
};
|
||||
|
||||
testInvoice.to = {
|
||||
name: 'Test Buyer Ltd',
|
||||
type: 'company',
|
||||
description: 'Test buyer company',
|
||||
address: {
|
||||
streetName: 'Example Street',
|
||||
houseNumber: '456',
|
||||
city: 'London',
|
||||
country: 'United Kingdom',
|
||||
postalCode: 'SW1A 1AA'
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: { year: 2019, month: 6, day: 15 },
|
||||
registrationDetails: {
|
||||
vatId: 'GB987654321',
|
||||
registrationId: 'Companies House 87654321',
|
||||
registrationName: 'Companies House'
|
||||
}
|
||||
};
|
||||
|
||||
testInvoice.items = [
|
||||
{
|
||||
position: 1,
|
||||
name: 'Professional Service',
|
||||
articleNumber: 'SERV-001',
|
||||
unitType: 'HUR',
|
||||
unitQuantity: 8,
|
||||
unitNetPrice: 150,
|
||||
vatPercentage: 19
|
||||
},
|
||||
{
|
||||
position: 2,
|
||||
name: 'Software License',
|
||||
articleNumber: 'SOFT-001',
|
||||
unitType: 'EA',
|
||||
unitQuantity: 1,
|
||||
unitNetPrice: 500,
|
||||
vatPercentage: 19
|
||||
}
|
||||
];
|
||||
|
||||
// Test conversions and check for data preservation
|
||||
const conversions: Array<{from: string, to: string}> = [
|
||||
{ from: 'facturx', to: 'ubl' },
|
||||
{ from: 'facturx', to: 'xrechnung' }
|
||||
];
|
||||
|
||||
for (const conversion of conversions) {
|
||||
console.log(`\nTesting ${conversion.from} → ${conversion.to} data preservation:`);
|
||||
|
||||
try {
|
||||
// Generate source XML
|
||||
const sourceXml = await testInvoice.exportXml(conversion.from as any);
|
||||
await testInvoice.loadXml(sourceXml);
|
||||
|
||||
// Convert to target format
|
||||
const { result: convertedXml, metric } = await PerformanceTracker.track(
|
||||
'data-preservation-conversion',
|
||||
async () => testInvoice.exportXml(conversion.to as any),
|
||||
{ conversion: `${conversion.from}-to-${conversion.to}` }
|
||||
);
|
||||
|
||||
const convertedInvoice = await EInvoice.fromXml(convertedXml);
|
||||
|
||||
// Check for data preservation
|
||||
const issues = checkDataPreservation(testInvoice, convertedInvoice);
|
||||
|
||||
if (issues.length === 0) {
|
||||
console.log(`✓ All critical data preserved (${metric.duration.toFixed(2)}ms)`);
|
||||
} else {
|
||||
console.log(`⚠ Data preservation issues found:`);
|
||||
issues.forEach(issue => console.log(` - ${issue}`));
|
||||
}
|
||||
|
||||
// Core fields should always be preserved
|
||||
expect(convertedInvoice.invoiceId).toEqual(testInvoice.invoiceId);
|
||||
expect(convertedInvoice.from.name).toEqual(testInvoice.from.name);
|
||||
expect(convertedInvoice.to.name).toEqual(testInvoice.to.name);
|
||||
|
||||
} catch (error) {
|
||||
console.log(`✗ Conversion failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CONV-01: Conversion Performance Benchmarks - should meet conversion performance targets', async () => {
|
||||
console.log('\nConversion Performance Benchmark Summary:');
|
||||
|
||||
const conversionOperations = [
|
||||
'cii-to-ubl-conversion',
|
||||
'ubl-to-cii-conversion',
|
||||
'zugferd-to-xrechnung-conversion'
|
||||
];
|
||||
|
||||
const benchmarkResults: { operation: string; metrics: any }[] = [];
|
||||
|
||||
for (const operation of conversionOperations) {
|
||||
const summary = await PerformanceTracker.getSummary(operation);
|
||||
if (summary) {
|
||||
benchmarkResults.push({ operation, metrics: summary });
|
||||
console.log(`\n${operation}:`);
|
||||
console.log(` Average: ${summary.average.toFixed(2)}ms`);
|
||||
console.log(` P95: ${summary.p95.toFixed(2)}ms`);
|
||||
console.log(` Count: ${summary.min !== undefined ? 'Available' : 'No data'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (benchmarkResults.length > 0) {
|
||||
const overallAverage = benchmarkResults.reduce((sum, result) =>
|
||||
sum + result.metrics.average, 0) / benchmarkResults.length;
|
||||
|
||||
console.log(`\nOverall Conversion Performance:`);
|
||||
console.log(` Average across operations: ${overallAverage.toFixed(2)}ms`);
|
||||
|
||||
// Performance targets
|
||||
expect(overallAverage).toBeLessThan(1000); // Conversions should be under 1 second on average
|
||||
|
||||
benchmarkResults.forEach(result => {
|
||||
expect(result.metrics.p95).toBeLessThan(2000); // P95 should be under 2 seconds
|
||||
});
|
||||
|
||||
console.log(`✓ All conversion performance benchmarks met`);
|
||||
} else {
|
||||
console.log('No conversion performance data available');
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to verify field mapping between invoices
|
||||
function verifyFieldMapping(source: EInvoice, converted: EInvoice, testName: string): void {
|
||||
const criticalFields = [
|
||||
{ field: 'invoiceId', name: 'Invoice ID' },
|
||||
{ field: 'currency', name: 'Currency' }
|
||||
];
|
||||
|
||||
for (const check of criticalFields) {
|
||||
const sourceVal = source[check.field as keyof EInvoice];
|
||||
const convertedVal = converted[check.field as keyof EInvoice];
|
||||
|
||||
if (sourceVal !== convertedVal) {
|
||||
console.log(` ⚠ ${check.name} mismatch: ${sourceVal} → ${convertedVal}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check seller/buyer names
|
||||
if (source.from?.name !== converted.from?.name) {
|
||||
console.log(` ⚠ Seller name mismatch: ${source.from?.name} → ${converted.from?.name}`);
|
||||
}
|
||||
|
||||
if (source.to?.name !== converted.to?.name) {
|
||||
console.log(` ⚠ Buyer name mismatch: ${source.to?.name} → ${converted.to?.name}`);
|
||||
}
|
||||
|
||||
// Check items count
|
||||
if (source.items?.length !== converted.items?.length) {
|
||||
console.log(` ⚠ Items count mismatch: ${source.items?.length} → ${converted.items?.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check data preservation
|
||||
function checkDataPreservation(source: EInvoice, converted: EInvoice): string[] {
|
||||
const issues: string[] = [];
|
||||
|
||||
// Check basic fields
|
||||
if (source.invoiceId !== converted.invoiceId) {
|
||||
issues.push(`Invoice ID changed: ${source.invoiceId} → ${converted.invoiceId}`);
|
||||
}
|
||||
|
||||
if (source.currency !== converted.currency) {
|
||||
issues.push(`Currency changed: ${source.currency} → ${converted.currency}`);
|
||||
}
|
||||
|
||||
// Check party information
|
||||
if (source.from?.name !== converted.from?.name) {
|
||||
issues.push(`Seller name changed: ${source.from?.name} → ${converted.from?.name}`);
|
||||
}
|
||||
|
||||
if (source.to?.name !== converted.to?.name) {
|
||||
issues.push(`Buyer name changed: ${source.to?.name} → ${converted.to?.name}`);
|
||||
}
|
||||
|
||||
// Check items
|
||||
if (source.items?.length !== converted.items?.length) {
|
||||
issues.push(`Items count changed: ${source.items?.length} → ${converted.items?.length}`);
|
||||
} else if (source.items && converted.items) {
|
||||
for (let i = 0; i < source.items.length; i++) {
|
||||
const sourceItem = source.items[i];
|
||||
const convertedItem = converted.items[i];
|
||||
|
||||
if (sourceItem.name !== convertedItem.name) {
|
||||
issues.push(`Item ${i+1} name changed: ${sourceItem.name} → ${convertedItem.name}`);
|
||||
}
|
||||
|
||||
if (sourceItem.unitNetPrice !== convertedItem.unitNetPrice) {
|
||||
issues.push(`Item ${i+1} price changed: ${sourceItem.unitNetPrice} → ${convertedItem.unitNetPrice}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
tap.start();
|
579
test/suite/einvoice_conversion/test.conv-02.ubl-to-cii.ts
Normal file
579
test/suite/einvoice_conversion/test.conv-02.ubl-to-cii.ts
Normal file
@ -0,0 +1,579 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../../../ts/plugins.ts';
|
||||
import { EInvoice } from '../../../ts/classes.xinvoice.ts';
|
||||
import { CorpusLoader } from '../../helpers/corpus.loader.ts';
|
||||
import { PerformanceTracker } from '../../helpers/performance.tracker.ts';
|
||||
|
||||
const testTimeout = 300000; // 5 minutes timeout for conversion processing
|
||||
|
||||
// CONV-02: UBL to CII Conversion
|
||||
// Tests conversion from UBL Invoice format to CII (Cross-Industry Invoice) format
|
||||
// including field mapping, data preservation, and semantic equivalence
|
||||
|
||||
tap.test('CONV-02: UBL to CII Conversion - Basic Conversion', async (tools) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Create a sample UBL invoice for conversion testing
|
||||
const sampleUblXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>UBL-TO-CII-001</ID>
|
||||
<IssueDate>2024-01-01</IssueDate>
|
||||
<InvoiceTypeCode>380</InvoiceTypeCode>
|
||||
<DocumentCurrencyCode>EUR</DocumentCurrencyCode>
|
||||
<Note>Test conversion from UBL to CII format</Note>
|
||||
<AccountingSupplierParty>
|
||||
<Party>
|
||||
<PartyName>
|
||||
<Name>UBL Test Supplier</Name>
|
||||
</PartyName>
|
||||
<PostalAddress>
|
||||
<StreetName>UBL Street 123</StreetName>
|
||||
<CityName>UBL City</CityName>
|
||||
<PostalZone>12345</PostalZone>
|
||||
<Country>
|
||||
<IdentificationCode>DE</IdentificationCode>
|
||||
</Country>
|
||||
</PostalAddress>
|
||||
<PartyTaxScheme>
|
||||
<CompanyID>DE123456789</CompanyID>
|
||||
</PartyTaxScheme>
|
||||
</Party>
|
||||
</AccountingSupplierParty>
|
||||
<AccountingCustomerParty>
|
||||
<Party>
|
||||
<PartyName>
|
||||
<Name>UBL Test Customer</Name>
|
||||
</PartyName>
|
||||
<PostalAddress>
|
||||
<StreetName>Customer Street 456</StreetName>
|
||||
<CityName>Customer City</CityName>
|
||||
<PostalZone>54321</PostalZone>
|
||||
<Country>
|
||||
<IdentificationCode>DE</IdentificationCode>
|
||||
</Country>
|
||||
</PostalAddress>
|
||||
</Party>
|
||||
</AccountingCustomerParty>
|
||||
<InvoiceLine>
|
||||
<ID>1</ID>
|
||||
<InvoicedQuantity unitCode="C62">2</InvoicedQuantity>
|
||||
<LineExtensionAmount currencyID="EUR">100.00</LineExtensionAmount>
|
||||
<Item>
|
||||
<Name>UBL Test Product</Name>
|
||||
<Description>Product for UBL to CII conversion testing</Description>
|
||||
<ClassifiedTaxCategory>
|
||||
<Percent>19.00</Percent>
|
||||
</ClassifiedTaxCategory>
|
||||
</Item>
|
||||
<Price>
|
||||
<PriceAmount currencyID="EUR">50.00</PriceAmount>
|
||||
</Price>
|
||||
</InvoiceLine>
|
||||
<TaxTotal>
|
||||
<TaxAmount currencyID="EUR">19.00</TaxAmount>
|
||||
<TaxSubtotal>
|
||||
<TaxableAmount currencyID="EUR">100.00</TaxableAmount>
|
||||
<TaxAmount currencyID="EUR">19.00</TaxAmount>
|
||||
<TaxCategory>
|
||||
<Percent>19.00</Percent>
|
||||
<TaxScheme>
|
||||
<ID>VAT</ID>
|
||||
</TaxScheme>
|
||||
</TaxCategory>
|
||||
</TaxSubtotal>
|
||||
</TaxTotal>
|
||||
<LegalMonetaryTotal>
|
||||
<LineExtensionAmount currencyID="EUR">100.00</LineExtensionAmount>
|
||||
<TaxExclusiveAmount currencyID="EUR">100.00</TaxExclusiveAmount>
|
||||
<TaxInclusiveAmount currencyID="EUR">119.00</TaxInclusiveAmount>
|
||||
<PayableAmount currencyID="EUR">119.00</PayableAmount>
|
||||
</LegalMonetaryTotal>
|
||||
</Invoice>`;
|
||||
|
||||
const invoice = new EInvoice();
|
||||
const parseResult = await invoice.fromXmlString(sampleUblXml);
|
||||
expect(parseResult).toBeTruthy();
|
||||
|
||||
// Test UBL to CII conversion if supported
|
||||
if (typeof invoice.convertTo === 'function') {
|
||||
tools.log('Testing UBL to CII conversion...');
|
||||
|
||||
try {
|
||||
const conversionResult = await invoice.convertTo('CII');
|
||||
|
||||
if (conversionResult) {
|
||||
tools.log('✓ UBL to CII conversion completed');
|
||||
|
||||
// Verify the converted format
|
||||
const convertedXml = await conversionResult.toXmlString();
|
||||
expect(convertedXml).toBeTruthy();
|
||||
expect(convertedXml.length).toBeGreaterThan(100);
|
||||
|
||||
// Check for CII format characteristics
|
||||
const ciiChecks = {
|
||||
hasCiiNamespace: convertedXml.includes('CrossIndustryInvoice') ||
|
||||
convertedXml.includes('urn:un:unece:uncefact:data:standard:CrossIndustryInvoice'),
|
||||
hasExchangedDocument: convertedXml.includes('ExchangedDocument'),
|
||||
hasSupplyChainTrade: convertedXml.includes('SupplyChainTradeTransaction'),
|
||||
hasOriginalId: convertedXml.includes('UBL-TO-CII-001'),
|
||||
hasOriginalCurrency: convertedXml.includes('EUR')
|
||||
};
|
||||
|
||||
tools.log('CII Format Verification:');
|
||||
tools.log(` CII Namespace: ${ciiChecks.hasCiiNamespace}`);
|
||||
tools.log(` ExchangedDocument: ${ciiChecks.hasExchangedDocument}`);
|
||||
tools.log(` SupplyChainTrade: ${ciiChecks.hasSupplyChainTrade}`);
|
||||
tools.log(` Original ID preserved: ${ciiChecks.hasOriginalId}`);
|
||||
tools.log(` Currency preserved: ${ciiChecks.hasOriginalCurrency}`);
|
||||
|
||||
if (ciiChecks.hasCiiNamespace && ciiChecks.hasExchangedDocument) {
|
||||
tools.log('✓ Valid CII format structure detected');
|
||||
} else {
|
||||
tools.log('⚠ CII format structure not clearly detected');
|
||||
}
|
||||
|
||||
// Validate the converted invoice
|
||||
try {
|
||||
const validationResult = await conversionResult.validate();
|
||||
if (validationResult.valid) {
|
||||
tools.log('✓ Converted CII invoice passes validation');
|
||||
} else {
|
||||
tools.log(`⚠ Converted CII validation issues: ${validationResult.errors?.length || 0} errors`);
|
||||
}
|
||||
} catch (validationError) {
|
||||
tools.log(`⚠ Converted CII validation failed: ${validationError.message}`);
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log('⚠ UBL to CII conversion returned no result');
|
||||
}
|
||||
|
||||
} catch (conversionError) {
|
||||
tools.log(`⚠ UBL to CII conversion failed: ${conversionError.message}`);
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log('⚠ UBL to CII conversion not supported (convertTo method not available)');
|
||||
|
||||
// Test alternative conversion approach if available
|
||||
if (typeof invoice.toCii === 'function') {
|
||||
try {
|
||||
const ciiResult = await invoice.toCii();
|
||||
if (ciiResult) {
|
||||
tools.log('✓ Alternative UBL to CII conversion successful');
|
||||
}
|
||||
} catch (alternativeError) {
|
||||
tools.log(`⚠ Alternative conversion failed: ${alternativeError.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
tools.log(`Basic UBL to CII conversion test failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
PerformanceTracker.recordMetric('conversion-ubl-to-cii-basic', duration);
|
||||
});
|
||||
|
||||
tap.test('CONV-02: UBL to CII Conversion - Corpus Testing', { timeout: testTimeout }, async (tools) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
let processedFiles = 0;
|
||||
let successfulConversions = 0;
|
||||
let conversionErrors = 0;
|
||||
let totalConversionTime = 0;
|
||||
|
||||
try {
|
||||
const ublFiles = await CorpusLoader.getFiles('UBL_XML_RECHNUNG');
|
||||
tools.log(`Testing UBL to CII conversion with ${ublFiles.length} UBL files`);
|
||||
|
||||
if (ublFiles.length === 0) {
|
||||
tools.log('⚠ No UBL files found in corpus for conversion testing');
|
||||
return;
|
||||
}
|
||||
|
||||
// Process a subset of files for performance
|
||||
const filesToProcess = ublFiles.slice(0, Math.min(8, ublFiles.length));
|
||||
|
||||
for (const filePath of filesToProcess) {
|
||||
const fileName = plugins.path.basename(filePath);
|
||||
const fileConversionStart = Date.now();
|
||||
|
||||
try {
|
||||
processedFiles++;
|
||||
|
||||
const invoice = new EInvoice();
|
||||
const parseResult = await invoice.fromFile(filePath);
|
||||
|
||||
if (parseResult) {
|
||||
// Attempt conversion to CII
|
||||
if (typeof invoice.convertTo === 'function') {
|
||||
const conversionResult = await invoice.convertTo('CII');
|
||||
|
||||
const fileConversionTime = Date.now() - fileConversionStart;
|
||||
totalConversionTime += fileConversionTime;
|
||||
|
||||
if (conversionResult) {
|
||||
successfulConversions++;
|
||||
|
||||
tools.log(`✓ ${fileName}: Converted to CII (${fileConversionTime}ms)`);
|
||||
|
||||
// Quick validation of converted content
|
||||
const convertedXml = await conversionResult.toXmlString();
|
||||
if (convertedXml && convertedXml.length > 100) {
|
||||
tools.log(` Converted content length: ${convertedXml.length} chars`);
|
||||
|
||||
// Test key field preservation
|
||||
const originalXml = await invoice.toXmlString();
|
||||
const preservationChecks = {
|
||||
currencyPreserved: originalXml.includes('EUR') === convertedXml.includes('EUR'),
|
||||
datePreserved: originalXml.includes('2024') === convertedXml.includes('2024')
|
||||
};
|
||||
|
||||
if (preservationChecks.currencyPreserved && preservationChecks.datePreserved) {
|
||||
tools.log(` ✓ Key data preserved in conversion`);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
conversionErrors++;
|
||||
tools.log(`⚠ ${fileName}: Conversion returned no result`);
|
||||
}
|
||||
} else {
|
||||
conversionErrors++;
|
||||
tools.log(`⚠ ${fileName}: Conversion method not available`);
|
||||
}
|
||||
} else {
|
||||
conversionErrors++;
|
||||
tools.log(`⚠ ${fileName}: Failed to parse original UBL`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
conversionErrors++;
|
||||
const fileConversionTime = Date.now() - fileConversionStart;
|
||||
totalConversionTime += fileConversionTime;
|
||||
|
||||
tools.log(`✗ ${fileName}: Conversion failed - ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const successRate = processedFiles > 0 ? (successfulConversions / processedFiles) * 100 : 0;
|
||||
const averageConversionTime = processedFiles > 0 ? totalConversionTime / processedFiles : 0;
|
||||
|
||||
tools.log(`\nUBL to CII Conversion Summary:`);
|
||||
tools.log(`- Files processed: ${processedFiles}`);
|
||||
tools.log(`- Successful conversions: ${successfulConversions} (${successRate.toFixed(1)}%)`);
|
||||
tools.log(`- Conversion errors: ${conversionErrors}`);
|
||||
tools.log(`- Average conversion time: ${averageConversionTime.toFixed(1)}ms`);
|
||||
|
||||
// Performance expectations
|
||||
if (processedFiles > 0) {
|
||||
expect(averageConversionTime).toBeLessThan(3000); // 3 seconds max per file
|
||||
}
|
||||
|
||||
// We expect some conversions to work, but don't require 100% success
|
||||
// as some files might have format-specific features that can't be converted
|
||||
if (processedFiles > 0) {
|
||||
expect(successRate).toBeGreaterThan(0); // At least one conversion should work
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
tools.log(`UBL to CII corpus testing failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - startTime;
|
||||
PerformanceTracker.recordMetric('conversion-ubl-to-cii-corpus', totalDuration);
|
||||
|
||||
tools.log(`UBL to CII corpus testing completed in ${totalDuration}ms`);
|
||||
});
|
||||
|
||||
tap.test('CONV-02: UBL to CII Conversion - Field Mapping Verification', async (tools) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Test specific field mappings between UBL and CII
|
||||
const fieldMappingTests = [
|
||||
{
|
||||
name: 'Invoice Header Fields',
|
||||
ublXml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>FIELD-MAP-001</ID>
|
||||
<IssueDate>2024-01-15</IssueDate>
|
||||
<InvoiceTypeCode>380</InvoiceTypeCode>
|
||||
<DocumentCurrencyCode>USD</DocumentCurrencyCode>
|
||||
<Note>Field mapping test invoice</Note>
|
||||
</Invoice>`,
|
||||
expectedMappings: {
|
||||
'ID': ['ExchangedDocument', 'ID'],
|
||||
'IssueDate': ['ExchangedDocument', 'IssueDateTime'],
|
||||
'InvoiceTypeCode': ['ExchangedDocument', 'TypeCode'],
|
||||
'DocumentCurrencyCode': ['InvoiceCurrencyCode'],
|
||||
'Note': ['IncludedNote']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Party Information',
|
||||
ublXml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>PARTY-MAP-001</ID>
|
||||
<IssueDate>2024-01-15</IssueDate>
|
||||
<InvoiceTypeCode>380</InvoiceTypeCode>
|
||||
<AccountingSupplierParty>
|
||||
<Party>
|
||||
<PartyName>
|
||||
<Name>Supplier Company Ltd</Name>
|
||||
</PartyName>
|
||||
<PostalAddress>
|
||||
<StreetName>Main Street 100</StreetName>
|
||||
<CityName>Business City</CityName>
|
||||
<PostalZone>10001</PostalZone>
|
||||
<Country>
|
||||
<IdentificationCode>US</IdentificationCode>
|
||||
</Country>
|
||||
</PostalAddress>
|
||||
</Party>
|
||||
</AccountingSupplierParty>
|
||||
</Invoice>`,
|
||||
expectedMappings: {
|
||||
'AccountingSupplierParty': ['SellerTradeParty'],
|
||||
'PartyName/Name': ['Name'],
|
||||
'PostalAddress': ['PostalTradeAddress'],
|
||||
'StreetName': ['LineOne'],
|
||||
'CityName': ['CityName'],
|
||||
'PostalZone': ['PostcodeCode'],
|
||||
'Country/IdentificationCode': ['CountryID']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Line Items and Pricing',
|
||||
ublXml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>LINE-MAP-001</ID>
|
||||
<IssueDate>2024-01-15</IssueDate>
|
||||
<InvoiceTypeCode>380</InvoiceTypeCode>
|
||||
<InvoiceLine>
|
||||
<ID>1</ID>
|
||||
<InvoicedQuantity unitCode="C62">5</InvoicedQuantity>
|
||||
<LineExtensionAmount currencyID="USD">250.00</LineExtensionAmount>
|
||||
<Item>
|
||||
<Name>Mapping Test Product</Name>
|
||||
<Description>Product for field mapping verification</Description>
|
||||
</Item>
|
||||
<Price>
|
||||
<PriceAmount currencyID="USD">50.00</PriceAmount>
|
||||
</Price>
|
||||
</InvoiceLine>
|
||||
</Invoice>`,
|
||||
expectedMappings: {
|
||||
'InvoiceLine': ['IncludedSupplyChainTradeLineItem'],
|
||||
'InvoiceLine/ID': ['AssociatedDocumentLineDocument/LineID'],
|
||||
'InvoicedQuantity': ['SpecifiedLineTradeDelivery/BilledQuantity'],
|
||||
'LineExtensionAmount': ['SpecifiedLineTradeSettlement/SpecifiedTradeSettlementLineMonetarySummation/LineTotalAmount'],
|
||||
'Item/Name': ['SpecifiedTradeProduct/Name'],
|
||||
'Price/PriceAmount': ['SpecifiedLineTradeAgreement/NetPriceProductTradePrice/ChargeAmount']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const mappingTest of fieldMappingTests) {
|
||||
tools.log(`Testing ${mappingTest.name} field mapping...`);
|
||||
|
||||
try {
|
||||
const invoice = new EInvoice();
|
||||
const parseResult = await invoice.fromXmlString(mappingTest.ublXml);
|
||||
|
||||
if (parseResult) {
|
||||
if (typeof invoice.convertTo === 'function') {
|
||||
const conversionResult = await invoice.convertTo('CII');
|
||||
|
||||
if (conversionResult) {
|
||||
const convertedXml = await conversionResult.toXmlString();
|
||||
|
||||
tools.log(` ✓ ${mappingTest.name} conversion completed`);
|
||||
tools.log(` Converted XML length: ${convertedXml.length} chars`);
|
||||
|
||||
// Check for expected CII structure elements
|
||||
let mappingsFound = 0;
|
||||
let mappingsTotal = Object.keys(mappingTest.expectedMappings).length;
|
||||
|
||||
for (const [ublField, ciiPath] of Object.entries(mappingTest.expectedMappings)) {
|
||||
const ciiElements = Array.isArray(ciiPath) ? ciiPath : [ciiPath];
|
||||
const hasMapping = ciiElements.some(element => convertedXml.includes(element));
|
||||
|
||||
if (hasMapping) {
|
||||
mappingsFound++;
|
||||
tools.log(` ✓ ${ublField} → ${ciiElements.join('/')} mapped`);
|
||||
} else {
|
||||
tools.log(` ⚠ ${ublField} → ${ciiElements.join('/')} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
const mappingSuccessRate = (mappingsFound / mappingsTotal) * 100;
|
||||
tools.log(` Field mapping success rate: ${mappingSuccessRate.toFixed(1)}% (${mappingsFound}/${mappingsTotal})`);
|
||||
|
||||
if (mappingSuccessRate >= 70) {
|
||||
tools.log(` ✓ Good field mapping coverage`);
|
||||
} else {
|
||||
tools.log(` ⚠ Low field mapping coverage - may need implementation`);
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log(` ⚠ ${mappingTest.name} conversion returned no result`);
|
||||
}
|
||||
} else {
|
||||
tools.log(` ⚠ ${mappingTest.name} conversion not supported`);
|
||||
}
|
||||
} else {
|
||||
tools.log(` ⚠ ${mappingTest.name} UBL parsing failed`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
tools.log(` ✗ ${mappingTest.name} test failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
PerformanceTracker.recordMetric('conversion-ubl-to-cii-field-mapping', duration);
|
||||
});
|
||||
|
||||
tap.test('CONV-02: UBL to CII Conversion - Data Integrity', async (tools) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Test data integrity during conversion
|
||||
const integrityTestXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>INTEGRITY-TEST-001</ID>
|
||||
<IssueDate>2024-01-15</IssueDate>
|
||||
<InvoiceTypeCode>380</InvoiceTypeCode>
|
||||
<DocumentCurrencyCode>EUR</DocumentCurrencyCode>
|
||||
<Note>Special characters: äöüß €£$¥ áéíóú àèìòù</Note>
|
||||
<AccountingSupplierParty>
|
||||
<Party>
|
||||
<PartyName>
|
||||
<Name>Tëst Suppliér Çômpány</Name>
|
||||
</PartyName>
|
||||
</Party>
|
||||
</AccountingSupplierParty>
|
||||
<InvoiceLine>
|
||||
<ID>1</ID>
|
||||
<InvoicedQuantity unitCode="C62">3.5</InvoicedQuantity>
|
||||
<LineExtensionAmount currencyID="EUR">175.50</LineExtensionAmount>
|
||||
<Item>
|
||||
<Name>Prödüct wíth spëcíàl chäractërs</Name>
|
||||
<Description>Testing unicode: 中文 日本語 한국어 العربية</Description>
|
||||
</Item>
|
||||
<Price>
|
||||
<PriceAmount currencyID="EUR">50.14</PriceAmount>
|
||||
</Price>
|
||||
</InvoiceLine>
|
||||
<TaxTotal>
|
||||
<TaxAmount currencyID="EUR">33.35</TaxAmount>
|
||||
</TaxTotal>
|
||||
<LegalMonetaryTotal>
|
||||
<LineExtensionAmount currencyID="EUR">175.50</LineExtensionAmount>
|
||||
<TaxExclusiveAmount currencyID="EUR">175.50</TaxExclusiveAmount>
|
||||
<TaxInclusiveAmount currencyID="EUR">208.85</TaxInclusiveAmount>
|
||||
<PayableAmount currencyID="EUR">208.85</PayableAmount>
|
||||
</LegalMonetaryTotal>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const invoice = new EInvoice();
|
||||
const parseResult = await invoice.fromXmlString(integrityTestXml);
|
||||
|
||||
if (parseResult) {
|
||||
tools.log('Testing data integrity during UBL to CII conversion...');
|
||||
|
||||
if (typeof invoice.convertTo === 'function') {
|
||||
const conversionResult = await invoice.convertTo('CII');
|
||||
|
||||
if (conversionResult) {
|
||||
const convertedXml = await conversionResult.toXmlString();
|
||||
const originalXml = await invoice.toXmlString();
|
||||
|
||||
// Test data integrity
|
||||
const integrityChecks = {
|
||||
invoiceIdPreserved: convertedXml.includes('INTEGRITY-TEST-001'),
|
||||
specialCharsPreserved: convertedXml.includes('äöüß') && convertedXml.includes('€£$¥'),
|
||||
unicodePreserved: convertedXml.includes('中文') || convertedXml.includes('日本語'),
|
||||
numbersPreserved: convertedXml.includes('175.50') && convertedXml.includes('50.14'),
|
||||
currencyPreserved: convertedXml.includes('EUR'),
|
||||
datePreserved: convertedXml.includes('2024-01-15') || convertedXml.includes('20240115')
|
||||
};
|
||||
|
||||
tools.log('Data Integrity Verification:');
|
||||
tools.log(` Invoice ID preserved: ${integrityChecks.invoiceIdPreserved}`);
|
||||
tools.log(` Special characters preserved: ${integrityChecks.specialCharsPreserved}`);
|
||||
tools.log(` Unicode characters preserved: ${integrityChecks.unicodePreserved}`);
|
||||
tools.log(` Numbers preserved: ${integrityChecks.numbersPreserved}`);
|
||||
tools.log(` Currency preserved: ${integrityChecks.currencyPreserved}`);
|
||||
tools.log(` Date preserved: ${integrityChecks.datePreserved}`);
|
||||
|
||||
const integrityScore = Object.values(integrityChecks).filter(Boolean).length;
|
||||
const totalChecks = Object.values(integrityChecks).length;
|
||||
const integrityPercentage = (integrityScore / totalChecks) * 100;
|
||||
|
||||
tools.log(`Data integrity score: ${integrityScore}/${totalChecks} (${integrityPercentage.toFixed(1)}%)`);
|
||||
|
||||
if (integrityPercentage >= 80) {
|
||||
tools.log('✓ Good data integrity maintained');
|
||||
} else {
|
||||
tools.log('⚠ Data integrity issues detected');
|
||||
}
|
||||
|
||||
// Test round-trip if possible
|
||||
if (typeof conversionResult.convertTo === 'function') {
|
||||
try {
|
||||
const roundTripResult = await conversionResult.convertTo('UBL');
|
||||
if (roundTripResult) {
|
||||
const roundTripXml = await roundTripResult.toXmlString();
|
||||
if (roundTripXml.includes('INTEGRITY-TEST-001')) {
|
||||
tools.log('✓ Round-trip conversion preserves ID');
|
||||
}
|
||||
}
|
||||
} catch (roundTripError) {
|
||||
tools.log(`⚠ Round-trip test failed: ${roundTripError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log('⚠ Data integrity conversion returned no result');
|
||||
}
|
||||
} else {
|
||||
tools.log('⚠ Data integrity conversion not supported');
|
||||
}
|
||||
} else {
|
||||
tools.log('⚠ Data integrity test - UBL parsing failed');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
tools.log(`Data integrity test failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
PerformanceTracker.recordMetric('conversion-ubl-to-cii-data-integrity', duration);
|
||||
});
|
||||
|
||||
tap.test('CONV-02: Performance Summary', async (tools) => {
|
||||
const operations = [
|
||||
'conversion-ubl-to-cii-basic',
|
||||
'conversion-ubl-to-cii-corpus',
|
||||
'conversion-ubl-to-cii-field-mapping',
|
||||
'conversion-ubl-to-cii-data-integrity'
|
||||
];
|
||||
|
||||
tools.log(`\n=== UBL to CII Conversion Performance Summary ===`);
|
||||
|
||||
for (const operation of operations) {
|
||||
const summary = await PerformanceTracker.getSummary(operation);
|
||||
if (summary) {
|
||||
tools.log(`${operation}:`);
|
||||
tools.log(` avg=${summary.average}ms, min=${summary.min}ms, max=${summary.max}ms, p95=${summary.p95}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
tools.log(`\nUBL to CII conversion testing completed.`);
|
||||
});
|
@ -0,0 +1,641 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../../../ts/plugins.ts';
|
||||
import { EInvoice } from '../../../ts/classes.xinvoice.ts';
|
||||
import { CorpusLoader } from '../../helpers/corpus.loader.ts';
|
||||
import { PerformanceTracker } from '../../helpers/performance.tracker.ts';
|
||||
|
||||
const testTimeout = 300000; // 5 minutes timeout for conversion processing
|
||||
|
||||
// CONV-03: ZUGFeRD to XRechnung Conversion
|
||||
// Tests conversion from ZUGFeRD format to XRechnung (German CIUS of EN16931)
|
||||
// including profile adaptation, compliance checking, and German-specific requirements
|
||||
|
||||
tap.test('CONV-03: ZUGFeRD to XRechnung Conversion - Basic Conversion', async (tools) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Create a sample ZUGFeRD invoice for conversion testing
|
||||
const sampleZugferdXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CrossIndustryInvoice xmlns="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
|
||||
<ExchangedDocumentContext>
|
||||
<GuidelineSpecifiedDocumentContextParameter>
|
||||
<ID>urn:cen.eu:en16931:2017#compliant#urn:zugferd.de:2p1:comfort</ID>
|
||||
</GuidelineSpecifiedDocumentContextParameter>
|
||||
</ExchangedDocumentContext>
|
||||
<ExchangedDocument>
|
||||
<ID>ZUGFERD-TO-XRECHNUNG-001</ID>
|
||||
<TypeCode>380</TypeCode>
|
||||
<IssueDateTime>
|
||||
<DateTimeString format="102">20240115</DateTimeString>
|
||||
</IssueDateTime>
|
||||
<IncludedNote>
|
||||
<Content>ZUGFeRD to XRechnung conversion test</Content>
|
||||
</IncludedNote>
|
||||
</ExchangedDocument>
|
||||
<SupplyChainTradeTransaction>
|
||||
<IncludedSupplyChainTradeLineItem>
|
||||
<AssociatedDocumentLineDocument>
|
||||
<LineID>1</LineID>
|
||||
</AssociatedDocumentLineDocument>
|
||||
<SpecifiedTradeProduct>
|
||||
<Name>ZUGFeRD Test Product</Name>
|
||||
<Description>Product for ZUGFeRD to XRechnung conversion</Description>
|
||||
</SpecifiedTradeProduct>
|
||||
<SpecifiedLineTradeAgreement>
|
||||
<NetPriceProductTradePrice>
|
||||
<ChargeAmount>50.00</ChargeAmount>
|
||||
</NetPriceProductTradePrice>
|
||||
</SpecifiedLineTradeAgreement>
|
||||
<SpecifiedLineTradeDelivery>
|
||||
<BilledQuantity unitCode="C62">2</BilledQuantity>
|
||||
</SpecifiedLineTradeDelivery>
|
||||
<SpecifiedLineTradeSettlement>
|
||||
<ApplicableTradeTax>
|
||||
<TypeCode>VAT</TypeCode>
|
||||
<RateApplicablePercent>19.00</RateApplicablePercent>
|
||||
</ApplicableTradeTax>
|
||||
<SpecifiedTradeSettlementLineMonetarySummation>
|
||||
<LineTotalAmount>100.00</LineTotalAmount>
|
||||
</SpecifiedTradeSettlementLineMonetarySummation>
|
||||
</SpecifiedLineTradeSettlement>
|
||||
</IncludedSupplyChainTradeLineItem>
|
||||
<ApplicableHeaderTradeAgreement>
|
||||
<SellerTradeParty>
|
||||
<Name>ZUGFeRD Test Supplier GmbH</Name>
|
||||
<PostalTradeAddress>
|
||||
<PostcodeCode>10115</PostcodeCode>
|
||||
<LineOne>Friedrichstraße 123</LineOne>
|
||||
<CityName>Berlin</CityName>
|
||||
<CountryID>DE</CountryID>
|
||||
</PostalTradeAddress>
|
||||
<SpecifiedTaxRegistration>
|
||||
<ID schemeID="VA">DE123456789</ID>
|
||||
</SpecifiedTaxRegistration>
|
||||
</SellerTradeParty>
|
||||
<BuyerTradeParty>
|
||||
<Name>XRechnung Test Customer GmbH</Name>
|
||||
<PostalTradeAddress>
|
||||
<PostcodeCode>80331</PostcodeCode>
|
||||
<LineOne>Marienplatz 1</LineOne>
|
||||
<CityName>München</CityName>
|
||||
<CountryID>DE</CountryID>
|
||||
</PostalTradeAddress>
|
||||
</BuyerTradeParty>
|
||||
</ApplicableHeaderTradeAgreement>
|
||||
<ApplicableHeaderTradeDelivery>
|
||||
<ActualDeliverySupplyChainEvent>
|
||||
<OccurrenceDateTime>
|
||||
<DateTimeString format="102">20240115</DateTimeString>
|
||||
</OccurrenceDateTime>
|
||||
</ActualDeliverySupplyChainEvent>
|
||||
</ApplicableHeaderTradeDelivery>
|
||||
<ApplicableHeaderTradeSettlement>
|
||||
<InvoiceCurrencyCode>EUR</InvoiceCurrencyCode>
|
||||
<ApplicableTradeTax>
|
||||
<CalculatedAmount>19.00</CalculatedAmount>
|
||||
<TypeCode>VAT</TypeCode>
|
||||
<BasisAmount>100.00</BasisAmount>
|
||||
<RateApplicablePercent>19.00</RateApplicablePercent>
|
||||
</ApplicableTradeTax>
|
||||
<SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
<LineTotalAmount>100.00</LineTotalAmount>
|
||||
<TaxBasisTotalAmount>100.00</TaxBasisTotalAmount>
|
||||
<TaxTotalAmount currencyID="EUR">19.00</TaxTotalAmount>
|
||||
<GrandTotalAmount>119.00</GrandTotalAmount>
|
||||
<DuePayableAmount>119.00</DuePayableAmount>
|
||||
</SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
</ApplicableHeaderTradeSettlement>
|
||||
</SupplyChainTradeTransaction>
|
||||
</CrossIndustryInvoice>`;
|
||||
|
||||
const invoice = new EInvoice();
|
||||
const parseResult = await invoice.fromXmlString(sampleZugferdXml);
|
||||
expect(parseResult).toBeTruthy();
|
||||
|
||||
// Test ZUGFeRD to XRechnung conversion if supported
|
||||
if (typeof invoice.convertTo === 'function') {
|
||||
tools.log('Testing ZUGFeRD to XRechnung conversion...');
|
||||
|
||||
try {
|
||||
const conversionResult = await invoice.convertTo('XRECHNUNG');
|
||||
|
||||
if (conversionResult) {
|
||||
tools.log('✓ ZUGFeRD to XRechnung conversion completed');
|
||||
|
||||
// Verify the converted format
|
||||
const convertedXml = await conversionResult.toXmlString();
|
||||
expect(convertedXml).toBeTruthy();
|
||||
expect(convertedXml.length).toBeGreaterThan(100);
|
||||
|
||||
// Check for XRechnung format characteristics
|
||||
const xrechnungChecks = {
|
||||
hasXrechnungCustomization: convertedXml.includes('urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung') ||
|
||||
convertedXml.includes('XRechnung') ||
|
||||
convertedXml.includes('xrechnung'),
|
||||
hasUblNamespace: convertedXml.includes('urn:oasis:names:specification:ubl:schema:xsd:Invoice-2'),
|
||||
hasPeppolProfile: convertedXml.includes('urn:fdc:peppol.eu:2017:poacc:billing:01:1.0'),
|
||||
hasOriginalId: convertedXml.includes('ZUGFERD-TO-XRECHNUNG-001'),
|
||||
hasGermanVat: convertedXml.includes('DE123456789'),
|
||||
hasEurocurrency: convertedXml.includes('EUR')
|
||||
};
|
||||
|
||||
tools.log('XRechnung Format Verification:');
|
||||
tools.log(` XRechnung Customization: ${xrechnungChecks.hasXrechnungCustomization}`);
|
||||
tools.log(` UBL Namespace: ${xrechnungChecks.hasUblNamespace}`);
|
||||
tools.log(` PEPPOL Profile: ${xrechnungChecks.hasPeppolProfile}`);
|
||||
tools.log(` Original ID preserved: ${xrechnungChecks.hasOriginalId}`);
|
||||
tools.log(` German VAT preserved: ${xrechnungChecks.hasGermanVat}`);
|
||||
tools.log(` Euro currency preserved: ${xrechnungChecks.hasEurourrency}`);
|
||||
|
||||
if (xrechnungChecks.hasUblNamespace || xrechnungChecks.hasXrechnungCustomization) {
|
||||
tools.log('✓ Valid XRechnung format structure detected');
|
||||
} else {
|
||||
tools.log('⚠ XRechnung format structure not clearly detected');
|
||||
}
|
||||
|
||||
// Validate the converted invoice
|
||||
try {
|
||||
const validationResult = await conversionResult.validate();
|
||||
if (validationResult.valid) {
|
||||
tools.log('✓ Converted XRechnung invoice passes validation');
|
||||
} else {
|
||||
tools.log(`⚠ Converted XRechnung validation issues: ${validationResult.errors?.length || 0} errors`);
|
||||
if (validationResult.errors && validationResult.errors.length > 0) {
|
||||
tools.log(` First error: ${validationResult.errors[0].message}`);
|
||||
}
|
||||
}
|
||||
} catch (validationError) {
|
||||
tools.log(`⚠ Converted XRechnung validation failed: ${validationError.message}`);
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log('⚠ ZUGFeRD to XRechnung conversion returned no result');
|
||||
}
|
||||
|
||||
} catch (conversionError) {
|
||||
tools.log(`⚠ ZUGFeRD to XRechnung conversion failed: ${conversionError.message}`);
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log('⚠ ZUGFeRD to XRechnung conversion not supported (convertTo method not available)');
|
||||
|
||||
// Test alternative conversion approach if available
|
||||
if (typeof invoice.toXRechnung === 'function') {
|
||||
try {
|
||||
const xrechnungResult = await invoice.toXRechnung();
|
||||
if (xrechnungResult) {
|
||||
tools.log('✓ Alternative ZUGFeRD to XRechnung conversion successful');
|
||||
}
|
||||
} catch (alternativeError) {
|
||||
tools.log(`⚠ Alternative conversion failed: ${alternativeError.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
tools.log(`Basic ZUGFeRD to XRechnung conversion test failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
PerformanceTracker.recordMetric('conversion-zugferd-to-xrechnung-basic', duration);
|
||||
});
|
||||
|
||||
tap.test('CONV-03: ZUGFeRD to XRechnung Conversion - Profile Adaptation', async (tools) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Test conversion of different ZUGFeRD profiles to XRechnung
|
||||
const profileTests = [
|
||||
{
|
||||
name: 'ZUGFeRD MINIMUM to XRechnung',
|
||||
zugferdXml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CrossIndustryInvoice xmlns="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
|
||||
<ExchangedDocumentContext>
|
||||
<GuidelineSpecifiedDocumentContextParameter>
|
||||
<ID>urn:cen.eu:en16931:2017#compliant#urn:zugferd.de:2p1:minimum</ID>
|
||||
</GuidelineSpecifiedDocumentContextParameter>
|
||||
</ExchangedDocumentContext>
|
||||
<ExchangedDocument>
|
||||
<ID>MIN-TO-XRECHNUNG-001</ID>
|
||||
<TypeCode>380</TypeCode>
|
||||
<IssueDateTime>
|
||||
<DateTimeString format="102">20240115</DateTimeString>
|
||||
</IssueDateTime>
|
||||
</ExchangedDocument>
|
||||
<SupplyChainTradeTransaction>
|
||||
<ApplicableHeaderTradeSettlement>
|
||||
<InvoiceCurrencyCode>EUR</InvoiceCurrencyCode>
|
||||
<SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
<DuePayableAmount>119.00</DuePayableAmount>
|
||||
</SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
</ApplicableHeaderTradeSettlement>
|
||||
</SupplyChainTradeTransaction>
|
||||
</CrossIndustryInvoice>`
|
||||
},
|
||||
{
|
||||
name: 'ZUGFeRD BASIC to XRechnung',
|
||||
zugferdXml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CrossIndustryInvoice xmlns="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
|
||||
<ExchangedDocumentContext>
|
||||
<GuidelineSpecifiedDocumentContextParameter>
|
||||
<ID>urn:cen.eu:en16931:2017#compliant#urn:zugferd.de:2p1:basic</ID>
|
||||
</GuidelineSpecifiedDocumentContextParameter>
|
||||
</ExchangedDocumentContext>
|
||||
<ExchangedDocument>
|
||||
<ID>BASIC-TO-XRECHNUNG-001</ID>
|
||||
<TypeCode>380</TypeCode>
|
||||
<IssueDateTime>
|
||||
<DateTimeString format="102">20240115</DateTimeString>
|
||||
</IssueDateTime>
|
||||
</ExchangedDocument>
|
||||
<SupplyChainTradeTransaction>
|
||||
<ApplicableHeaderTradeAgreement>
|
||||
<SellerTradeParty>
|
||||
<Name>BASIC Supplier GmbH</Name>
|
||||
</SellerTradeParty>
|
||||
<BuyerTradeParty>
|
||||
<Name>BASIC Customer GmbH</Name>
|
||||
</BuyerTradeParty>
|
||||
</ApplicableHeaderTradeAgreement>
|
||||
<ApplicableHeaderTradeSettlement>
|
||||
<InvoiceCurrencyCode>EUR</InvoiceCurrencyCode>
|
||||
<SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
<TaxBasisTotalAmount>100.00</TaxBasisTotalAmount>
|
||||
<TaxTotalAmount currencyID="EUR">19.00</TaxTotalAmount>
|
||||
<GrandTotalAmount>119.00</GrandTotalAmount>
|
||||
<DuePayableAmount>119.00</DuePayableAmount>
|
||||
</SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
</ApplicableHeaderTradeSettlement>
|
||||
</SupplyChainTradeTransaction>
|
||||
</CrossIndustryInvoice>`
|
||||
},
|
||||
{
|
||||
name: 'ZUGFeRD COMFORT to XRechnung',
|
||||
zugferdXml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CrossIndustryInvoice xmlns="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
|
||||
<ExchangedDocumentContext>
|
||||
<GuidelineSpecifiedDocumentContextParameter>
|
||||
<ID>urn:cen.eu:en16931:2017#compliant#urn:zugferd.de:2p1:comfort</ID>
|
||||
</GuidelineSpecifiedDocumentContextParameter>
|
||||
</ExchangedDocumentContext>
|
||||
<ExchangedDocument>
|
||||
<ID>COMFORT-TO-XRECHNUNG-001</ID>
|
||||
<TypeCode>380</TypeCode>
|
||||
<IssueDateTime>
|
||||
<DateTimeString format="102">20240115</DateTimeString>
|
||||
</IssueDateTime>
|
||||
</ExchangedDocument>
|
||||
<SupplyChainTradeTransaction>
|
||||
<IncludedSupplyChainTradeLineItem>
|
||||
<AssociatedDocumentLineDocument>
|
||||
<LineID>1</LineID>
|
||||
</AssociatedDocumentLineDocument>
|
||||
<SpecifiedTradeProduct>
|
||||
<Name>COMFORT Test Product</Name>
|
||||
</SpecifiedTradeProduct>
|
||||
<SpecifiedLineTradeSettlement>
|
||||
<SpecifiedTradeSettlementLineMonetarySummation>
|
||||
<LineTotalAmount>100.00</LineTotalAmount>
|
||||
</SpecifiedTradeSettlementLineMonetarySummation>
|
||||
</SpecifiedLineTradeSettlement>
|
||||
</IncludedSupplyChainTradeLineItem>
|
||||
<ApplicableHeaderTradeSettlement>
|
||||
<InvoiceCurrencyCode>EUR</InvoiceCurrencyCode>
|
||||
<SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
<LineTotalAmount>100.00</LineTotalAmount>
|
||||
<TaxBasisTotalAmount>100.00</TaxBasisTotalAmount>
|
||||
<TaxTotalAmount currencyID="EUR">19.00</TaxTotalAmount>
|
||||
<GrandTotalAmount>119.00</GrandTotalAmount>
|
||||
<DuePayableAmount>119.00</DuePayableAmount>
|
||||
</SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
</ApplicableHeaderTradeSettlement>
|
||||
</SupplyChainTradeTransaction>
|
||||
</CrossIndustryInvoice>`
|
||||
}
|
||||
];
|
||||
|
||||
for (const profileTest of profileTests) {
|
||||
tools.log(`Testing ${profileTest.name}...`);
|
||||
|
||||
try {
|
||||
const invoice = new EInvoice();
|
||||
const parseResult = await invoice.fromXmlString(profileTest.zugferdXml);
|
||||
|
||||
if (parseResult) {
|
||||
if (typeof invoice.convertTo === 'function') {
|
||||
const conversionResult = await invoice.convertTo('XRECHNUNG');
|
||||
|
||||
if (conversionResult) {
|
||||
tools.log(`✓ ${profileTest.name} conversion completed`);
|
||||
|
||||
const convertedXml = await conversionResult.toXmlString();
|
||||
|
||||
// Check profile-specific adaptations
|
||||
const profileAdaptations = {
|
||||
hasXrechnungProfile: convertedXml.includes('xrechnung') ||
|
||||
convertedXml.includes('XRechnung'),
|
||||
retainsOriginalId: convertedXml.includes('TO-XRECHNUNG-001'),
|
||||
hasRequiredStructure: convertedXml.includes('<Invoice') ||
|
||||
convertedXml.includes('<CrossIndustryInvoice'),
|
||||
hasGermanContext: convertedXml.includes('urn:xoev-de:kosit') ||
|
||||
convertedXml.includes('xrechnung')
|
||||
};
|
||||
|
||||
tools.log(` Profile adaptation results:`);
|
||||
tools.log(` XRechnung profile: ${profileAdaptations.hasXrechnungProfile}`);
|
||||
tools.log(` Original ID retained: ${profileAdaptations.retainsOriginalId}`);
|
||||
tools.log(` Required structure: ${profileAdaptations.hasRequiredStructure}`);
|
||||
tools.log(` German context: ${profileAdaptations.hasGermanContext}`);
|
||||
|
||||
if (profileAdaptations.hasRequiredStructure && profileAdaptations.retainsOriginalId) {
|
||||
tools.log(` ✓ Successful profile adaptation`);
|
||||
} else {
|
||||
tools.log(` ⚠ Profile adaptation issues detected`);
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log(`⚠ ${profileTest.name} conversion returned no result`);
|
||||
}
|
||||
} else {
|
||||
tools.log(`⚠ ${profileTest.name} conversion not supported`);
|
||||
}
|
||||
} else {
|
||||
tools.log(`⚠ ${profileTest.name} ZUGFeRD parsing failed`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
tools.log(`✗ ${profileTest.name} test failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
PerformanceTracker.recordMetric('conversion-zugferd-to-xrechnung-profiles', duration);
|
||||
});
|
||||
|
||||
tap.test('CONV-03: ZUGFeRD to XRechnung Conversion - German Compliance', async (tools) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Test German-specific compliance requirements for XRechnung
|
||||
const germanComplianceXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CrossIndustryInvoice xmlns="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
|
||||
<ExchangedDocumentContext>
|
||||
<GuidelineSpecifiedDocumentContextParameter>
|
||||
<ID>urn:cen.eu:en16931:2017#compliant#urn:zugferd.de:2p1:comfort</ID>
|
||||
</GuidelineSpecifiedDocumentContextParameter>
|
||||
</ExchangedDocumentContext>
|
||||
<ExchangedDocument>
|
||||
<ID>DE-COMPLIANCE-001</ID>
|
||||
<TypeCode>380</TypeCode>
|
||||
<IssueDateTime>
|
||||
<DateTimeString format="102">20240115</DateTimeString>
|
||||
</IssueDateTime>
|
||||
</ExchangedDocument>
|
||||
<SupplyChainTradeTransaction>
|
||||
<ApplicableHeaderTradeAgreement>
|
||||
<BuyerReference>BUYER-REF-12345</BuyerReference>
|
||||
<SellerTradeParty>
|
||||
<Name>Deutsche Lieferant GmbH</Name>
|
||||
<PostalTradeAddress>
|
||||
<PostcodeCode>10115</PostcodeCode>
|
||||
<LineOne>Unter den Linden 1</LineOne>
|
||||
<CityName>Berlin</CityName>
|
||||
<CountryID>DE</CountryID>
|
||||
</PostalTradeAddress>
|
||||
<SpecifiedTaxRegistration>
|
||||
<ID schemeID="VA">DE987654321</ID>
|
||||
</SpecifiedTaxRegistration>
|
||||
</SellerTradeParty>
|
||||
<BuyerTradeParty>
|
||||
<Name>Deutscher Kunde GmbH</Name>
|
||||
<PostalTradeAddress>
|
||||
<PostcodeCode>80331</PostcodeCode>
|
||||
<LineOne>Maximilianstraße 1</LineOne>
|
||||
<CityName>München</CityName>
|
||||
<CountryID>DE</CountryID>
|
||||
</PostalTradeAddress>
|
||||
</BuyerTradeParty>
|
||||
</ApplicableHeaderTradeAgreement>
|
||||
<ApplicableHeaderTradeSettlement>
|
||||
<PaymentReference>PAYMENT-REF-67890</PaymentReference>
|
||||
<InvoiceCurrencyCode>EUR</InvoiceCurrencyCode>
|
||||
<ApplicableTradeTax>
|
||||
<CalculatedAmount>19.00</CalculatedAmount>
|
||||
<TypeCode>VAT</TypeCode>
|
||||
<BasisAmount>100.00</BasisAmount>
|
||||
<RateApplicablePercent>19.00</RateApplicablePercent>
|
||||
<CategoryCode>S</CategoryCode>
|
||||
</ApplicableTradeTax>
|
||||
<SpecifiedTradePaymentTerms>
|
||||
<Description>Zahlbar innerhalb 30 Tagen ohne Abzug</Description>
|
||||
<DueDateDateTime>
|
||||
<DateTimeString format="102">20240214</DateTimeString>
|
||||
</DueDateDateTime>
|
||||
</SpecifiedTradePaymentTerms>
|
||||
<SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
<LineTotalAmount>100.00</LineTotalAmount>
|
||||
<TaxBasisTotalAmount>100.00</TaxBasisTotalAmount>
|
||||
<TaxTotalAmount currencyID="EUR">19.00</TaxTotalAmount>
|
||||
<GrandTotalAmount>119.00</GrandTotalAmount>
|
||||
<DuePayableAmount>119.00</DuePayableAmount>
|
||||
</SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
</ApplicableHeaderTradeSettlement>
|
||||
</SupplyChainTradeTransaction>
|
||||
</CrossIndustryInvoice>`;
|
||||
|
||||
try {
|
||||
const invoice = new EInvoice();
|
||||
const parseResult = await invoice.fromXmlString(germanComplianceXml);
|
||||
|
||||
if (parseResult) {
|
||||
tools.log('Testing German compliance requirements during conversion...');
|
||||
|
||||
if (typeof invoice.convertTo === 'function') {
|
||||
const conversionResult = await invoice.convertTo('XRECHNUNG');
|
||||
|
||||
if (conversionResult) {
|
||||
const convertedXml = await conversionResult.toXmlString();
|
||||
|
||||
// Check German-specific compliance requirements
|
||||
const germanComplianceChecks = {
|
||||
hasBuyerReference: convertedXml.includes('BUYER-REF-12345'),
|
||||
hasPaymentReference: convertedXml.includes('PAYMENT-REF-67890'),
|
||||
hasGermanVatNumber: convertedXml.includes('DE987654321'),
|
||||
hasGermanAddresses: convertedXml.includes('Berlin') && convertedXml.includes('München'),
|
||||
hasGermanPostCodes: convertedXml.includes('10115') && convertedXml.includes('80331'),
|
||||
hasEuroCurrency: convertedXml.includes('EUR'),
|
||||
hasStandardVatRate: convertedXml.includes('19.00'),
|
||||
hasPaymentTerms: convertedXml.includes('30 Tagen') || convertedXml.includes('payment')
|
||||
};
|
||||
|
||||
tools.log('German Compliance Verification:');
|
||||
tools.log(` Buyer reference preserved: ${germanComplianceChecks.hasBuyerReference}`);
|
||||
tools.log(` Payment reference preserved: ${germanComplianceChecks.hasPaymentReference}`);
|
||||
tools.log(` German VAT number preserved: ${germanComplianceChecks.hasGermanVatNumber}`);
|
||||
tools.log(` German addresses preserved: ${germanComplianceChecks.hasGermanAddresses}`);
|
||||
tools.log(` German postal codes preserved: ${germanComplianceChecks.hasGermanPostCodes}`);
|
||||
tools.log(` Euro currency preserved: ${germanComplianceChecks.hasEuroCurrency}`);
|
||||
tools.log(` Standard VAT rate preserved: ${germanComplianceChecks.hasStandardVatRate}`);
|
||||
tools.log(` Payment terms preserved: ${germanComplianceChecks.hasPaymentTerms}`);
|
||||
|
||||
const complianceScore = Object.values(germanComplianceChecks).filter(Boolean).length;
|
||||
const totalChecks = Object.values(germanComplianceChecks).length;
|
||||
const compliancePercentage = (complianceScore / totalChecks) * 100;
|
||||
|
||||
tools.log(`German compliance score: ${complianceScore}/${totalChecks} (${compliancePercentage.toFixed(1)}%)`);
|
||||
|
||||
if (compliancePercentage >= 80) {
|
||||
tools.log('✓ Good German compliance maintained');
|
||||
} else {
|
||||
tools.log('⚠ German compliance issues detected');
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log('⚠ German compliance conversion returned no result');
|
||||
}
|
||||
} else {
|
||||
tools.log('⚠ German compliance conversion not supported');
|
||||
}
|
||||
} else {
|
||||
tools.log('⚠ German compliance test - ZUGFeRD parsing failed');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
tools.log(`German compliance test failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
PerformanceTracker.recordMetric('conversion-zugferd-to-xrechnung-german-compliance', duration);
|
||||
});
|
||||
|
||||
tap.test('CONV-03: ZUGFeRD to XRechnung Conversion - Corpus Testing', { timeout: testTimeout }, async (tools) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
let processedFiles = 0;
|
||||
let successfulConversions = 0;
|
||||
let conversionErrors = 0;
|
||||
let totalConversionTime = 0;
|
||||
|
||||
try {
|
||||
const zugferdFiles = await CorpusLoader.getFiles('ZUGFERD_V2');
|
||||
tools.log(`Testing ZUGFeRD to XRechnung conversion with ${zugferdFiles.length} ZUGFeRD files`);
|
||||
|
||||
if (zugferdFiles.length === 0) {
|
||||
tools.log('⚠ No ZUGFeRD files found in corpus for conversion testing');
|
||||
return;
|
||||
}
|
||||
|
||||
// Process a subset of files for performance
|
||||
const filesToProcess = zugferdFiles.slice(0, Math.min(6, zugferdFiles.length));
|
||||
|
||||
for (const filePath of filesToProcess) {
|
||||
const fileName = plugins.path.basename(filePath);
|
||||
const fileConversionStart = Date.now();
|
||||
|
||||
try {
|
||||
processedFiles++;
|
||||
|
||||
const invoice = new EInvoice();
|
||||
const parseResult = await invoice.fromFile(filePath);
|
||||
|
||||
if (parseResult) {
|
||||
// Attempt conversion to XRechnung
|
||||
if (typeof invoice.convertTo === 'function') {
|
||||
const conversionResult = await invoice.convertTo('XRECHNUNG');
|
||||
|
||||
const fileConversionTime = Date.now() - fileConversionStart;
|
||||
totalConversionTime += fileConversionTime;
|
||||
|
||||
if (conversionResult) {
|
||||
successfulConversions++;
|
||||
|
||||
tools.log(`✓ ${fileName}: Converted to XRechnung (${fileConversionTime}ms)`);
|
||||
|
||||
// Quick validation of converted content
|
||||
const convertedXml = await conversionResult.toXmlString();
|
||||
if (convertedXml && convertedXml.length > 100) {
|
||||
tools.log(` Converted content length: ${convertedXml.length} chars`);
|
||||
|
||||
// Check for XRechnung characteristics
|
||||
const xrechnungMarkers = {
|
||||
hasXrechnungId: convertedXml.includes('xrechnung') || convertedXml.includes('XRechnung'),
|
||||
hasUblStructure: convertedXml.includes('Invoice') && convertedXml.includes('urn:oasis:names'),
|
||||
hasGermanElements: convertedXml.includes('DE') || convertedXml.includes('EUR')
|
||||
};
|
||||
|
||||
if (Object.values(xrechnungMarkers).some(Boolean)) {
|
||||
tools.log(` ✓ XRechnung characteristics detected`);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
conversionErrors++;
|
||||
tools.log(`⚠ ${fileName}: Conversion returned no result`);
|
||||
}
|
||||
} else {
|
||||
conversionErrors++;
|
||||
tools.log(`⚠ ${fileName}: Conversion method not available`);
|
||||
}
|
||||
} else {
|
||||
conversionErrors++;
|
||||
tools.log(`⚠ ${fileName}: Failed to parse original ZUGFeRD`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
conversionErrors++;
|
||||
const fileConversionTime = Date.now() - fileConversionStart;
|
||||
totalConversionTime += fileConversionTime;
|
||||
|
||||
tools.log(`✗ ${fileName}: Conversion failed - ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const successRate = processedFiles > 0 ? (successfulConversions / processedFiles) * 100 : 0;
|
||||
const averageConversionTime = processedFiles > 0 ? totalConversionTime / processedFiles : 0;
|
||||
|
||||
tools.log(`\nZUGFeRD to XRechnung Conversion Summary:`);
|
||||
tools.log(`- Files processed: ${processedFiles}`);
|
||||
tools.log(`- Successful conversions: ${successfulConversions} (${successRate.toFixed(1)}%)`);
|
||||
tools.log(`- Conversion errors: ${conversionErrors}`);
|
||||
tools.log(`- Average conversion time: ${averageConversionTime.toFixed(1)}ms`);
|
||||
|
||||
// Performance expectations
|
||||
if (processedFiles > 0) {
|
||||
expect(averageConversionTime).toBeLessThan(4000); // 4 seconds max per file
|
||||
}
|
||||
|
||||
// We expect some conversions to work
|
||||
if (processedFiles > 0) {
|
||||
expect(successRate).toBeGreaterThan(0); // At least one conversion should work
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
tools.log(`ZUGFeRD to XRechnung corpus testing failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - startTime;
|
||||
PerformanceTracker.recordMetric('conversion-zugferd-to-xrechnung-corpus', totalDuration);
|
||||
|
||||
tools.log(`ZUGFeRD to XRechnung corpus testing completed in ${totalDuration}ms`);
|
||||
});
|
||||
|
||||
tap.test('CONV-03: Performance Summary', async (tools) => {
|
||||
const operations = [
|
||||
'conversion-zugferd-to-xrechnung-basic',
|
||||
'conversion-zugferd-to-xrechnung-profiles',
|
||||
'conversion-zugferd-to-xrechnung-german-compliance',
|
||||
'conversion-zugferd-to-xrechnung-corpus'
|
||||
];
|
||||
|
||||
tools.log(`\n=== ZUGFeRD to XRechnung Conversion Performance Summary ===`);
|
||||
|
||||
for (const operation of operations) {
|
||||
const summary = await PerformanceTracker.getSummary(operation);
|
||||
if (summary) {
|
||||
tools.log(`${operation}:`);
|
||||
tools.log(` avg=${summary.average}ms, min=${summary.min}ms, max=${summary.max}ms, p95=${summary.p95}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
tools.log(`\nZUGFeRD to XRechnung conversion testing completed.`);
|
||||
});
|
621
test/suite/einvoice_conversion/test.conv-04.field-mapping.ts
Normal file
621
test/suite/einvoice_conversion/test.conv-04.field-mapping.ts
Normal file
@ -0,0 +1,621 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { CorpusLoader } from '../corpus.loader.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
|
||||
tap.test('CONV-04: Field Mapping - should correctly map fields between formats', async (t) => {
|
||||
// CONV-04: Verify accurate field mapping during format conversion
|
||||
// This test ensures data is correctly transferred between different formats
|
||||
|
||||
const performanceTracker = new PerformanceTracker('CONV-04: Field Mapping');
|
||||
const corpusLoader = new CorpusLoader();
|
||||
|
||||
t.test('Basic field mapping UBL to CII', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// UBL invoice with comprehensive fields
|
||||
const ublInvoice = `<?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:UBLVersionID>2.1</cbc:UBLVersionID>
|
||||
<cbc:CustomizationID>urn:cen.eu:en16931:2017</cbc:CustomizationID>
|
||||
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
|
||||
<cbc:ID>FIELD-MAP-001</cbc:ID>
|
||||
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
|
||||
<cbc:DueDate>2025-02-25</cbc:DueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:Note>Field mapping test invoice</cbc:Note>
|
||||
<cbc:TaxPointDate>2025-01-25</cbc:TaxPointDate>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
<cbc:TaxCurrencyCode>EUR</cbc:TaxCurrencyCode>
|
||||
<cbc:BuyerReference>PO-2025-001</cbc:BuyerReference>
|
||||
<cac:OrderReference>
|
||||
<cbc:ID>ORDER-123</cbc:ID>
|
||||
</cac:OrderReference>
|
||||
<cac:BillingReference>
|
||||
<cac:InvoiceDocumentReference>
|
||||
<cbc:ID>PREV-INV-001</cbc:ID>
|
||||
<cbc:IssueDate>2025-01-01</cbc:IssueDate>
|
||||
</cac:InvoiceDocumentReference>
|
||||
</cac:BillingReference>
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cbc:EndpointID schemeID="0088">5790000435975</cbc:EndpointID>
|
||||
<cac:PartyIdentification>
|
||||
<cbc:ID schemeID="0184">DK12345678</cbc:ID>
|
||||
</cac:PartyIdentification>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>Supplier Company A/S</cbc:Name>
|
||||
</cac:PartyName>
|
||||
<cac:PostalAddress>
|
||||
<cbc:StreetName>Main Street</cbc:StreetName>
|
||||
<cbc:BuildingNumber>1</cbc:BuildingNumber>
|
||||
<cbc:CityName>Copenhagen</cbc:CityName>
|
||||
<cbc:PostalZone>1234</cbc:PostalZone>
|
||||
<cbc:CountrySubentity>Capital Region</cbc:CountrySubentity>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>DK</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
<cac:PartyTaxScheme>
|
||||
<cbc:CompanyID>DK12345678</cbc:CompanyID>
|
||||
<cac:TaxScheme>
|
||||
<cbc:ID>VAT</cbc:ID>
|
||||
</cac:TaxScheme>
|
||||
</cac:PartyTaxScheme>
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>Supplier Company A/S</cbc:RegistrationName>
|
||||
<cbc:CompanyID schemeID="0184">DK12345678</cbc:CompanyID>
|
||||
</cac:PartyLegalEntity>
|
||||
<cac:Contact>
|
||||
<cbc:Name>John Doe</cbc:Name>
|
||||
<cbc:Telephone>+45 12345678</cbc:Telephone>
|
||||
<cbc:ElectronicMail>john@supplier.dk</cbc:ElectronicMail>
|
||||
</cac:Contact>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cbc:EndpointID schemeID="0088">5790000435982</cbc:EndpointID>
|
||||
<cac:PartyIdentification>
|
||||
<cbc:ID schemeID="0184">DK87654321</cbc:ID>
|
||||
</cac:PartyIdentification>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>Customer Company B/V</cbc:Name>
|
||||
</cac:PartyName>
|
||||
<cac:PostalAddress>
|
||||
<cbc:StreetName>Market Street</cbc:StreetName>
|
||||
<cbc:BuildingNumber>100</cbc:BuildingNumber>
|
||||
<cbc:CityName>Aarhus</cbc:CityName>
|
||||
<cbc:PostalZone>8000</cbc:PostalZone>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>DK</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
<cac:PartyTaxScheme>
|
||||
<cbc:CompanyID>DK87654321</cbc:CompanyID>
|
||||
<cac:TaxScheme>
|
||||
<cbc:ID>VAT</cbc:ID>
|
||||
</cac:TaxScheme>
|
||||
</cac:PartyTaxScheme>
|
||||
<cac:Contact>
|
||||
<cbc:Name>Jane Smith</cbc:Name>
|
||||
<cbc:ElectronicMail>jane@customer.dk</cbc:ElectronicMail>
|
||||
</cac:Contact>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
<cac:PaymentMeans>
|
||||
<cbc:PaymentMeansCode name="Credit transfer">30</cbc:PaymentMeansCode>
|
||||
<cbc:PaymentID>PAY-2025-001</cbc:PaymentID>
|
||||
<cac:PayeeFinancialAccount>
|
||||
<cbc:ID>DK5000400440116243</cbc:ID>
|
||||
<cbc:Name>Supplier Bank Account</cbc:Name>
|
||||
<cac:FinancialInstitutionBranch>
|
||||
<cbc:ID>DANBDK22</cbc:ID>
|
||||
<cbc:Name>Danske Bank</cbc:Name>
|
||||
</cac:FinancialInstitutionBranch>
|
||||
</cac:PayeeFinancialAccount>
|
||||
</cac:PaymentMeans>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(ublInvoice);
|
||||
|
||||
// Check if key fields are preserved
|
||||
const invoiceData = einvoice.getInvoiceData();
|
||||
if (invoiceData) {
|
||||
// Basic fields
|
||||
expect(invoiceData.invoiceNumber).toBe('FIELD-MAP-001');
|
||||
expect(invoiceData.issueDate).toContain('2025-01-25');
|
||||
expect(invoiceData.dueDate).toContain('2025-02-25');
|
||||
expect(invoiceData.currency).toBe('EUR');
|
||||
|
||||
// Supplier fields
|
||||
if (invoiceData.supplier) {
|
||||
expect(invoiceData.supplier.name).toContain('Supplier Company');
|
||||
expect(invoiceData.supplier.vatNumber).toContain('DK12345678');
|
||||
expect(invoiceData.supplier.address?.street).toContain('Main Street');
|
||||
expect(invoiceData.supplier.address?.city).toBe('Copenhagen');
|
||||
expect(invoiceData.supplier.address?.postalCode).toBe('1234');
|
||||
expect(invoiceData.supplier.address?.country).toBe('DK');
|
||||
}
|
||||
|
||||
// Customer fields
|
||||
if (invoiceData.customer) {
|
||||
expect(invoiceData.customer.name).toContain('Customer Company');
|
||||
expect(invoiceData.customer.vatNumber).toContain('DK87654321');
|
||||
expect(invoiceData.customer.address?.city).toBe('Aarhus');
|
||||
}
|
||||
|
||||
console.log('Basic field mapping verified');
|
||||
} else {
|
||||
console.log('Field mapping through invoice data not available');
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('basic-mapping', elapsed);
|
||||
});
|
||||
|
||||
t.test('Complex nested field mapping', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// CII invoice with nested structures
|
||||
const ciiInvoice = `<?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:ExchangedDocumentContext>
|
||||
<ram:GuidelineSpecifiedDocumentContextParameter>
|
||||
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
|
||||
</ram:GuidelineSpecifiedDocumentContextParameter>
|
||||
</rsm:ExchangedDocumentContext>
|
||||
<rsm:ExchangedDocument>
|
||||
<ram:ID>NESTED-MAP-001</ram:ID>
|
||||
<ram:TypeCode>380</ram:TypeCode>
|
||||
<ram:IssueDateTime>
|
||||
<udt:DateTimeString format="102">20250125</udt:DateTimeString>
|
||||
</ram:IssueDateTime>
|
||||
<ram:IncludedNote>
|
||||
<ram:Content>Complex nested structure test</ram:Content>
|
||||
<ram:SubjectCode>AAI</ram:SubjectCode>
|
||||
</ram:IncludedNote>
|
||||
<ram:IncludedNote>
|
||||
<ram:Content>Second note for testing</ram:Content>
|
||||
<ram:SubjectCode>REG</ram:SubjectCode>
|
||||
</ram:IncludedNote>
|
||||
</rsm:ExchangedDocument>
|
||||
<rsm:SupplyChainTradeTransaction>
|
||||
<ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:AssociatedDocumentLineDocument>
|
||||
<ram:LineID>1</ram:LineID>
|
||||
<ram:IncludedNote>
|
||||
<ram:Content>Line item note</ram:Content>
|
||||
</ram:IncludedNote>
|
||||
</ram:AssociatedDocumentLineDocument>
|
||||
<ram:SpecifiedTradeProduct>
|
||||
<ram:GlobalID schemeID="0160">1234567890123</ram:GlobalID>
|
||||
<ram:SellerAssignedID>PROD-001</ram:SellerAssignedID>
|
||||
<ram:BuyerAssignedID>CUST-PROD-001</ram:BuyerAssignedID>
|
||||
<ram:Name>Complex Product</ram:Name>
|
||||
<ram:Description>Product with multiple identifiers and attributes</ram:Description>
|
||||
<ram:ApplicableProductCharacteristic>
|
||||
<ram:Description>Color</ram:Description>
|
||||
<ram:Value>Blue</ram:Value>
|
||||
</ram:ApplicableProductCharacteristic>
|
||||
<ram:ApplicableProductCharacteristic>
|
||||
<ram:Description>Size</ram:Description>
|
||||
<ram:Value>Large</ram:Value>
|
||||
</ram:ApplicableProductCharacteristic>
|
||||
</ram:SpecifiedTradeProduct>
|
||||
<ram:SpecifiedLineTradeAgreement>
|
||||
<ram:BuyerOrderReferencedDocument>
|
||||
<ram:LineID>PO-LINE-001</ram:LineID>
|
||||
</ram:BuyerOrderReferencedDocument>
|
||||
<ram:GrossPriceProductTradePrice>
|
||||
<ram:ChargeAmount>120.00</ram:ChargeAmount>
|
||||
<ram:AppliedTradeAllowanceCharge>
|
||||
<ram:ChargeIndicator>
|
||||
<udt:Indicator>false</udt:Indicator>
|
||||
</ram:ChargeIndicator>
|
||||
<ram:CalculationPercent>10.00</ram:CalculationPercent>
|
||||
<ram:ActualAmount>12.00</ram:ActualAmount>
|
||||
<ram:Reason>Volume discount</ram:Reason>
|
||||
</ram:AppliedTradeAllowanceCharge>
|
||||
</ram:GrossPriceProductTradePrice>
|
||||
<ram:NetPriceProductTradePrice>
|
||||
<ram:ChargeAmount>108.00</ram:ChargeAmount>
|
||||
</ram:NetPriceProductTradePrice>
|
||||
</ram:SpecifiedLineTradeAgreement>
|
||||
<ram:SpecifiedLineTradeDelivery>
|
||||
<ram:BilledQuantity unitCode="C62">10</ram:BilledQuantity>
|
||||
</ram:SpecifiedLineTradeDelivery>
|
||||
<ram:SpecifiedLineTradeSettlement>
|
||||
<ram:ApplicableTradeTax>
|
||||
<ram:TypeCode>VAT</ram:TypeCode>
|
||||
<ram:CategoryCode>S</ram:CategoryCode>
|
||||
<ram:RateApplicablePercent>19.00</ram:RateApplicablePercent>
|
||||
</ram:ApplicableTradeTax>
|
||||
<ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
<ram:LineTotalAmount>1080.00</ram:LineTotalAmount>
|
||||
</ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
</ram:SpecifiedLineTradeSettlement>
|
||||
</ram:IncludedSupplyChainTradeLineItem>
|
||||
</rsm:SupplyChainTradeTransaction>
|
||||
</rsm:CrossIndustryInvoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(ciiInvoice);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Verify nested structures are preserved
|
||||
expect(xmlString).toContain('NESTED-MAP-001');
|
||||
expect(xmlString).toContain('Complex nested structure test');
|
||||
expect(xmlString).toContain('PROD-001');
|
||||
expect(xmlString).toContain('1234567890123');
|
||||
expect(xmlString).toContain('Color');
|
||||
expect(xmlString).toContain('Blue');
|
||||
expect(xmlString).toContain('Volume discount');
|
||||
|
||||
console.log('Complex nested field mapping tested');
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('nested-mapping', elapsed);
|
||||
});
|
||||
|
||||
t.test('Field mapping with missing optional fields', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Minimal UBL invoice
|
||||
const minimalUbl = `<?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>MINIMAL-001</cbc:ID>
|
||||
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>Minimal Supplier</cbc:RegistrationName>
|
||||
</cac:PartyLegalEntity>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>Minimal Customer</cbc:RegistrationName>
|
||||
</cac:PartyLegalEntity>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
<cac:LegalMonetaryTotal>
|
||||
<cbc:PayableAmount currencyID="EUR">100.00</cbc:PayableAmount>
|
||||
</cac:LegalMonetaryTotal>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(minimalUbl);
|
||||
|
||||
const invoiceData = einvoice.getInvoiceData();
|
||||
|
||||
// Verify mandatory fields are mapped
|
||||
expect(invoiceData?.invoiceNumber).toBe('MINIMAL-001');
|
||||
expect(invoiceData?.issueDate).toContain('2025-01-25');
|
||||
expect(invoiceData?.currency).toBe('EUR');
|
||||
expect(invoiceData?.totalAmount).toBe(100.00);
|
||||
|
||||
// Optional fields should be undefined or have defaults
|
||||
expect(invoiceData?.dueDate).toBeUndefined();
|
||||
expect(invoiceData?.notes).toBeUndefined();
|
||||
expect(invoiceData?.supplier?.vatNumber).toBeUndefined();
|
||||
|
||||
console.log('Minimal field mapping verified');
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('minimal-mapping', elapsed);
|
||||
});
|
||||
|
||||
t.test('Field type conversion mapping', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Invoice with various data types
|
||||
const typeTestInvoice = `<?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>TYPE-TEST-001</cbc:ID>
|
||||
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
|
||||
<cbc:IssueTime>14:30:00</cbc:IssueTime>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
<cbc:LineCountNumeric>5</cbc:LineCountNumeric>
|
||||
<cbc:TaxPointDate>2025-01-25</cbc:TaxPointDate>
|
||||
<cac:InvoicePeriod>
|
||||
<cbc:StartDate>2025-01-01</cbc:StartDate>
|
||||
<cbc:EndDate>2025-01-31</cbc:EndDate>
|
||||
</cac:InvoicePeriod>
|
||||
<cac:OrderReference>
|
||||
<cbc:ID>ORDER-123</cbc:ID>
|
||||
<cbc:SalesOrderID>SO-456</cbc:SalesOrderID>
|
||||
</cac:OrderReference>
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>Type Test Supplier</cbc:RegistrationName>
|
||||
</cac:PartyLegalEntity>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>Type Test Customer</cbc:RegistrationName>
|
||||
</cac:PartyLegalEntity>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
<cac:AllowanceCharge>
|
||||
<cbc:ChargeIndicator>false</cbc:ChargeIndicator>
|
||||
<cbc:MultiplierFactorNumeric>0.05</cbc:MultiplierFactorNumeric>
|
||||
<cbc:Amount currencyID="EUR">50.00</cbc:Amount>
|
||||
<cbc:BaseAmount currencyID="EUR">1000.00</cbc:BaseAmount>
|
||||
</cac:AllowanceCharge>
|
||||
<cac:TaxTotal>
|
||||
<cbc:TaxAmount currencyID="EUR">190.00</cbc:TaxAmount>
|
||||
<cac:TaxSubtotal>
|
||||
<cbc:TaxableAmount currencyID="EUR">1000.00</cbc:TaxableAmount>
|
||||
<cbc:TaxAmount currencyID="EUR">190.00</cbc:TaxAmount>
|
||||
<cac:TaxCategory>
|
||||
<cbc:ID>S</cbc:ID>
|
||||
<cbc:Percent>19.00</cbc:Percent>
|
||||
<cbc:TaxExemptionReasonCode>VATEX-EU-O</cbc:TaxExemptionReasonCode>
|
||||
</cac:TaxCategory>
|
||||
</cac:TaxSubtotal>
|
||||
</cac:TaxTotal>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(typeTestInvoice);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Verify different data types are preserved
|
||||
expect(xmlString).toContain('TYPE-TEST-001'); // String
|
||||
expect(xmlString).toContain('2025-01-25'); // Date
|
||||
expect(xmlString).toContain('14:30:00'); // Time
|
||||
expect(xmlString).toContain('5'); // Integer
|
||||
expect(xmlString).toContain('19.00'); // Decimal
|
||||
expect(xmlString).toContain('false'); // Boolean
|
||||
expect(xmlString).toContain('0.05'); // Float
|
||||
|
||||
console.log('Field type conversion mapping verified');
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('type-conversion', elapsed);
|
||||
});
|
||||
|
||||
t.test('Array field mapping', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Invoice with multiple repeated elements
|
||||
const arrayInvoice = `<?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>ARRAY-TEST-001</cbc:ID>
|
||||
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:Note>First note</cbc:Note>
|
||||
<cbc:Note>Second note</cbc:Note>
|
||||
<cbc:Note>Third note with special chars: €£¥</cbc:Note>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
<cac:AdditionalDocumentReference>
|
||||
<cbc:ID>DOC-001</cbc:ID>
|
||||
<cbc:DocumentType>Contract</cbc:DocumentType>
|
||||
</cac:AdditionalDocumentReference>
|
||||
<cac:AdditionalDocumentReference>
|
||||
<cbc:ID>DOC-002</cbc:ID>
|
||||
<cbc:DocumentType>Purchase Order</cbc:DocumentType>
|
||||
</cac:AdditionalDocumentReference>
|
||||
<cac:AdditionalDocumentReference>
|
||||
<cbc:ID>DOC-003</cbc:ID>
|
||||
<cbc:DocumentType>Delivery Note</cbc:DocumentType>
|
||||
</cac:AdditionalDocumentReference>
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cac:PartyIdentification>
|
||||
<cbc:ID schemeID="GLN">1234567890123</cbc:ID>
|
||||
</cac:PartyIdentification>
|
||||
<cac:PartyIdentification>
|
||||
<cbc:ID schemeID="VAT">DK12345678</cbc:ID>
|
||||
</cac:PartyIdentification>
|
||||
<cac:PartyIdentification>
|
||||
<cbc:ID schemeID="DUNS">123456789</cbc:ID>
|
||||
</cac:PartyIdentification>
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>Array Test Supplier</cbc:RegistrationName>
|
||||
</cac:PartyLegalEntity>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>Array Test Customer</cbc:RegistrationName>
|
||||
</cac:PartyLegalEntity>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
<cac:PaymentMeans>
|
||||
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
|
||||
<cbc:PaymentID>PAY-001</cbc:PaymentID>
|
||||
</cac:PaymentMeans>
|
||||
<cac:PaymentMeans>
|
||||
<cbc:PaymentMeansCode>31</cbc:PaymentMeansCode>
|
||||
<cbc:PaymentID>PAY-002</cbc:PaymentID>
|
||||
</cac:PaymentMeans>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(arrayInvoice);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Verify arrays are preserved
|
||||
expect(xmlString).toContain('First note');
|
||||
expect(xmlString).toContain('Second note');
|
||||
expect(xmlString).toContain('Third note with special chars: €£¥');
|
||||
expect(xmlString).toContain('DOC-001');
|
||||
expect(xmlString).toContain('DOC-002');
|
||||
expect(xmlString).toContain('DOC-003');
|
||||
expect(xmlString).toContain('1234567890123');
|
||||
expect(xmlString).toContain('DK12345678');
|
||||
expect(xmlString).toContain('123456789');
|
||||
|
||||
console.log('Array field mapping verified');
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('array-mapping', elapsed);
|
||||
});
|
||||
|
||||
t.test('Cross-reference field mapping', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Invoice with cross-references between sections
|
||||
const crossRefInvoice = `<?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>XREF-TEST-001</cbc:ID>
|
||||
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
<cac:ProjectReference>
|
||||
<cbc:ID>PROJ-2025-001</cbc:ID>
|
||||
</cac:ProjectReference>
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>Cross Reference Supplier</cbc:RegistrationName>
|
||||
</cac:PartyLegalEntity>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>Cross Reference Customer</cbc:RegistrationName>
|
||||
</cac:PartyLegalEntity>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
<cac:Delivery>
|
||||
<cbc:ActualDeliveryDate>2025-01-20</cbc:ActualDeliveryDate>
|
||||
<cac:DeliveryLocation>
|
||||
<cbc:ID schemeID="GLN">5790000435999</cbc:ID>
|
||||
<cac:Address>
|
||||
<cbc:StreetName>Delivery Street</cbc:StreetName>
|
||||
<cbc:CityName>Copenhagen</cbc:CityName>
|
||||
</cac:Address>
|
||||
</cac:DeliveryLocation>
|
||||
</cac:Delivery>
|
||||
<cac:InvoiceLine>
|
||||
<cbc:ID>1</cbc:ID>
|
||||
<cbc:Note>Delivered to GLN: 5790000435999</cbc:Note>
|
||||
<cbc:InvoicedQuantity unitCode="C62">10</cbc:InvoicedQuantity>
|
||||
<cbc:LineExtensionAmount currencyID="EUR">1000.00</cbc:LineExtensionAmount>
|
||||
<cac:OrderLineReference>
|
||||
<cbc:LineID>ORDER-LINE-001</cbc:LineID>
|
||||
</cac:OrderLineReference>
|
||||
<cac:Item>
|
||||
<cbc:Name>Product for PROJ-2025-001</cbc:Name>
|
||||
</cac:Item>
|
||||
<cac:Price>
|
||||
<cbc:PriceAmount currencyID="EUR">100.00</cbc:PriceAmount>
|
||||
</cac:Price>
|
||||
</cac:InvoiceLine>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(crossRefInvoice);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Verify cross-references are maintained
|
||||
expect(xmlString).toContain('PROJ-2025-001');
|
||||
expect(xmlString).toContain('5790000435999');
|
||||
expect(xmlString).toContain('Delivered to GLN: 5790000435999');
|
||||
expect(xmlString).toContain('Product for PROJ-2025-001');
|
||||
expect(xmlString).toContain('ORDER-LINE-001');
|
||||
|
||||
console.log('Cross-reference field mapping verified');
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('cross-reference', elapsed);
|
||||
});
|
||||
|
||||
t.test('Corpus field mapping validation', async () => {
|
||||
const startTime = performance.now();
|
||||
let processedCount = 0;
|
||||
let mappingIssues = 0;
|
||||
const criticalFields = ['ID', 'IssueDate', 'DocumentCurrencyCode', 'AccountingSupplierParty', 'AccountingCustomerParty'];
|
||||
|
||||
const files = await corpusLoader.getAllFiles();
|
||||
const xmlFiles = files.filter(f => f.endsWith('.xml') && !f.includes('.pdf'));
|
||||
|
||||
// Test field mapping on corpus files
|
||||
const sampleSize = Math.min(30, xmlFiles.length);
|
||||
const sample = xmlFiles.slice(0, sampleSize);
|
||||
|
||||
for (const file of sample) {
|
||||
try {
|
||||
const content = await corpusLoader.readFile(file);
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
if (typeof content === 'string') {
|
||||
await einvoice.loadFromString(content);
|
||||
} else {
|
||||
await einvoice.loadFromBuffer(content);
|
||||
}
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
const invoiceData = einvoice.getInvoiceData();
|
||||
|
||||
// Check critical field mapping
|
||||
let hasIssue = false;
|
||||
|
||||
if (invoiceData) {
|
||||
if (!invoiceData.invoiceNumber && xmlString.includes('<cbc:ID>')) {
|
||||
console.log(`${file}: Invoice number not mapped`);
|
||||
hasIssue = true;
|
||||
}
|
||||
if (!invoiceData.issueDate && xmlString.includes('<cbc:IssueDate>')) {
|
||||
console.log(`${file}: Issue date not mapped`);
|
||||
hasIssue = true;
|
||||
}
|
||||
if (!invoiceData.currency && xmlString.includes('<cbc:DocumentCurrencyCode>')) {
|
||||
console.log(`${file}: Currency not mapped`);
|
||||
hasIssue = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasIssue) mappingIssues++;
|
||||
processedCount++;
|
||||
} catch (error) {
|
||||
console.log(`Field mapping error in ${file}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Corpus field mapping validation (${processedCount} files):`);
|
||||
console.log(`- Files with potential mapping issues: ${mappingIssues}`);
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('corpus-validation', elapsed);
|
||||
});
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
|
||||
// Performance assertions
|
||||
const avgTime = performanceTracker.getAverageTime();
|
||||
expect(avgTime).toBeLessThan(300); // Field mapping should be reasonably fast
|
||||
});
|
||||
|
||||
tap.start();
|
668
test/suite/einvoice_conversion/test.conv-05.mandatory-fields.ts
Normal file
668
test/suite/einvoice_conversion/test.conv-05.mandatory-fields.ts
Normal file
@ -0,0 +1,668 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { CorpusLoader } from '../corpus.loader.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
|
||||
tap.test('CONV-05: Mandatory Fields - should ensure all mandatory fields are preserved', async (t) => {
|
||||
// CONV-05: Verify mandatory fields are maintained during format conversion
|
||||
// This test ensures no required data is lost during transformation
|
||||
|
||||
const performanceTracker = new PerformanceTracker('CONV-05: Mandatory Fields');
|
||||
const corpusLoader = new CorpusLoader();
|
||||
|
||||
t.test('EN16931 mandatory fields in UBL', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// UBL invoice with all EN16931 mandatory fields
|
||||
const ublInvoice = `<?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">
|
||||
<!-- BT-1: Invoice number (mandatory) -->
|
||||
<cbc:ID>MANDATORY-UBL-001</cbc:ID>
|
||||
<!-- BT-2: Invoice issue date (mandatory) -->
|
||||
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
|
||||
<!-- BT-3: Invoice type code (mandatory) -->
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<!-- BT-5: Invoice currency code (mandatory) -->
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
|
||||
<!-- BG-4: Seller (mandatory) -->
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<!-- BT-27: Seller name (mandatory) -->
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>Mandatory Fields Supplier AB</cbc:RegistrationName>
|
||||
</cac:PartyLegalEntity>
|
||||
<!-- BG-5: Seller postal address (mandatory) -->
|
||||
<cac:PostalAddress>
|
||||
<!-- BT-35: Seller address line 1 -->
|
||||
<cbc:StreetName>Kungsgatan 10</cbc:StreetName>
|
||||
<!-- BT-37: Seller city (mandatory) -->
|
||||
<cbc:CityName>Stockholm</cbc:CityName>
|
||||
<!-- BT-38: Seller post code -->
|
||||
<cbc:PostalZone>11143</cbc:PostalZone>
|
||||
<!-- BT-40: Seller country code (mandatory) -->
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>SE</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
<!-- BT-31: Seller VAT identifier -->
|
||||
<cac:PartyTaxScheme>
|
||||
<cbc:CompanyID>SE123456789001</cbc:CompanyID>
|
||||
<cac:TaxScheme>
|
||||
<cbc:ID>VAT</cbc:ID>
|
||||
</cac:TaxScheme>
|
||||
</cac:PartyTaxScheme>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
|
||||
<!-- BG-7: Buyer (mandatory) -->
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<!-- BT-44: Buyer name (mandatory) -->
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>Mandatory Fields Customer AS</cbc:RegistrationName>
|
||||
</cac:PartyLegalEntity>
|
||||
<!-- BG-8: Buyer postal address (mandatory) -->
|
||||
<cac:PostalAddress>
|
||||
<!-- BT-50: Buyer address line 1 -->
|
||||
<cbc:StreetName>Karl Johans gate 1</cbc:StreetName>
|
||||
<!-- BT-52: Buyer city (mandatory) -->
|
||||
<cbc:CityName>Oslo</cbc:CityName>
|
||||
<!-- BT-53: Buyer post code -->
|
||||
<cbc:PostalZone>0154</cbc:PostalZone>
|
||||
<!-- BT-55: Buyer country code (mandatory) -->
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>NO</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
|
||||
<!-- BG-22: Document totals (mandatory) -->
|
||||
<cac:LegalMonetaryTotal>
|
||||
<!-- BT-106: Sum of Invoice line net amount -->
|
||||
<cbc:LineExtensionAmount currencyID="EUR">1000.00</cbc:LineExtensionAmount>
|
||||
<!-- BT-109: Invoice total amount without VAT -->
|
||||
<cbc:TaxExclusiveAmount currencyID="EUR">1000.00</cbc:TaxExclusiveAmount>
|
||||
<!-- BT-112: Invoice total amount with VAT -->
|
||||
<cbc:TaxInclusiveAmount currencyID="EUR">1190.00</cbc:TaxInclusiveAmount>
|
||||
<!-- BT-115: Amount due for payment (mandatory) -->
|
||||
<cbc:PayableAmount currencyID="EUR">1190.00</cbc:PayableAmount>
|
||||
</cac:LegalMonetaryTotal>
|
||||
|
||||
<!-- BG-23: VAT breakdown (mandatory for VAT invoices) -->
|
||||
<cac:TaxTotal>
|
||||
<!-- BT-110: Invoice total VAT amount -->
|
||||
<cbc:TaxAmount currencyID="EUR">190.00</cbc:TaxAmount>
|
||||
<cac:TaxSubtotal>
|
||||
<!-- BT-116: VAT category taxable amount -->
|
||||
<cbc:TaxableAmount currencyID="EUR">1000.00</cbc:TaxableAmount>
|
||||
<!-- BT-117: VAT category tax amount -->
|
||||
<cbc:TaxAmount currencyID="EUR">190.00</cbc:TaxAmount>
|
||||
<cac:TaxCategory>
|
||||
<!-- BT-118: VAT category code (mandatory) -->
|
||||
<cbc:ID>S</cbc:ID>
|
||||
<!-- BT-119: VAT category rate -->
|
||||
<cbc:Percent>19</cbc:Percent>
|
||||
<cac:TaxScheme>
|
||||
<cbc:ID>VAT</cbc:ID>
|
||||
</cac:TaxScheme>
|
||||
</cac:TaxCategory>
|
||||
</cac:TaxSubtotal>
|
||||
</cac:TaxTotal>
|
||||
|
||||
<!-- BG-25: Invoice line (mandatory - at least one) -->
|
||||
<cac:InvoiceLine>
|
||||
<!-- BT-126: Invoice line identifier (mandatory) -->
|
||||
<cbc:ID>1</cbc:ID>
|
||||
<!-- BT-129: Invoiced quantity (mandatory) -->
|
||||
<cbc:InvoicedQuantity unitCode="C62">10</cbc:InvoicedQuantity>
|
||||
<!-- BT-131: Invoice line net amount (mandatory) -->
|
||||
<cbc:LineExtensionAmount currencyID="EUR">1000.00</cbc:LineExtensionAmount>
|
||||
<!-- BT-153: Item name (mandatory) -->
|
||||
<cac:Item>
|
||||
<cbc:Name>Mandatory Test Product</cbc:Name>
|
||||
<!-- BT-151: Item VAT category code (mandatory) -->
|
||||
<cac:ClassifiedTaxCategory>
|
||||
<cbc:ID>S</cbc:ID>
|
||||
<cbc:Percent>19</cbc:Percent>
|
||||
<cac:TaxScheme>
|
||||
<cbc:ID>VAT</cbc:ID>
|
||||
</cac:TaxScheme>
|
||||
</cac:ClassifiedTaxCategory>
|
||||
</cac:Item>
|
||||
<!-- BT-146: Item net price (mandatory) -->
|
||||
<cac:Price>
|
||||
<cbc:PriceAmount currencyID="EUR">100.00</cbc:PriceAmount>
|
||||
</cac:Price>
|
||||
</cac:InvoiceLine>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(ublInvoice);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
const invoiceData = einvoice.getInvoiceData();
|
||||
|
||||
// Verify mandatory fields are present
|
||||
const mandatoryChecks = {
|
||||
'Invoice number': xmlString.includes('MANDATORY-UBL-001'),
|
||||
'Issue date': xmlString.includes('2025-01-25'),
|
||||
'Invoice type': xmlString.includes('380'),
|
||||
'Currency': xmlString.includes('EUR'),
|
||||
'Seller name': xmlString.includes('Mandatory Fields Supplier'),
|
||||
'Seller country': xmlString.includes('SE'),
|
||||
'Buyer name': xmlString.includes('Mandatory Fields Customer'),
|
||||
'Buyer country': xmlString.includes('NO'),
|
||||
'Payable amount': xmlString.includes('1190.00'),
|
||||
'VAT amount': xmlString.includes('190.00'),
|
||||
'Line ID': xmlString.includes('<cbc:ID>1</cbc:ID>') || xmlString.includes('<ram:LineID>1</ram:LineID>'),
|
||||
'Item name': xmlString.includes('Mandatory Test Product')
|
||||
};
|
||||
|
||||
const missingFields = Object.entries(mandatoryChecks)
|
||||
.filter(([field, present]) => !present)
|
||||
.map(([field]) => field);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
console.log('Missing mandatory fields:', missingFields);
|
||||
} else {
|
||||
console.log('All EN16931 mandatory fields preserved');
|
||||
}
|
||||
|
||||
expect(missingFields.length).toBe(0);
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('en16931-mandatory', elapsed);
|
||||
});
|
||||
|
||||
t.test('EN16931 mandatory fields in CII', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// CII invoice with all mandatory fields
|
||||
const ciiInvoice = `<?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:ExchangedDocumentContext>
|
||||
<ram:GuidelineSpecifiedDocumentContextParameter>
|
||||
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
|
||||
</ram:GuidelineSpecifiedDocumentContextParameter>
|
||||
</rsm:ExchangedDocumentContext>
|
||||
|
||||
<rsm:ExchangedDocument>
|
||||
<!-- BT-1: Invoice number (mandatory) -->
|
||||
<ram:ID>MANDATORY-CII-001</ram:ID>
|
||||
<!-- BT-3: Invoice type code (mandatory) -->
|
||||
<ram:TypeCode>380</ram:TypeCode>
|
||||
<!-- BT-2: Invoice issue date (mandatory) -->
|
||||
<ram:IssueDateTime>
|
||||
<udt:DateTimeString format="102">20250125</udt:DateTimeString>
|
||||
</ram:IssueDateTime>
|
||||
</rsm:ExchangedDocument>
|
||||
|
||||
<rsm:SupplyChainTradeTransaction>
|
||||
<!-- Invoice lines -->
|
||||
<ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:AssociatedDocumentLineDocument>
|
||||
<!-- BT-126: Line ID (mandatory) -->
|
||||
<ram:LineID>1</ram:LineID>
|
||||
</ram:AssociatedDocumentLineDocument>
|
||||
<ram:SpecifiedTradeProduct>
|
||||
<!-- BT-153: Item name (mandatory) -->
|
||||
<ram:Name>CII Mandatory Product</ram:Name>
|
||||
</ram:SpecifiedTradeProduct>
|
||||
<ram:SpecifiedLineTradeAgreement>
|
||||
<ram:NetPriceProductTradePrice>
|
||||
<!-- BT-146: Net price (mandatory) -->
|
||||
<ram:ChargeAmount>100.00</ram:ChargeAmount>
|
||||
</ram:NetPriceProductTradePrice>
|
||||
</ram:SpecifiedLineTradeAgreement>
|
||||
<ram:SpecifiedLineTradeDelivery>
|
||||
<!-- BT-129: Quantity (mandatory) -->
|
||||
<ram:BilledQuantity unitCode="C62">10</ram:BilledQuantity>
|
||||
</ram:SpecifiedLineTradeDelivery>
|
||||
<ram:SpecifiedLineTradeSettlement>
|
||||
<ram:ApplicableTradeTax>
|
||||
<ram:TypeCode>VAT</ram:TypeCode>
|
||||
<!-- BT-151: VAT category (mandatory) -->
|
||||
<ram:CategoryCode>S</ram:CategoryCode>
|
||||
<ram:RateApplicablePercent>19</ram:RateApplicablePercent>
|
||||
</ram:ApplicableTradeTax>
|
||||
<ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
<!-- BT-131: Line net amount (mandatory) -->
|
||||
<ram:LineTotalAmount>1000.00</ram:LineTotalAmount>
|
||||
</ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
</ram:SpecifiedLineTradeSettlement>
|
||||
</ram:IncludedSupplyChainTradeLineItem>
|
||||
|
||||
<ram:ApplicableHeaderTradeAgreement>
|
||||
<!-- BG-4: Seller (mandatory) -->
|
||||
<ram:SellerTradeParty>
|
||||
<!-- BT-27: Seller name (mandatory) -->
|
||||
<ram:Name>CII Mandatory Seller</ram:Name>
|
||||
<!-- BG-5: Seller address (mandatory) -->
|
||||
<ram:PostalTradeAddress>
|
||||
<!-- BT-35: Address line -->
|
||||
<ram:LineOne>Musterstraße 1</ram:LineOne>
|
||||
<!-- BT-37: City (mandatory) -->
|
||||
<ram:CityName>Berlin</ram:CityName>
|
||||
<!-- BT-38: Post code -->
|
||||
<ram:PostcodeCode>10115</ram:PostcodeCode>
|
||||
<!-- BT-40: Country (mandatory) -->
|
||||
<ram:CountryID>DE</ram:CountryID>
|
||||
</ram:PostalTradeAddress>
|
||||
<ram:SpecifiedTaxRegistration>
|
||||
<!-- BT-31: VAT ID -->
|
||||
<ram:ID schemeID="VA">DE123456789</ram:ID>
|
||||
</ram:SpecifiedTaxRegistration>
|
||||
</ram:SellerTradeParty>
|
||||
<!-- BG-7: Buyer (mandatory) -->
|
||||
<ram:BuyerTradeParty>
|
||||
<!-- BT-44: Buyer name (mandatory) -->
|
||||
<ram:Name>CII Mandatory Buyer</ram:Name>
|
||||
<!-- BG-8: Buyer address (mandatory) -->
|
||||
<ram:PostalTradeAddress>
|
||||
<!-- BT-50: Address line -->
|
||||
<ram:LineOne>Schulstraße 10</ram:LineOne>
|
||||
<!-- BT-52: City (mandatory) -->
|
||||
<ram:CityName>Hamburg</ram:CityName>
|
||||
<!-- BT-53: Post code -->
|
||||
<ram:PostcodeCode>20095</ram:PostcodeCode>
|
||||
<!-- BT-55: Country (mandatory) -->
|
||||
<ram:CountryID>DE</ram:CountryID>
|
||||
</ram:PostalTradeAddress>
|
||||
</ram:BuyerTradeParty>
|
||||
</ram:ApplicableHeaderTradeAgreement>
|
||||
|
||||
<ram:ApplicableHeaderTradeSettlement>
|
||||
<!-- BT-5: Currency (mandatory) -->
|
||||
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
|
||||
<!-- BG-23: VAT breakdown (mandatory) -->
|
||||
<ram:ApplicableTradeTax>
|
||||
<ram:CalculatedAmount>190.00</ram:CalculatedAmount>
|
||||
<ram:TypeCode>VAT</ram:TypeCode>
|
||||
<!-- BT-118: VAT category (mandatory) -->
|
||||
<ram:CategoryCode>S</ram:CategoryCode>
|
||||
<!-- BT-116: Taxable amount -->
|
||||
<ram:BasisAmount>1000.00</ram:BasisAmount>
|
||||
<!-- BT-119: VAT rate -->
|
||||
<ram:RateApplicablePercent>19</ram:RateApplicablePercent>
|
||||
</ram:ApplicableTradeTax>
|
||||
<!-- BG-22: Totals (mandatory) -->
|
||||
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
<!-- BT-106: Line total -->
|
||||
<ram:LineTotalAmount>1000.00</ram:LineTotalAmount>
|
||||
<!-- BT-109: Tax exclusive -->
|
||||
<ram:TaxBasisTotalAmount>1000.00</ram:TaxBasisTotalAmount>
|
||||
<!-- BT-110/117: Tax amount -->
|
||||
<ram:TaxTotalAmount currencyID="EUR">190.00</ram:TaxTotalAmount>
|
||||
<!-- BT-112: Grand total -->
|
||||
<ram:GrandTotalAmount>1190.00</ram:GrandTotalAmount>
|
||||
<!-- BT-115: Due payable (mandatory) -->
|
||||
<ram:DuePayableAmount>1190.00</ram:DuePayableAmount>
|
||||
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
</ram:ApplicableHeaderTradeSettlement>
|
||||
</rsm:SupplyChainTradeTransaction>
|
||||
</rsm:CrossIndustryInvoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(ciiInvoice);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Verify CII mandatory fields
|
||||
const ciiMandatoryChecks = {
|
||||
'Invoice ID': xmlString.includes('MANDATORY-CII-001'),
|
||||
'Type code': xmlString.includes('380'),
|
||||
'Issue date': xmlString.includes('20250125'),
|
||||
'Currency': xmlString.includes('EUR'),
|
||||
'Seller name': xmlString.includes('CII Mandatory Seller'),
|
||||
'Seller country': xmlString.includes('<ram:CountryID>DE</ram:CountryID>'),
|
||||
'Buyer name': xmlString.includes('CII Mandatory Buyer'),
|
||||
'Line ID': xmlString.includes('<ram:LineID>1</ram:LineID>'),
|
||||
'Product name': xmlString.includes('CII Mandatory Product'),
|
||||
'Due amount': xmlString.includes('<ram:DuePayableAmount>1190.00</ram:DuePayableAmount>')
|
||||
};
|
||||
|
||||
const missingCiiFields = Object.entries(ciiMandatoryChecks)
|
||||
.filter(([field, present]) => !present)
|
||||
.map(([field]) => field);
|
||||
|
||||
if (missingCiiFields.length > 0) {
|
||||
console.log('Missing CII mandatory fields:', missingCiiFields);
|
||||
}
|
||||
|
||||
expect(missingCiiFields.length).toBe(0);
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('cii-mandatory', elapsed);
|
||||
});
|
||||
|
||||
t.test('XRechnung specific mandatory fields', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// XRechnung has additional mandatory fields
|
||||
const xrechnungInvoice = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ubl:Invoice xmlns:ubl="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:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0</cbc:CustomizationID>
|
||||
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
|
||||
<cbc:ID>XRECHNUNG-001</cbc:ID>
|
||||
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
<!-- XRechnung mandatory: BT-10 Buyer reference -->
|
||||
<cbc:BuyerReference>LEITWEG-ID-123456</cbc:BuyerReference>
|
||||
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cbc:EndpointID schemeID="EM">seller@example.de</cbc:EndpointID>
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>XRechnung Seller GmbH</cbc:RegistrationName>
|
||||
</cac:PartyLegalEntity>
|
||||
<cac:PostalAddress>
|
||||
<cbc:StreetName>Berliner Straße 1</cbc:StreetName>
|
||||
<cbc:CityName>Berlin</cbc:CityName>
|
||||
<cbc:PostalZone>10115</cbc:PostalZone>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>DE</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
<cac:Contact>
|
||||
<cbc:Name>Max Mustermann</cbc:Name>
|
||||
<cbc:Telephone>+49 30 12345678</cbc:Telephone>
|
||||
<cbc:ElectronicMail>max@seller.de</cbc:ElectronicMail>
|
||||
</cac:Contact>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cbc:EndpointID schemeID="EM">buyer@behoerde.de</cbc:EndpointID>
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>Bundesbehörde XY</cbc:RegistrationName>
|
||||
</cac:PartyLegalEntity>
|
||||
<cac:PostalAddress>
|
||||
<cbc:StreetName>Amtsstraße 100</cbc:StreetName>
|
||||
<cbc:CityName>Bonn</cbc:CityName>
|
||||
<cbc:PostalZone>53113</cbc:PostalZone>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>DE</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
|
||||
<cac:PaymentMeans>
|
||||
<!-- XRechnung mandatory: Payment means code -->
|
||||
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
|
||||
<cac:PayeeFinancialAccount>
|
||||
<cbc:ID>DE89370400440532013000</cbc:ID>
|
||||
</cac:PayeeFinancialAccount>
|
||||
</cac:PaymentMeans>
|
||||
|
||||
<cac:LegalMonetaryTotal>
|
||||
<cbc:PayableAmount currencyID="EUR">119.00</cbc:PayableAmount>
|
||||
</cac:LegalMonetaryTotal>
|
||||
</ubl:Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(xrechnungInvoice);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Check XRechnung specific mandatory fields
|
||||
const xrechnungChecks = {
|
||||
'Customization ID': xmlString.includes('xrechnung'),
|
||||
'Buyer reference': xmlString.includes('LEITWEG-ID-123456'),
|
||||
'Seller email': xmlString.includes('seller@example.de') || xmlString.includes('max@seller.de'),
|
||||
'Buyer endpoint': xmlString.includes('buyer@behoerde.de'),
|
||||
'Payment means': xmlString.includes('>30<')
|
||||
};
|
||||
|
||||
const missingXrechnung = Object.entries(xrechnungChecks)
|
||||
.filter(([field, present]) => !present)
|
||||
.map(([field]) => field);
|
||||
|
||||
if (missingXrechnung.length > 0) {
|
||||
console.log('Missing XRechnung fields:', missingXrechnung);
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('xrechnung-mandatory', elapsed);
|
||||
});
|
||||
|
||||
t.test('Mandatory fields validation errors', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Invoice missing mandatory fields
|
||||
const incompleteInvoice = `<?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">
|
||||
<!-- Missing: Invoice ID -->
|
||||
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<!-- Missing: Currency code -->
|
||||
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<!-- Missing: Seller name -->
|
||||
<cac:PostalAddress>
|
||||
<cbc:StreetName>Test Street</cbc:StreetName>
|
||||
<!-- Missing: City -->
|
||||
<!-- Missing: Country -->
|
||||
</cac:PostalAddress>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
|
||||
<!-- Missing: Buyer entirely -->
|
||||
|
||||
<!-- Missing: Totals -->
|
||||
|
||||
<!-- Missing: Invoice lines -->
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
try {
|
||||
await einvoice.loadFromString(incompleteInvoice);
|
||||
|
||||
const validationResult = await einvoice.validate();
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
console.log('Validation detected missing mandatory fields');
|
||||
|
||||
// Check for specific mandatory field errors
|
||||
const mandatoryErrors = validationResult.errors?.filter(err =>
|
||||
err.message.toLowerCase().includes('mandatory') ||
|
||||
err.message.toLowerCase().includes('required') ||
|
||||
err.message.toLowerCase().includes('must')
|
||||
);
|
||||
|
||||
if (mandatoryErrors && mandatoryErrors.length > 0) {
|
||||
console.log(`Found ${mandatoryErrors.length} mandatory field errors`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Processing incomplete invoice:', error.message);
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('validation-errors', elapsed);
|
||||
});
|
||||
|
||||
t.test('Conditional mandatory fields', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Some fields are mandatory only in certain conditions
|
||||
const conditionalInvoice = `<?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>CONDITIONAL-001</cbc:ID>
|
||||
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>VAT Exempt Supplier</cbc:RegistrationName>
|
||||
</cac:PartyLegalEntity>
|
||||
<cac:PostalAddress>
|
||||
<cbc:CityName>Paris</cbc:CityName>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>FR</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>Tax Exempt Customer</cbc:RegistrationName>
|
||||
</cac:PartyLegalEntity>
|
||||
<cac:PostalAddress>
|
||||
<cbc:CityName>Brussels</cbc:CityName>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>BE</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
|
||||
<!-- VAT exempt scenario -->
|
||||
<cac:TaxTotal>
|
||||
<cbc:TaxAmount currencyID="EUR">0.00</cbc:TaxAmount>
|
||||
<cac:TaxSubtotal>
|
||||
<cbc:TaxableAmount currencyID="EUR">1000.00</cbc:TaxableAmount>
|
||||
<cbc:TaxAmount currencyID="EUR">0.00</cbc:TaxAmount>
|
||||
<cac:TaxCategory>
|
||||
<cbc:ID>E</cbc:ID>
|
||||
<cbc:Percent>0</cbc:Percent>
|
||||
<!-- Mandatory when tax category is E: Exemption reason -->
|
||||
<cbc:TaxExemptionReasonCode>VATEX-EU-IC</cbc:TaxExemptionReasonCode>
|
||||
<cbc:TaxExemptionReason>Intra-community supply</cbc:TaxExemptionReason>
|
||||
<cac:TaxScheme>
|
||||
<cbc:ID>VAT</cbc:ID>
|
||||
</cac:TaxScheme>
|
||||
</cac:TaxCategory>
|
||||
</cac:TaxSubtotal>
|
||||
</cac:TaxTotal>
|
||||
|
||||
<!-- Credit note specific mandatory fields -->
|
||||
<cac:BillingReference>
|
||||
<cac:InvoiceDocumentReference>
|
||||
<!-- Mandatory for credit notes: Referenced invoice -->
|
||||
<cbc:ID>ORIGINAL-INV-001</cbc:ID>
|
||||
<cbc:IssueDate>2025-01-01</cbc:IssueDate>
|
||||
</cac:InvoiceDocumentReference>
|
||||
</cac:BillingReference>
|
||||
|
||||
<cac:LegalMonetaryTotal>
|
||||
<cbc:PayableAmount currencyID="EUR">1000.00</cbc:PayableAmount>
|
||||
</cac:LegalMonetaryTotal>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(conditionalInvoice);
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Check conditional mandatory fields
|
||||
const conditionalChecks = {
|
||||
'VAT exemption reason code': xmlString.includes('VATEX-EU-IC'),
|
||||
'VAT exemption reason': xmlString.includes('Intra-community supply'),
|
||||
'Referenced invoice': xmlString.includes('ORIGINAL-INV-001')
|
||||
};
|
||||
|
||||
Object.entries(conditionalChecks).forEach(([field, present]) => {
|
||||
if (present) {
|
||||
console.log(`✓ Conditional mandatory field preserved: ${field}`);
|
||||
}
|
||||
});
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('conditional-mandatory', elapsed);
|
||||
});
|
||||
|
||||
t.test('Corpus mandatory fields analysis', async () => {
|
||||
const startTime = performance.now();
|
||||
let processedCount = 0;
|
||||
const missingFieldStats: Record<string, number> = {};
|
||||
|
||||
const files = await corpusLoader.getAllFiles();
|
||||
const xmlFiles = files.filter(f => f.endsWith('.xml') && !f.includes('.pdf'));
|
||||
|
||||
// Sample corpus files for mandatory field analysis
|
||||
const sampleSize = Math.min(40, xmlFiles.length);
|
||||
const sample = xmlFiles.slice(0, sampleSize);
|
||||
|
||||
for (const file of sample) {
|
||||
try {
|
||||
const content = await corpusLoader.readFile(file);
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
if (typeof content === 'string') {
|
||||
await einvoice.loadFromString(content);
|
||||
} else {
|
||||
await einvoice.loadFromBuffer(content);
|
||||
}
|
||||
|
||||
const xmlString = einvoice.getXmlString();
|
||||
|
||||
// Check for mandatory fields
|
||||
const mandatoryFields = [
|
||||
{ name: 'Invoice ID', patterns: ['<cbc:ID>', '<ram:ID>'] },
|
||||
{ name: 'Issue Date', patterns: ['<cbc:IssueDate>', '<ram:IssueDateTime>'] },
|
||||
{ name: 'Currency', patterns: ['<cbc:DocumentCurrencyCode>', '<ram:InvoiceCurrencyCode>'] },
|
||||
{ name: 'Seller Name', patterns: ['<cbc:RegistrationName>', '<ram:Name>'] },
|
||||
{ name: 'Buyer Name', patterns: ['AccountingCustomerParty', 'BuyerTradeParty'] },
|
||||
{ name: 'Total Amount', patterns: ['<cbc:PayableAmount>', '<ram:DuePayableAmount>'] }
|
||||
];
|
||||
|
||||
mandatoryFields.forEach(field => {
|
||||
const hasField = field.patterns.some(pattern => xmlString.includes(pattern));
|
||||
if (!hasField) {
|
||||
missingFieldStats[field.name] = (missingFieldStats[field.name] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
processedCount++;
|
||||
} catch (error) {
|
||||
console.log(`Error checking ${file}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Corpus mandatory fields analysis (${processedCount} files):`);
|
||||
if (Object.keys(missingFieldStats).length > 0) {
|
||||
console.log('Files missing mandatory fields:');
|
||||
Object.entries(missingFieldStats)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.forEach(([field, count]) => {
|
||||
console.log(` ${field}: ${count} files`);
|
||||
});
|
||||
} else {
|
||||
console.log('All sampled files have mandatory fields');
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('corpus-analysis', elapsed);
|
||||
});
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
|
||||
// Performance assertions
|
||||
const avgTime = performanceTracker.getAverageTime();
|
||||
expect(avgTime).toBeLessThan(300); // Mandatory field checks should be fast
|
||||
});
|
||||
|
||||
tap.start();
|
@ -0,0 +1,826 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../../../ts/plugins.ts';
|
||||
import { EInvoice } from '../../../ts/classes.xinvoice.ts';
|
||||
import { CorpusLoader } from '../../helpers/corpus.loader.ts';
|
||||
import { PerformanceTracker } from '../../helpers/performance.tracker.ts';
|
||||
|
||||
const testTimeout = 300000; // 5 minutes timeout for conversion processing
|
||||
|
||||
// CONV-06: Data Loss Detection
|
||||
// Tests detection and reporting of data loss during format conversions
|
||||
// including field mapping limitations, unsupported features, and precision loss
|
||||
|
||||
tap.test('CONV-06: Data Loss Detection - Field Mapping Loss', async (tools) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Test data loss detection during conversions with rich data
|
||||
const richDataUblXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>DATA-LOSS-TEST-001</ID>
|
||||
<IssueDate>2024-01-15</IssueDate>
|
||||
<InvoiceTypeCode>380</InvoiceTypeCode>
|
||||
<DocumentCurrencyCode>EUR</DocumentCurrencyCode>
|
||||
<Note>Rich data invoice for data loss detection testing</Note>
|
||||
<InvoicePeriod>
|
||||
<StartDate>2024-01-01</StartDate>
|
||||
<EndDate>2024-01-31</EndDate>
|
||||
<Description>January 2024 billing period</Description>
|
||||
</InvoicePeriod>
|
||||
<OrderReference>
|
||||
<ID>ORDER-12345</ID>
|
||||
<IssueDate>2023-12-15</IssueDate>
|
||||
</OrderReference>
|
||||
<BillingReference>
|
||||
<InvoiceDocumentReference>
|
||||
<ID>BILLING-REF-678</ID>
|
||||
</InvoiceDocumentReference>
|
||||
</BillingReference>
|
||||
<DespatchDocumentReference>
|
||||
<ID>DESPATCH-890</ID>
|
||||
</DespatchDocumentReference>
|
||||
<ReceiptDocumentReference>
|
||||
<ID>RECEIPT-ABC</ID>
|
||||
</ReceiptDocumentReference>
|
||||
<ContractDocumentReference>
|
||||
<ID>CONTRACT-XYZ</ID>
|
||||
</ContractDocumentReference>
|
||||
<AdditionalDocumentReference>
|
||||
<ID>ADDITIONAL-DOC-123</ID>
|
||||
<DocumentType>Specification</DocumentType>
|
||||
<Attachment>
|
||||
<EmbeddedDocumentBinaryObject mimeCode="application/pdf" filename="spec.pdf">UERGIGNvbnRlbnQgRXhhbXBsZQ==</EmbeddedDocumentBinaryObject>
|
||||
</Attachment>
|
||||
</AdditionalDocumentReference>
|
||||
<AccountingSupplierParty>
|
||||
<Party>
|
||||
<PartyIdentification>
|
||||
<ID schemeID="0088">1234567890123</ID>
|
||||
</PartyIdentification>
|
||||
<PartyName>
|
||||
<Name>Rich Data Supplier Ltd</Name>
|
||||
</PartyName>
|
||||
<PostalAddress>
|
||||
<StreetName>Innovation Street 123</StreetName>
|
||||
<AdditionalStreetName>Building A, Floor 5</AdditionalStreetName>
|
||||
<CityName>Tech City</CityName>
|
||||
<PostalZone>12345</PostalZone>
|
||||
<CountrySubentity>Tech State</CountrySubentity>
|
||||
<AddressLine>
|
||||
<Line>Additional address information</Line>
|
||||
</AddressLine>
|
||||
<Country>
|
||||
<IdentificationCode>DE</IdentificationCode>
|
||||
</Country>
|
||||
</PostalAddress>
|
||||
<PartyTaxScheme>
|
||||
<CompanyID>DE123456789</CompanyID>
|
||||
<TaxScheme>
|
||||
<ID>VAT</ID>
|
||||
</TaxScheme>
|
||||
</PartyTaxScheme>
|
||||
<PartyLegalEntity>
|
||||
<RegistrationName>Rich Data Supplier Limited</RegistrationName>
|
||||
<CompanyID schemeID="0021">HRB123456</CompanyID>
|
||||
</PartyLegalEntity>
|
||||
<Contact>
|
||||
<Name>John Doe</Name>
|
||||
<Telephone>+49-30-12345678</Telephone>
|
||||
<Telefax>+49-30-12345679</Telefax>
|
||||
<ElectronicMail>john.doe@richdata.com</ElectronicMail>
|
||||
</Contact>
|
||||
</Party>
|
||||
</AccountingSupplierParty>
|
||||
<AccountingCustomerParty>
|
||||
<Party>
|
||||
<PartyIdentification>
|
||||
<ID schemeID="0088">9876543210987</ID>
|
||||
</PartyIdentification>
|
||||
<PartyName>
|
||||
<Name>Rich Data Customer GmbH</Name>
|
||||
</PartyName>
|
||||
<PostalAddress>
|
||||
<StreetName>Customer Boulevard 456</StreetName>
|
||||
<CityName>Customer City</CityName>
|
||||
<PostalZone>54321</PostalZone>
|
||||
<Country>
|
||||
<IdentificationCode>DE</IdentificationCode>
|
||||
</Country>
|
||||
</PostalAddress>
|
||||
</Party>
|
||||
</AccountingCustomerParty>
|
||||
<Delivery>
|
||||
<DeliveryLocation>
|
||||
<Address>
|
||||
<StreetName>Delivery Street 789</StreetName>
|
||||
<CityName>Delivery City</CityName>
|
||||
<PostalZone>98765</PostalZone>
|
||||
<Country>
|
||||
<IdentificationCode>DE</IdentificationCode>
|
||||
</Country>
|
||||
</Address>
|
||||
</DeliveryLocation>
|
||||
<ActualDeliveryDate>2024-01-10</ActualDeliveryDate>
|
||||
</Delivery>
|
||||
<PaymentMeans>
|
||||
<PaymentMeansCode>58</PaymentMeansCode>
|
||||
<PaymentID>PAYMENT-ID-456</PaymentID>
|
||||
<PayeeFinancialAccount>
|
||||
<ID>DE89370400440532013000</ID>
|
||||
<Name>Rich Data Account</Name>
|
||||
<FinancialInstitutionBranch>
|
||||
<ID>COBADEFFXXX</ID>
|
||||
</FinancialInstitutionBranch>
|
||||
</PayeeFinancialAccount>
|
||||
</PaymentMeans>
|
||||
<PaymentTerms>
|
||||
<Note>Payment due within 30 days. 2% discount if paid within 10 days.</Note>
|
||||
</PaymentTerms>
|
||||
<AllowanceCharge>
|
||||
<ChargeIndicator>false</ChargeIndicator>
|
||||
<AllowanceChargeReasonCode>95</AllowanceChargeReasonCode>
|
||||
<AllowanceChargeReason>Volume discount</AllowanceChargeReason>
|
||||
<Amount currencyID="EUR">10.00</Amount>
|
||||
<BaseAmount currencyID="EUR">100.00</BaseAmount>
|
||||
<MultiplierFactorNumeric>0.1</MultiplierFactorNumeric>
|
||||
</AllowanceCharge>
|
||||
<InvoiceLine>
|
||||
<ID>1</ID>
|
||||
<InvoicedQuantity unitCode="C62">2</InvoicedQuantity>
|
||||
<LineExtensionAmount currencyID="EUR">90.00</LineExtensionAmount>
|
||||
<OrderLineReference>
|
||||
<LineID>ORDER-LINE-1</LineID>
|
||||
</OrderLineReference>
|
||||
<Item>
|
||||
<Description>Premium product with rich metadata</Description>
|
||||
<Name>Rich Data Product Pro</Name>
|
||||
<BuyersItemIdentification>
|
||||
<ID>BUYER-SKU-123</ID>
|
||||
</BuyersItemIdentification>
|
||||
<SellersItemIdentification>
|
||||
<ID>SELLER-SKU-456</ID>
|
||||
</SellersItemIdentification>
|
||||
<ManufacturersItemIdentification>
|
||||
<ID>MFG-SKU-789</ID>
|
||||
</ManufacturersItemIdentification>
|
||||
<StandardItemIdentification>
|
||||
<ID schemeID="0160">1234567890123</ID>
|
||||
</StandardItemIdentification>
|
||||
<ItemSpecificationDocumentReference>
|
||||
<ID>SPEC-DOC-001</ID>
|
||||
</ItemSpecificationDocumentReference>
|
||||
<OriginCountry>
|
||||
<IdentificationCode>DE</IdentificationCode>
|
||||
</OriginCountry>
|
||||
<CommodityClassification>
|
||||
<ItemClassificationCode listID="UNSPSC">43211508</ItemClassificationCode>
|
||||
</CommodityClassification>
|
||||
<ClassifiedTaxCategory>
|
||||
<Percent>19.00</Percent>
|
||||
<TaxScheme>
|
||||
<ID>VAT</ID>
|
||||
</TaxScheme>
|
||||
</ClassifiedTaxCategory>
|
||||
<AdditionalItemProperty>
|
||||
<Name>Color</Name>
|
||||
<Value>Blue</Value>
|
||||
</AdditionalItemProperty>
|
||||
<AdditionalItemProperty>
|
||||
<Name>Weight</Name>
|
||||
<Value>2.5</Value>
|
||||
<ValueQuantity unitCode="KGM">2.5</ValueQuantity>
|
||||
</AdditionalItemProperty>
|
||||
</Item>
|
||||
<Price>
|
||||
<PriceAmount currencyID="EUR">50.00</PriceAmount>
|
||||
<BaseQuantity unitCode="C62">1</BaseQuantity>
|
||||
</Price>
|
||||
</InvoiceLine>
|
||||
<TaxTotal>
|
||||
<TaxAmount currencyID="EUR">17.10</TaxAmount>
|
||||
<TaxSubtotal>
|
||||
<TaxableAmount currencyID="EUR">90.00</TaxableAmount>
|
||||
<TaxAmount currencyID="EUR">17.10</TaxAmount>
|
||||
<TaxCategory>
|
||||
<Percent>19.00</Percent>
|
||||
<TaxScheme>
|
||||
<ID>VAT</ID>
|
||||
</TaxScheme>
|
||||
</TaxCategory>
|
||||
</TaxSubtotal>
|
||||
</TaxTotal>
|
||||
<LegalMonetaryTotal>
|
||||
<LineExtensionAmount currencyID="EUR">100.00</LineExtensionAmount>
|
||||
<AllowanceTotalAmount currencyID="EUR">10.00</AllowanceTotalAmount>
|
||||
<TaxExclusiveAmount currencyID="EUR">90.00</TaxExclusiveAmount>
|
||||
<TaxInclusiveAmount currencyID="EUR">107.10</TaxInclusiveAmount>
|
||||
<PayableAmount currencyID="EUR">107.10</PayableAmount>
|
||||
</LegalMonetaryTotal>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const invoice = new EInvoice();
|
||||
const parseResult = await invoice.fromXmlString(richDataUblXml);
|
||||
expect(parseResult).toBeTruthy();
|
||||
|
||||
// Extract original data elements for comparison
|
||||
const originalData = {
|
||||
invoicePeriod: richDataUblXml.includes('InvoicePeriod'),
|
||||
orderReference: richDataUblXml.includes('OrderReference'),
|
||||
billingReference: richDataUblXml.includes('BillingReference'),
|
||||
additionalDocuments: richDataUblXml.includes('AdditionalDocumentReference'),
|
||||
embeddedDocuments: richDataUblXml.includes('EmbeddedDocumentBinaryObject'),
|
||||
contactInformation: richDataUblXml.includes('Contact'),
|
||||
deliveryInformation: richDataUblXml.includes('Delivery'),
|
||||
paymentMeans: richDataUblXml.includes('PaymentMeans'),
|
||||
allowanceCharges: richDataUblXml.includes('AllowanceCharge'),
|
||||
itemProperties: richDataUblXml.includes('AdditionalItemProperty'),
|
||||
itemIdentifications: richDataUblXml.includes('BuyersItemIdentification'),
|
||||
taxDetails: richDataUblXml.includes('TaxSubtotal')
|
||||
};
|
||||
|
||||
tools.log('Original UBL data elements detected:');
|
||||
Object.entries(originalData).forEach(([key, value]) => {
|
||||
tools.log(` ${key}: ${value}`);
|
||||
});
|
||||
|
||||
// Test conversion and data loss detection
|
||||
const conversionTargets = ['CII', 'XRECHNUNG'];
|
||||
|
||||
for (const target of conversionTargets) {
|
||||
tools.log(`\nTesting data loss in UBL to ${target} conversion...`);
|
||||
|
||||
try {
|
||||
if (typeof invoice.convertTo === 'function') {
|
||||
const conversionResult = await invoice.convertTo(target);
|
||||
|
||||
if (conversionResult) {
|
||||
const convertedXml = await conversionResult.toXmlString();
|
||||
|
||||
// Check for data preservation
|
||||
const preservedData = {
|
||||
invoicePeriod: convertedXml.includes('Period') || convertedXml.includes('BillingPeriod'),
|
||||
orderReference: convertedXml.includes('ORDER-12345') || convertedXml.includes('OrderReference'),
|
||||
billingReference: convertedXml.includes('BILLING-REF-678') || convertedXml.includes('BillingReference'),
|
||||
additionalDocuments: convertedXml.includes('ADDITIONAL-DOC-123') || convertedXml.includes('AdditionalDocument'),
|
||||
embeddedDocuments: convertedXml.includes('UERGIGNvbnRlbnQgRXhhbXBsZQ==') || convertedXml.includes('EmbeddedDocument'),
|
||||
contactInformation: convertedXml.includes('john.doe@richdata.com') || convertedXml.includes('Contact'),
|
||||
deliveryInformation: convertedXml.includes('Delivery Street') || convertedXml.includes('Delivery'),
|
||||
paymentMeans: convertedXml.includes('DE89370400440532013000') || convertedXml.includes('PaymentMeans'),
|
||||
allowanceCharges: convertedXml.includes('Volume discount') || convertedXml.includes('Allowance'),
|
||||
itemProperties: convertedXml.includes('Color') || convertedXml.includes('Blue'),
|
||||
itemIdentifications: convertedXml.includes('BUYER-SKU-123') || convertedXml.includes('ItemIdentification'),
|
||||
taxDetails: convertedXml.includes('17.10') && convertedXml.includes('19.00')
|
||||
};
|
||||
|
||||
tools.log(`Data preservation in ${target} format:`);
|
||||
let preservedCount = 0;
|
||||
let totalElements = 0;
|
||||
|
||||
Object.entries(preservedData).forEach(([key, preserved]) => {
|
||||
const wasOriginal = originalData[key];
|
||||
tools.log(` ${key}: ${wasOriginal ? (preserved ? 'PRESERVED' : 'LOST') : 'N/A'}`);
|
||||
if (wasOriginal) {
|
||||
totalElements++;
|
||||
if (preserved) preservedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const preservationRate = totalElements > 0 ? (preservedCount / totalElements) * 100 : 0;
|
||||
const dataLossRate = 100 - preservationRate;
|
||||
|
||||
tools.log(`\n${target} Conversion Results:`);
|
||||
tools.log(` Elements preserved: ${preservedCount}/${totalElements}`);
|
||||
tools.log(` Preservation rate: ${preservationRate.toFixed(1)}%`);
|
||||
tools.log(` Data loss rate: ${dataLossRate.toFixed(1)}%`);
|
||||
|
||||
if (dataLossRate > 0) {
|
||||
tools.log(` ⚠ Data loss detected in ${target} conversion`);
|
||||
|
||||
// Identify specific losses
|
||||
const lostElements = Object.entries(preservedData)
|
||||
.filter(([key, preserved]) => originalData[key] && !preserved)
|
||||
.map(([key]) => key);
|
||||
|
||||
if (lostElements.length > 0) {
|
||||
tools.log(` Lost elements: ${lostElements.join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
tools.log(` ✓ No data loss detected in ${target} conversion`);
|
||||
}
|
||||
|
||||
// Test if data loss detection is available in the API
|
||||
if (typeof conversionResult.getDataLossReport === 'function') {
|
||||
try {
|
||||
const dataLossReport = await conversionResult.getDataLossReport();
|
||||
if (dataLossReport) {
|
||||
tools.log(` Data loss report available: ${dataLossReport.lostFields?.length || 0} lost fields`);
|
||||
}
|
||||
} catch (reportError) {
|
||||
tools.log(` Data loss report error: ${reportError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log(` ⚠ ${target} conversion returned no result`);
|
||||
}
|
||||
} else {
|
||||
tools.log(` ⚠ ${target} conversion not supported`);
|
||||
}
|
||||
|
||||
} catch (conversionError) {
|
||||
tools.log(` ✗ ${target} conversion failed: ${conversionError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
tools.log(`Field mapping loss test failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
PerformanceTracker.recordMetric('data-loss-field-mapping', duration);
|
||||
});
|
||||
|
||||
tap.test('CONV-06: Data Loss Detection - Precision Loss', async (tools) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Test precision loss in numeric values during conversion
|
||||
const precisionTestXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>PRECISION-TEST-001</ID>
|
||||
<IssueDate>2024-01-15</IssueDate>
|
||||
<InvoiceTypeCode>380</InvoiceTypeCode>
|
||||
<DocumentCurrencyCode>EUR</DocumentCurrencyCode>
|
||||
<InvoiceLine>
|
||||
<ID>1</ID>
|
||||
<InvoicedQuantity unitCode="C62">3.14159</InvoicedQuantity>
|
||||
<LineExtensionAmount currencyID="EUR">33.33333</LineExtensionAmount>
|
||||
<Item>
|
||||
<Name>Precision Test Product</Name>
|
||||
<AdditionalItemProperty>
|
||||
<Name>Precise Weight</Name>
|
||||
<Value>2.718281828</Value>
|
||||
</AdditionalItemProperty>
|
||||
<AdditionalItemProperty>
|
||||
<Name>Very Precise Measurement</Name>
|
||||
<Value>1.4142135623730951</Value>
|
||||
</AdditionalItemProperty>
|
||||
</Item>
|
||||
<Price>
|
||||
<PriceAmount currencyID="EUR">10.617</PriceAmount>
|
||||
</Price>
|
||||
</InvoiceLine>
|
||||
<TaxTotal>
|
||||
<TaxAmount currencyID="EUR">6.33333</TaxAmount>
|
||||
<TaxSubtotal>
|
||||
<TaxableAmount currencyID="EUR">33.33333</TaxableAmount>
|
||||
<TaxAmount currencyID="EUR">6.33333</TaxAmount>
|
||||
<TaxCategory>
|
||||
<Percent>19.00000</Percent>
|
||||
</TaxCategory>
|
||||
</TaxSubtotal>
|
||||
</TaxTotal>
|
||||
<LegalMonetaryTotal>
|
||||
<LineExtensionAmount currencyID="EUR">33.33333</LineExtensionAmount>
|
||||
<TaxExclusiveAmount currencyID="EUR">33.33333</TaxExclusiveAmount>
|
||||
<TaxInclusiveAmount currencyID="EUR">39.66666</TaxInclusiveAmount>
|
||||
<PayableAmount currencyID="EUR">39.66666</PayableAmount>
|
||||
</LegalMonetaryTotal>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const invoice = new EInvoice();
|
||||
const parseResult = await invoice.fromXmlString(precisionTestXml);
|
||||
|
||||
if (parseResult) {
|
||||
tools.log('Testing precision loss during format conversion...');
|
||||
|
||||
// Extract original precision values
|
||||
const originalPrecisionValues = {
|
||||
quantity: '3.14159',
|
||||
lineAmount: '33.33333',
|
||||
priceAmount: '10.617',
|
||||
taxAmount: '6.33333',
|
||||
preciseWeight: '2.718281828',
|
||||
veryPreciseMeasurement: '1.4142135623730951'
|
||||
};
|
||||
|
||||
const conversionTargets = ['CII'];
|
||||
|
||||
for (const target of conversionTargets) {
|
||||
tools.log(`\nTesting precision preservation in ${target} conversion...`);
|
||||
|
||||
try {
|
||||
if (typeof invoice.convertTo === 'function') {
|
||||
const conversionResult = await invoice.convertTo(target);
|
||||
|
||||
if (conversionResult) {
|
||||
const convertedXml = await conversionResult.toXmlString();
|
||||
|
||||
// Check precision preservation
|
||||
const precisionPreservation = {};
|
||||
let totalPrecisionTests = 0;
|
||||
let precisionPreserved = 0;
|
||||
|
||||
Object.entries(originalPrecisionValues).forEach(([key, originalValue]) => {
|
||||
totalPrecisionTests++;
|
||||
const isPreserved = convertedXml.includes(originalValue);
|
||||
precisionPreservation[key] = isPreserved;
|
||||
|
||||
if (isPreserved) {
|
||||
precisionPreserved++;
|
||||
tools.log(` ✓ ${key}: ${originalValue} preserved`);
|
||||
} else {
|
||||
// Check for rounded values
|
||||
const rounded2 = parseFloat(originalValue).toFixed(2);
|
||||
const rounded3 = parseFloat(originalValue).toFixed(3);
|
||||
|
||||
if (convertedXml.includes(rounded2)) {
|
||||
tools.log(` ⚠ ${key}: ${originalValue} → ${rounded2} (rounded to 2 decimals)`);
|
||||
} else if (convertedXml.includes(rounded3)) {
|
||||
tools.log(` ⚠ ${key}: ${originalValue} → ${rounded3} (rounded to 3 decimals)`);
|
||||
} else {
|
||||
tools.log(` ✗ ${key}: ${originalValue} lost or heavily modified`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const precisionRate = totalPrecisionTests > 0 ? (precisionPreserved / totalPrecisionTests) * 100 : 0;
|
||||
const precisionLossRate = 100 - precisionRate;
|
||||
|
||||
tools.log(`\n${target} Precision Results:`);
|
||||
tools.log(` Values with full precision: ${precisionPreserved}/${totalPrecisionTests}`);
|
||||
tools.log(` Precision preservation rate: ${precisionRate.toFixed(1)}%`);
|
||||
tools.log(` Precision loss rate: ${precisionLossRate.toFixed(1)}%`);
|
||||
|
||||
if (precisionLossRate > 0) {
|
||||
tools.log(` ⚠ Precision loss detected - may be due to format limitations`);
|
||||
} else {
|
||||
tools.log(` ✓ Full precision maintained`);
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log(` ⚠ ${target} conversion returned no result`);
|
||||
}
|
||||
} else {
|
||||
tools.log(` ⚠ ${target} conversion not supported`);
|
||||
}
|
||||
|
||||
} catch (conversionError) {
|
||||
tools.log(` ✗ ${target} conversion failed: ${conversionError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log('⚠ Precision test - UBL parsing failed');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
tools.log(`Precision loss test failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
PerformanceTracker.recordMetric('data-loss-precision', duration);
|
||||
});
|
||||
|
||||
tap.test('CONV-06: Data Loss Detection - Unsupported Features', async (tools) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Test handling of format-specific features that may not be supported in target format
|
||||
const unsupportedFeaturesTests = [
|
||||
{
|
||||
name: 'UBL Specific Features',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>UNSUPPORTED-UBL-001</ID>
|
||||
<IssueDate>2024-01-15</IssueDate>
|
||||
<InvoiceTypeCode>380</InvoiceTypeCode>
|
||||
<UUID>550e8400-e29b-41d4-a716-446655440000</UUID>
|
||||
<ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</ProfileID>
|
||||
<ProfileExecutionID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</ProfileExecutionID>
|
||||
<BuyerCustomerParty>
|
||||
<Party>
|
||||
<PartyName>
|
||||
<Name>Different Customer Structure</Name>
|
||||
</PartyName>
|
||||
</Party>
|
||||
</BuyerCustomerParty>
|
||||
<TaxRepresentativeParty>
|
||||
<PartyName>
|
||||
<Name>Tax Representative</Name>
|
||||
</PartyName>
|
||||
</TaxRepresentativeParty>
|
||||
<ProjectReference>
|
||||
<ID>PROJECT-123</ID>
|
||||
</ProjectReference>
|
||||
</Invoice>`,
|
||||
features: ['UUID', 'ProfileExecutionID', 'BuyerCustomerParty', 'TaxRepresentativeParty', 'ProjectReference']
|
||||
},
|
||||
{
|
||||
name: 'Advanced Payment Features',
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>PAYMENT-FEATURES-001</ID>
|
||||
<IssueDate>2024-01-15</IssueDate>
|
||||
<InvoiceTypeCode>380</InvoiceTypeCode>
|
||||
<PrepaidPayment>
|
||||
<PaidAmount currencyID="EUR">50.00</PaidAmount>
|
||||
<PaidDate>2024-01-01</PaidDate>
|
||||
</PrepaidPayment>
|
||||
<PaymentMeans>
|
||||
<PaymentMeansCode>31</PaymentMeansCode>
|
||||
<PaymentDueDate>2024-02-15</PaymentDueDate>
|
||||
<InstructionID>INSTRUCTION-789</InstructionID>
|
||||
<PaymentChannelCode>ONLINE</PaymentChannelCode>
|
||||
</PaymentMeans>
|
||||
<PaymentTerms>
|
||||
<SettlementDiscountPercent>2.00</SettlementDiscountPercent>
|
||||
<PenaltySurchargePercent>1.50</PenaltySurchargePercent>
|
||||
<PaymentMeansID>PAYMENT-MEANS-ABC</PaymentMeansID>
|
||||
</PaymentTerms>
|
||||
</Invoice>`,
|
||||
features: ['PrepaidPayment', 'PaymentDueDate', 'InstructionID', 'PaymentChannelCode', 'SettlementDiscountPercent', 'PenaltySurchargePercent']
|
||||
}
|
||||
];
|
||||
|
||||
for (const featureTest of unsupportedFeaturesTests) {
|
||||
tools.log(`\nTesting unsupported features: ${featureTest.name}`);
|
||||
|
||||
try {
|
||||
const invoice = new EInvoice();
|
||||
const parseResult = await invoice.fromXmlString(featureTest.xml);
|
||||
|
||||
if (parseResult) {
|
||||
// Test conversion to different formats
|
||||
const targets = ['CII'];
|
||||
|
||||
for (const target of targets) {
|
||||
tools.log(` Converting to ${target}...`);
|
||||
|
||||
try {
|
||||
if (typeof invoice.convertTo === 'function') {
|
||||
const conversionResult = await invoice.convertTo(target);
|
||||
|
||||
if (conversionResult) {
|
||||
const convertedXml = await conversionResult.toXmlString();
|
||||
|
||||
// Check for feature preservation
|
||||
const featurePreservation = {};
|
||||
let preservedFeatures = 0;
|
||||
let totalFeatures = featureTest.features.length;
|
||||
|
||||
featureTest.features.forEach(feature => {
|
||||
const isPreserved = convertedXml.includes(feature) ||
|
||||
convertedXml.toLowerCase().includes(feature.toLowerCase());
|
||||
featurePreservation[feature] = isPreserved;
|
||||
|
||||
if (isPreserved) {
|
||||
preservedFeatures++;
|
||||
tools.log(` ✓ ${feature}: preserved`);
|
||||
} else {
|
||||
tools.log(` ✗ ${feature}: not preserved (may be unsupported)`);
|
||||
}
|
||||
});
|
||||
|
||||
const featurePreservationRate = totalFeatures > 0 ? (preservedFeatures / totalFeatures) * 100 : 0;
|
||||
const featureLossRate = 100 - featurePreservationRate;
|
||||
|
||||
tools.log(` ${target} Feature Support:`);
|
||||
tools.log(` Preserved features: ${preservedFeatures}/${totalFeatures}`);
|
||||
tools.log(` Feature preservation rate: ${featurePreservationRate.toFixed(1)}%`);
|
||||
tools.log(` Feature loss rate: ${featureLossRate.toFixed(1)}%`);
|
||||
|
||||
if (featureLossRate > 50) {
|
||||
tools.log(` ⚠ High feature loss - target format may not support these features`);
|
||||
} else if (featureLossRate > 0) {
|
||||
tools.log(` ⚠ Some features lost - partial support in target format`);
|
||||
} else {
|
||||
tools.log(` ✓ All features preserved`);
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log(` ⚠ ${target} conversion returned no result`);
|
||||
}
|
||||
} else {
|
||||
tools.log(` ⚠ ${target} conversion not supported`);
|
||||
}
|
||||
|
||||
} catch (conversionError) {
|
||||
tools.log(` ✗ ${target} conversion failed: ${conversionError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log(` ⚠ ${featureTest.name} UBL parsing failed`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
tools.log(` ✗ ${featureTest.name} test failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
PerformanceTracker.recordMetric('data-loss-unsupported-features', duration);
|
||||
});
|
||||
|
||||
tap.test('CONV-06: Data Loss Detection - Round-Trip Loss Analysis', async (tools) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Test data loss in round-trip conversions (UBL → CII → UBL)
|
||||
const roundTripTestXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>ROUND-TRIP-001</ID>
|
||||
<IssueDate>2024-01-15</IssueDate>
|
||||
<InvoiceTypeCode>380</InvoiceTypeCode>
|
||||
<DocumentCurrencyCode>EUR</DocumentCurrencyCode>
|
||||
<Note>Round-trip conversion test</Note>
|
||||
<AccountingSupplierParty>
|
||||
<Party>
|
||||
<PartyName>
|
||||
<Name>Round Trip Supplier</Name>
|
||||
</PartyName>
|
||||
<PostalAddress>
|
||||
<StreetName>Round Trip Street 123</StreetName>
|
||||
<CityName>Round Trip City</CityName>
|
||||
<PostalZone>12345</PostalZone>
|
||||
<Country>
|
||||
<IdentificationCode>DE</IdentificationCode>
|
||||
</Country>
|
||||
</PostalAddress>
|
||||
</Party>
|
||||
</AccountingSupplierParty>
|
||||
<InvoiceLine>
|
||||
<ID>1</ID>
|
||||
<InvoicedQuantity unitCode="C62">1.5</InvoicedQuantity>
|
||||
<LineExtensionAmount currencyID="EUR">75.50</LineExtensionAmount>
|
||||
<Item>
|
||||
<Name>Round Trip Product</Name>
|
||||
<Description>Product for round-trip testing</Description>
|
||||
</Item>
|
||||
<Price>
|
||||
<PriceAmount currencyID="EUR">50.33</PriceAmount>
|
||||
</Price>
|
||||
</InvoiceLine>
|
||||
<LegalMonetaryTotal>
|
||||
<LineExtensionAmount currencyID="EUR">75.50</LineExtensionAmount>
|
||||
<TaxExclusiveAmount currencyID="EUR">75.50</TaxExclusiveAmount>
|
||||
<TaxInclusiveAmount currencyID="EUR">89.85</TaxInclusiveAmount>
|
||||
<PayableAmount currencyID="EUR">89.85</PayableAmount>
|
||||
</LegalMonetaryTotal>
|
||||
</Invoice>`;
|
||||
|
||||
try {
|
||||
const originalInvoice = new EInvoice();
|
||||
const parseResult = await originalInvoice.fromXmlString(roundTripTestXml);
|
||||
|
||||
if (parseResult) {
|
||||
tools.log('Testing round-trip data loss (UBL → CII → UBL)...');
|
||||
|
||||
// Extract key data from original
|
||||
const originalData = {
|
||||
id: 'ROUND-TRIP-001',
|
||||
supplierName: 'Round Trip Supplier',
|
||||
streetName: 'Round Trip Street 123',
|
||||
cityName: 'Round Trip City',
|
||||
postalCode: '12345',
|
||||
productName: 'Round Trip Product',
|
||||
quantity: '1.5',
|
||||
price: '50.33',
|
||||
lineAmount: '75.50',
|
||||
payableAmount: '89.85'
|
||||
};
|
||||
|
||||
try {
|
||||
// Step 1: UBL → CII
|
||||
if (typeof originalInvoice.convertTo === 'function') {
|
||||
const ciiInvoice = await originalInvoice.convertTo('CII');
|
||||
|
||||
if (ciiInvoice) {
|
||||
tools.log('✓ Step 1: UBL → CII conversion completed');
|
||||
|
||||
const ciiXml = await ciiInvoice.toXmlString();
|
||||
|
||||
// Check data preservation in CII
|
||||
const ciiPreservation = {};
|
||||
let ciiPreserved = 0;
|
||||
|
||||
Object.entries(originalData).forEach(([key, value]) => {
|
||||
const isPreserved = ciiXml.includes(value);
|
||||
ciiPreservation[key] = isPreserved;
|
||||
if (isPreserved) ciiPreserved++;
|
||||
});
|
||||
|
||||
const ciiPreservationRate = (ciiPreserved / Object.keys(originalData).length) * 100;
|
||||
tools.log(` CII preservation rate: ${ciiPreservationRate.toFixed(1)}%`);
|
||||
|
||||
// Step 2: CII → UBL (round-trip)
|
||||
if (typeof ciiInvoice.convertTo === 'function') {
|
||||
const roundTripInvoice = await ciiInvoice.convertTo('UBL');
|
||||
|
||||
if (roundTripInvoice) {
|
||||
tools.log('✓ Step 2: CII → UBL conversion completed');
|
||||
|
||||
const roundTripXml = await roundTripInvoice.toXmlString();
|
||||
|
||||
// Check data preservation after round-trip
|
||||
const roundTripPreservation = {};
|
||||
let roundTripPreserved = 0;
|
||||
|
||||
Object.entries(originalData).forEach(([key, value]) => {
|
||||
const isPreserved = roundTripXml.includes(value);
|
||||
roundTripPreservation[key] = isPreserved;
|
||||
if (isPreserved) roundTripPreserved++;
|
||||
|
||||
const originalPresent = originalData[key];
|
||||
const ciiPresent = ciiPreservation[key];
|
||||
const roundTripPresent = isPreserved;
|
||||
|
||||
let status = 'LOST';
|
||||
if (roundTripPresent) status = 'PRESERVED';
|
||||
else if (ciiPresent) status = 'LOST_IN_ROUND_TRIP';
|
||||
else status = 'LOST_IN_FIRST_CONVERSION';
|
||||
|
||||
tools.log(` ${key}: ${status}`);
|
||||
});
|
||||
|
||||
const roundTripPreservationRate = (roundTripPreserved / Object.keys(originalData).length) * 100;
|
||||
const totalDataLoss = 100 - roundTripPreservationRate;
|
||||
|
||||
tools.log(`\nRound-Trip Analysis Results:`);
|
||||
tools.log(` Original elements: ${Object.keys(originalData).length}`);
|
||||
tools.log(` After CII conversion: ${ciiPreserved} preserved (${ciiPreservationRate.toFixed(1)}%)`);
|
||||
tools.log(` After round-trip: ${roundTripPreserved} preserved (${roundTripPreservationRate.toFixed(1)}%)`);
|
||||
tools.log(` Total data loss: ${totalDataLoss.toFixed(1)}%`);
|
||||
|
||||
if (totalDataLoss === 0) {
|
||||
tools.log(` ✓ Perfect round-trip - no data loss`);
|
||||
} else if (totalDataLoss < 20) {
|
||||
tools.log(` ✓ Good round-trip - minimal data loss`);
|
||||
} else if (totalDataLoss < 50) {
|
||||
tools.log(` ⚠ Moderate round-trip data loss`);
|
||||
} else {
|
||||
tools.log(` ✗ High round-trip data loss`);
|
||||
}
|
||||
|
||||
// Compare file sizes
|
||||
const originalSize = roundTripTestXml.length;
|
||||
const roundTripSize = roundTripXml.length;
|
||||
const sizeDifference = Math.abs(roundTripSize - originalSize);
|
||||
const sizeChangePercent = (sizeDifference / originalSize) * 100;
|
||||
|
||||
tools.log(` Size analysis:`);
|
||||
tools.log(` Original: ${originalSize} chars`);
|
||||
tools.log(` Round-trip: ${roundTripSize} chars`);
|
||||
tools.log(` Size change: ${sizeChangePercent.toFixed(1)}%`);
|
||||
|
||||
} else {
|
||||
tools.log('⚠ Step 2: CII → UBL conversion returned no result');
|
||||
}
|
||||
} else {
|
||||
tools.log('⚠ Step 2: CII → UBL conversion not supported');
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log('⚠ Step 1: UBL → CII conversion returned no result');
|
||||
}
|
||||
} else {
|
||||
tools.log('⚠ Round-trip conversion not supported');
|
||||
}
|
||||
|
||||
} catch (conversionError) {
|
||||
tools.log(`Round-trip conversion failed: ${conversionError.message}`);
|
||||
}
|
||||
|
||||
} else {
|
||||
tools.log('⚠ Round-trip test - original UBL parsing failed');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
tools.log(`Round-trip loss analysis failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
PerformanceTracker.recordMetric('data-loss-round-trip', duration);
|
||||
});
|
||||
|
||||
tap.test('CONV-06: Performance Summary', async (tools) => {
|
||||
const operations = [
|
||||
'data-loss-field-mapping',
|
||||
'data-loss-precision',
|
||||
'data-loss-unsupported-features',
|
||||
'data-loss-round-trip'
|
||||
];
|
||||
|
||||
tools.log(`\n=== Data Loss Detection Performance Summary ===`);
|
||||
|
||||
for (const operation of operations) {
|
||||
const summary = await PerformanceTracker.getSummary(operation);
|
||||
if (summary) {
|
||||
tools.log(`${operation}:`);
|
||||
tools.log(` avg=${summary.average}ms, min=${summary.min}ms, max=${summary.max}ms, p95=${summary.p95}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
tools.log(`\nData loss detection testing completed.`);
|
||||
tools.log(`Note: Some data loss is expected when converting between different formats`);
|
||||
tools.log(`due to format-specific features and structural differences.`);
|
||||
});
|
@ -0,0 +1,523 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { CorpusLoader } from '../corpus.loader.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
|
||||
tap.test('CONV-07: Character Encoding - should preserve character encoding during conversion', async (t) => {
|
||||
// CONV-07: Verify character encoding is maintained across format conversions
|
||||
// This test ensures special characters and international text are preserved
|
||||
|
||||
const performanceTracker = new PerformanceTracker('CONV-07: Character Encoding');
|
||||
const corpusLoader = new CorpusLoader();
|
||||
|
||||
t.test('UTF-8 encoding preservation in conversion', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// UBL invoice with various UTF-8 characters
|
||||
const ublInvoice = `<?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>UTF8-CONV-001</cbc:ID>
|
||||
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:Note>Special characters: € £ ¥ © ® ™ § ¶ • ° ± × ÷</cbc:Note>
|
||||
<cbc:Note>Diacritics: àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ</cbc:Note>
|
||||
<cbc:Note>Greek: ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ αβγδεζηθικλμνξοπρστυφχψω</cbc:Note>
|
||||
<cbc:Note>Cyrillic: АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ</cbc:Note>
|
||||
<cbc:Note>CJK: 中文 日本語 한국어</cbc:Note>
|
||||
<cbc:Note>Arabic: العربية مرحبا</cbc:Note>
|
||||
<cbc:Note>Hebrew: עברית שלום</cbc:Note>
|
||||
<cbc:Note>Emoji: 😀 🎉 💰 📧 🌍</cbc:Note>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>Société Générale Müller & Associés</cbc:Name>
|
||||
</cac:PartyName>
|
||||
<cac:PostalAddress>
|
||||
<cbc:StreetName>Rue de la Légion d'Honneur</cbc:StreetName>
|
||||
<cbc:CityName>Zürich</cbc:CityName>
|
||||
<cbc:PostalZone>8001</cbc:PostalZone>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>CH</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
<cac:Contact>
|
||||
<cbc:Name>François Lefèvre</cbc:Name>
|
||||
<cbc:ElectronicMail>françois@société-générale.ch</cbc:ElectronicMail>
|
||||
</cac:Contact>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>北京科技有限公司 (Beijing Tech Co.)</cbc:Name>
|
||||
</cac:PartyName>
|
||||
<cac:PostalAddress>
|
||||
<cbc:StreetName>北京市朝阳区建国路88号</cbc:StreetName>
|
||||
<cbc:CityName>北京</cbc:CityName>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>CN</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
|
||||
<cac:InvoiceLine>
|
||||
<cbc:ID>1</cbc:ID>
|
||||
<cbc:Note>Spëcïål cháracters in line: ñ ç ø å æ þ ð</cbc:Note>
|
||||
<cbc:InvoicedQuantity unitCode="C62">10</cbc:InvoicedQuantity>
|
||||
<cbc:LineExtensionAmount currencyID="EUR">1000.00</cbc:LineExtensionAmount>
|
||||
<cac:Item>
|
||||
<cbc:Name>Bücher über Köln – München</cbc:Name>
|
||||
<cbc:Description>Prix: 25,50 € (TVA incluse) • Größe: 21×29,7 cm²</cbc:Description>
|
||||
</cac:Item>
|
||||
<cac:Price>
|
||||
<cbc:PriceAmount currencyID="EUR">100.00</cbc:PriceAmount>
|
||||
</cac:Price>
|
||||
</cac:InvoiceLine>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(ublInvoice);
|
||||
|
||||
// Convert to another format (simulated by getting XML back)
|
||||
const convertedXml = einvoice.getXmlString();
|
||||
|
||||
// Verify all special characters are preserved
|
||||
const encodingChecks = [
|
||||
// Currency symbols
|
||||
{ char: '€', name: 'Euro' },
|
||||
{ char: '£', name: 'Pound' },
|
||||
{ char: '¥', name: 'Yen' },
|
||||
// Special symbols
|
||||
{ char: '©', name: 'Copyright' },
|
||||
{ char: '®', name: 'Registered' },
|
||||
{ char: '™', name: 'Trademark' },
|
||||
{ char: '×', name: 'Multiplication' },
|
||||
{ char: '÷', name: 'Division' },
|
||||
// Diacritics
|
||||
{ char: 'àáâãäå', name: 'Latin a variations' },
|
||||
{ char: 'çñøæþð', name: 'Special Latin' },
|
||||
// Greek
|
||||
{ char: 'ΑΒΓΔ', name: 'Greek uppercase' },
|
||||
{ char: 'αβγδ', name: 'Greek lowercase' },
|
||||
// Cyrillic
|
||||
{ char: 'АБВГ', name: 'Cyrillic' },
|
||||
// CJK
|
||||
{ char: '中文', name: 'Chinese' },
|
||||
{ char: '日本語', name: 'Japanese' },
|
||||
{ char: '한국어', name: 'Korean' },
|
||||
// RTL
|
||||
{ char: 'العربية', name: 'Arabic' },
|
||||
{ char: 'עברית', name: 'Hebrew' },
|
||||
// Emoji
|
||||
{ char: '😀', name: 'Emoji' },
|
||||
// Names with diacritics
|
||||
{ char: 'François Lefèvre', name: 'French name' },
|
||||
{ char: 'Zürich', name: 'Swiss city' },
|
||||
{ char: 'Müller', name: 'German name' },
|
||||
// Special punctuation
|
||||
{ char: '–', name: 'En dash' },
|
||||
{ char: '•', name: 'Bullet' },
|
||||
{ char: '²', name: 'Superscript' }
|
||||
];
|
||||
|
||||
let preservedCount = 0;
|
||||
const missingChars: string[] = [];
|
||||
|
||||
encodingChecks.forEach(check => {
|
||||
if (convertedXml.includes(check.char)) {
|
||||
preservedCount++;
|
||||
} else {
|
||||
missingChars.push(`${check.name} (${check.char})`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`UTF-8 preservation: ${preservedCount}/${encodingChecks.length} character sets preserved`);
|
||||
if (missingChars.length > 0) {
|
||||
console.log('Missing characters:', missingChars);
|
||||
}
|
||||
|
||||
expect(preservedCount).toBeGreaterThan(encodingChecks.length * 0.9); // Allow 10% loss
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('utf8-preservation', elapsed);
|
||||
});
|
||||
|
||||
t.test('Entity encoding in conversion', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// CII invoice with XML entities
|
||||
const ciiInvoice = `<?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">
|
||||
<rsm:ExchangedDocument>
|
||||
<ram:ID>ENTITY-CONV-001</ram:ID>
|
||||
<ram:IncludedNote>
|
||||
<ram:Content>XML entities: <invoice> & "quotes" with 'apostrophes'</ram:Content>
|
||||
</ram:IncludedNote>
|
||||
<ram:IncludedNote>
|
||||
<ram:Content>Numeric entities: € £ ¥ ™</ram:Content>
|
||||
</ram:IncludedNote>
|
||||
<ram:IncludedNote>
|
||||
<ram:Content>Hex entities: € £ ¥</ram:Content>
|
||||
</ram:IncludedNote>
|
||||
</rsm:ExchangedDocument>
|
||||
<rsm:SupplyChainTradeTransaction>
|
||||
<ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:SpecifiedTradeProduct>
|
||||
<ram:Name>Product & Service <Premium></ram:Name>
|
||||
<ram:Description>Price comparison: USD < EUR > GBP</ram:Description>
|
||||
</ram:SpecifiedTradeProduct>
|
||||
</ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:ApplicableHeaderTradeAgreement>
|
||||
<ram:SellerTradeParty>
|
||||
<ram:Name>Smith & Jones "Trading" Ltd.</ram:Name>
|
||||
<ram:Description>Registered in England & Wales</ram:Description>
|
||||
</ram:SellerTradeParty>
|
||||
</ram:ApplicableHeaderTradeAgreement>
|
||||
</rsm:SupplyChainTradeTransaction>
|
||||
</rsm:CrossIndustryInvoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(ciiInvoice);
|
||||
|
||||
const convertedXml = einvoice.getXmlString();
|
||||
|
||||
// Check entity preservation
|
||||
const entityChecks = {
|
||||
'Ampersand entity': convertedXml.includes('&') || convertedXml.includes(' & '),
|
||||
'Less than entity': convertedXml.includes('<') || convertedXml.includes(' < '),
|
||||
'Greater than entity': convertedXml.includes('>') || convertedXml.includes(' > '),
|
||||
'Quote preservation': convertedXml.includes('"quotes"') || convertedXml.includes('"quotes"'),
|
||||
'Apostrophe preservation': convertedXml.includes("'apostrophes'") || convertedXml.includes(''apostrophes''),
|
||||
'Numeric entities': convertedXml.includes('€') || convertedXml.includes('€'),
|
||||
'Hex entities': convertedXml.includes('£') || convertedXml.includes('£')
|
||||
};
|
||||
|
||||
Object.entries(entityChecks).forEach(([check, passed]) => {
|
||||
if (passed) {
|
||||
console.log(`✓ ${check}`);
|
||||
} else {
|
||||
console.log(`✗ ${check}`);
|
||||
}
|
||||
});
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('entity-encoding', elapsed);
|
||||
});
|
||||
|
||||
t.test('Mixed encoding scenarios', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Invoice with mixed encoding challenges
|
||||
const mixedInvoice = `<?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>MIXED-ENC-001</cbc:ID>
|
||||
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
<cbc:Note><![CDATA[CDATA content: <tag> & special chars € £ ¥]]></cbc:Note>
|
||||
<cbc:Note>Mixed: Normal text with €100 and <escaped> content</cbc:Note>
|
||||
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>Müller & Associés S.à r.l.</cbc:Name>
|
||||
</cac:PartyName>
|
||||
<cac:PostalAddress>
|
||||
<cbc:StreetName>Hauptstraße 42 (Gebäude "A")</cbc:StreetName>
|
||||
<cbc:AdditionalStreetName><![CDATA[Floor 3 & 4]]></cbc:AdditionalStreetName>
|
||||
<cbc:CityName>Köln</cbc:CityName>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>DE</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
|
||||
<cac:PaymentTerms>
|
||||
<cbc:Note>Payment terms: 2/10 net 30 (2% if paid <= 10 days)</cbc:Note>
|
||||
<cbc:Note><![CDATA[Bank: Société Générale
|
||||
IBAN: FR14 2004 1010 0505 0001 3M02 606
|
||||
BIC: SOGEFRPP]]></cbc:Note>
|
||||
</cac:PaymentTerms>
|
||||
|
||||
<cac:InvoiceLine>
|
||||
<cbc:ID>1</cbc:ID>
|
||||
<cbc:Note>Temperature range: -40°C ≤ T ≤ +85°C</cbc:Note>
|
||||
<cbc:InvoicedQuantity unitCode="C62">10</cbc:InvoicedQuantity>
|
||||
<cbc:LineExtensionAmount currencyID="EUR">1000.00</cbc:LineExtensionAmount>
|
||||
<cac:Item>
|
||||
<cbc:Name>Product™ with ® symbol © 2025</cbc:Name>
|
||||
<cbc:Description>Size: 10cm × 20cm × 5cm • Weight: ≈1kg</cbc:Description>
|
||||
<cac:AdditionalItemProperty>
|
||||
<cbc:Name>Special chars</cbc:Name>
|
||||
<cbc:Value>α β γ δ ε ≠ ∞ ∑ √ ∫</cbc:Value>
|
||||
</cac:AdditionalItemProperty>
|
||||
</cac:Item>
|
||||
</cac:InvoiceLine>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(mixedInvoice);
|
||||
|
||||
const convertedXml = einvoice.getXmlString();
|
||||
|
||||
// Check mixed encoding preservation
|
||||
const mixedChecks = {
|
||||
'CDATA content': convertedXml.includes('CDATA content') || convertedXml.includes('<tag>'),
|
||||
'Mixed entities and Unicode': convertedXml.includes('€100') || convertedXml.includes('€100'),
|
||||
'German umlauts': convertedXml.includes('Müller') && convertedXml.includes('Köln'),
|
||||
'French accents': convertedXml.includes('Associés') && convertedXml.includes('Société'),
|
||||
'Mathematical symbols': convertedXml.includes('≤') && convertedXml.includes('≈'),
|
||||
'Trademark symbols': convertedXml.includes('™') && convertedXml.includes('®'),
|
||||
'Greek letters': convertedXml.includes('α') || convertedXml.includes('beta'),
|
||||
'Temperature notation': convertedXml.includes('°C'),
|
||||
'Multiplication sign': convertedXml.includes('×'),
|
||||
'CDATA in address': convertedXml.includes('Floor 3') || convertedXml.includes('& 4')
|
||||
};
|
||||
|
||||
const passedChecks = Object.entries(mixedChecks).filter(([_, passed]) => passed).length;
|
||||
console.log(`Mixed encoding: ${passedChecks}/${Object.keys(mixedChecks).length} checks passed`);
|
||||
|
||||
expect(passedChecks).toBeGreaterThan(Object.keys(mixedChecks).length * 0.8);
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('mixed-encoding', elapsed);
|
||||
});
|
||||
|
||||
t.test('Encoding in different invoice formats', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Test encoding across different format characteristics
|
||||
const formats = [
|
||||
{
|
||||
name: 'UBL with namespaces',
|
||||
content: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ubl:Invoice xmlns:ubl="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">NS-€-001</cbc:ID>
|
||||
<cbc:Note xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">Namespace test: €£¥</cbc:Note>
|
||||
</ubl:Invoice>`
|
||||
},
|
||||
{
|
||||
name: 'CII with complex structure',
|
||||
content: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CrossIndustryInvoice xmlns="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
|
||||
<ExchangedDocument>
|
||||
<ID>CII-Ü-001</ID>
|
||||
<Name>Übersicht über Änderungen</Name>
|
||||
</ExchangedDocument>
|
||||
</CrossIndustryInvoice>`
|
||||
},
|
||||
{
|
||||
name: 'Factur-X with French',
|
||||
content: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CrossIndustryInvoice>
|
||||
<ExchangedDocument>
|
||||
<ID>FX-FR-001</ID>
|
||||
<IncludedNote>
|
||||
<Content>Facture détaillée avec références spéciales</Content>
|
||||
</IncludedNote>
|
||||
</ExchangedDocument>
|
||||
</CrossIndustryInvoice>`
|
||||
}
|
||||
];
|
||||
|
||||
for (const format of formats) {
|
||||
try {
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(format.content);
|
||||
const converted = einvoice.getXmlString();
|
||||
|
||||
// Check key characters are preserved
|
||||
let preserved = true;
|
||||
if (format.name.includes('UBL') && !converted.includes('€£¥')) preserved = false;
|
||||
if (format.name.includes('CII') && !converted.includes('Ü')) preserved = false;
|
||||
if (format.name.includes('French') && !converted.includes('détaillée')) preserved = false;
|
||||
|
||||
console.log(`${format.name}: ${preserved ? '✓' : '✗'} Encoding preserved`);
|
||||
} catch (error) {
|
||||
console.log(`${format.name}: Error - ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('format-encoding', elapsed);
|
||||
});
|
||||
|
||||
t.test('Bidirectional text preservation', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Test RTL (Right-to-Left) text preservation
|
||||
const rtlInvoice = `<?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>RTL-TEST-001</cbc:ID>
|
||||
<cbc:IssueDate>2025-01-25</cbc:IssueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>شركة التقنية المحدودة</cbc:Name>
|
||||
</cac:PartyName>
|
||||
<cac:PostalAddress>
|
||||
<cbc:StreetName>شارع الملك فهد 123</cbc:StreetName>
|
||||
<cbc:CityName>الرياض</cbc:CityName>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cac:PartyName>
|
||||
<cbc:Name>חברת הטכנולוגיה בע"מ</cbc:Name>
|
||||
</cac:PartyName>
|
||||
<cac:PostalAddress>
|
||||
<cbc:StreetName>רחוב דיזנגוף 456</cbc:StreetName>
|
||||
<cbc:CityName>תל אביב</cbc:CityName>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>IL</cbc:IdentificationCode>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
|
||||
<cac:InvoiceLine>
|
||||
<cbc:ID>1</cbc:ID>
|
||||
<cbc:Note>Mixed text: العربية (Arabic) and עברית (Hebrew) with English</cbc:Note>
|
||||
<cbc:InvoicedQuantity unitCode="C62">10</cbc:InvoicedQuantity>
|
||||
<cbc:LineExtensionAmount currencyID="EUR">1000.00</cbc:LineExtensionAmount>
|
||||
<cac:Item>
|
||||
<cbc:Name>منتج تقني متقدم / מוצר טכנולוגי מתקדם</cbc:Name>
|
||||
</cac:Item>
|
||||
</cac:InvoiceLine>
|
||||
</Invoice>`;
|
||||
|
||||
const einvoice = new EInvoice();
|
||||
await einvoice.loadFromString(rtlInvoice);
|
||||
|
||||
const convertedXml = einvoice.getXmlString();
|
||||
|
||||
// Check RTL text preservation
|
||||
const rtlChecks = {
|
||||
'Arabic company': convertedXml.includes('شركة التقنية المحدودة'),
|
||||
'Arabic street': convertedXml.includes('شارع الملك فهد'),
|
||||
'Arabic city': convertedXml.includes('الرياض'),
|
||||
'Hebrew company': convertedXml.includes('חברת הטכנולוגיה'),
|
||||
'Hebrew street': convertedXml.includes('רחוב דיזנגוף'),
|
||||
'Hebrew city': convertedXml.includes('תל אביב'),
|
||||
'Mixed RTL/LTR': convertedXml.includes('Arabic') && convertedXml.includes('Hebrew'),
|
||||
'Arabic product': convertedXml.includes('منتج تقني متقدم'),
|
||||
'Hebrew product': convertedXml.includes('מוצר טכנולוגי מתקדם')
|
||||
};
|
||||
|
||||
const rtlPreserved = Object.entries(rtlChecks).filter(([_, passed]) => passed).length;
|
||||
console.log(`RTL text preservation: ${rtlPreserved}/${Object.keys(rtlChecks).length}`);
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('rtl-preservation', elapsed);
|
||||
});
|
||||
|
||||
t.test('Corpus encoding preservation analysis', async () => {
|
||||
const startTime = performance.now();
|
||||
let processedCount = 0;
|
||||
let encodingIssues = 0;
|
||||
const characterCategories = {
|
||||
'ASCII only': 0,
|
||||
'Latin extended': 0,
|
||||
'Greek': 0,
|
||||
'Cyrillic': 0,
|
||||
'CJK': 0,
|
||||
'Arabic/Hebrew': 0,
|
||||
'Special symbols': 0,
|
||||
'Emoji': 0
|
||||
};
|
||||
|
||||
const files = await corpusLoader.getAllFiles();
|
||||
const xmlFiles = files.filter(f => f.endsWith('.xml') && !f.includes('.pdf'));
|
||||
|
||||
// Sample corpus for encoding analysis
|
||||
const sampleSize = Math.min(50, xmlFiles.length);
|
||||
const sample = xmlFiles.slice(0, sampleSize);
|
||||
|
||||
for (const file of sample) {
|
||||
try {
|
||||
const content = await corpusLoader.readFile(file);
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
let originalString: string;
|
||||
if (typeof content === 'string') {
|
||||
originalString = content;
|
||||
await einvoice.loadFromString(content);
|
||||
} else {
|
||||
originalString = content.toString('utf8');
|
||||
await einvoice.loadFromBuffer(content);
|
||||
}
|
||||
|
||||
const convertedXml = einvoice.getXmlString();
|
||||
|
||||
// Categorize content
|
||||
if (!/[^\x00-\x7F]/.test(originalString)) {
|
||||
characterCategories['ASCII only']++;
|
||||
} else {
|
||||
if (/[À-ÿĀ-ſ]/.test(originalString)) characterCategories['Latin extended']++;
|
||||
if (/[Α-Ωα-ω]/.test(originalString)) characterCategories['Greek']++;
|
||||
if (/[А-Яа-я]/.test(originalString)) characterCategories['Cyrillic']++;
|
||||
if (/[\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/.test(originalString)) characterCategories['CJK']++;
|
||||
if (/[\u0590-\u05FF\u0600-\u06FF]/.test(originalString)) characterCategories['Arabic/Hebrew']++;
|
||||
if (/[©®™€£¥§¶•°±×÷≤≥≠≈∞]/.test(originalString)) characterCategories['Special symbols']++;
|
||||
if (/[\u{1F300}-\u{1F9FF}]/u.test(originalString)) characterCategories['Emoji']++;
|
||||
}
|
||||
|
||||
// Simple check for major encoding loss
|
||||
const originalNonAscii = (originalString.match(/[^\x00-\x7F]/g) || []).length;
|
||||
const convertedNonAscii = (convertedXml.match(/[^\x00-\x7F]/g) || []).length;
|
||||
|
||||
if (originalNonAscii > 0 && convertedNonAscii < originalNonAscii * 0.8) {
|
||||
encodingIssues++;
|
||||
console.log(`Potential encoding loss in ${file}: ${originalNonAscii} -> ${convertedNonAscii} non-ASCII chars`);
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
} catch (error) {
|
||||
console.log(`Encoding analysis error in ${file}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Corpus encoding analysis (${processedCount} files):`);
|
||||
console.log('Character categories found:');
|
||||
Object.entries(characterCategories)
|
||||
.filter(([_, count]) => count > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.forEach(([category, count]) => {
|
||||
console.log(` ${category}: ${count} files`);
|
||||
});
|
||||
console.log(`Files with potential encoding issues: ${encodingIssues}`);
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
performanceTracker.addMeasurement('corpus-encoding', elapsed);
|
||||
});
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
|
||||
// Performance assertions
|
||||
const avgTime = performanceTracker.getAverageTime();
|
||||
expect(avgTime).toBeLessThan(400); // Encoding operations may take longer
|
||||
});
|
||||
|
||||
tap.start();
|
@ -0,0 +1,335 @@
|
||||
/**
|
||||
* @file test.conv-08.extension-preservation.ts
|
||||
* @description Tests for preserving format-specific extensions during conversion
|
||||
*/
|
||||
|
||||
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('CONV-08: Extension Preservation');
|
||||
|
||||
tap.test('CONV-08: Extension Preservation - should preserve format-specific extensions', async (t) => {
|
||||
// Test 1: Preserve ZUGFeRD profile extensions
|
||||
const zugferdProfile = await performanceTracker.measureAsync(
|
||||
'zugferd-profile-preservation',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Create invoice with ZUGFeRD-specific profile data
|
||||
const zugferdInvoice = {
|
||||
format: 'zugferd' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'ZF-2024-001',
|
||||
issueDate: '2024-01-15',
|
||||
seller: {
|
||||
name: 'Test GmbH',
|
||||
address: 'Test Street 1',
|
||||
country: 'DE',
|
||||
taxId: 'DE123456789'
|
||||
},
|
||||
buyer: {
|
||||
name: 'Customer AG',
|
||||
address: 'Customer Street 2',
|
||||
country: 'DE',
|
||||
taxId: 'DE987654321'
|
||||
},
|
||||
items: [{
|
||||
description: 'Product with ZUGFeRD extensions',
|
||||
quantity: 1,
|
||||
unitPrice: 100.00,
|
||||
vatRate: 19
|
||||
}],
|
||||
// ZUGFeRD-specific extensions
|
||||
extensions: {
|
||||
profile: 'EXTENDED',
|
||||
guidedInvoiceReference: 'GI-2024-001',
|
||||
contractReference: 'CONTRACT-2024',
|
||||
orderReference: 'ORDER-2024-001',
|
||||
additionalReferences: [
|
||||
{ type: 'DeliveryNote', number: 'DN-2024-001' },
|
||||
{ type: 'PurchaseOrder', number: 'PO-2024-001' }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Convert to UBL
|
||||
const converted = await einvoice.convertFormat(zugferdInvoice, 'ubl');
|
||||
|
||||
// Check if extensions are preserved
|
||||
const extensionPreserved = converted.data.extensions &&
|
||||
converted.data.extensions.zugferd &&
|
||||
converted.data.extensions.zugferd.profile === 'EXTENDED';
|
||||
|
||||
return { extensionPreserved, originalExtensions: zugferdInvoice.data.extensions };
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: Preserve PEPPOL customization ID
|
||||
const peppolCustomization = await performanceTracker.measureAsync(
|
||||
'peppol-customization-preservation',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Create PEPPOL invoice with customization
|
||||
const peppolInvoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'PEPPOL-2024-001',
|
||||
issueDate: '2024-01-15',
|
||||
seller: {
|
||||
name: 'Nordic Supplier AS',
|
||||
address: 'Business Street 1',
|
||||
country: 'NO',
|
||||
taxId: 'NO999888777'
|
||||
},
|
||||
buyer: {
|
||||
name: 'Swedish Buyer AB',
|
||||
address: 'Customer Street 2',
|
||||
country: 'SE',
|
||||
taxId: 'SE556677889901'
|
||||
},
|
||||
items: [{
|
||||
description: 'PEPPOL compliant service',
|
||||
quantity: 1,
|
||||
unitPrice: 1000.00,
|
||||
vatRate: 25
|
||||
}],
|
||||
// PEPPOL-specific extensions
|
||||
extensions: {
|
||||
customizationID: 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
profileID: 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
|
||||
endpointID: {
|
||||
scheme: '0088',
|
||||
value: '7300010000001'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Convert to CII
|
||||
const converted = await einvoice.convertFormat(peppolInvoice, 'cii');
|
||||
|
||||
// Check if PEPPOL extensions are preserved
|
||||
const peppolPreserved = converted.data.extensions &&
|
||||
converted.data.extensions.peppol &&
|
||||
converted.data.extensions.peppol.customizationID === peppolInvoice.data.extensions.customizationID;
|
||||
|
||||
return { peppolPreserved, customizationID: peppolInvoice.data.extensions.customizationID };
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: Preserve XRechnung routing information
|
||||
const xrechnungRouting = await performanceTracker.measureAsync(
|
||||
'xrechnung-routing-preservation',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Create XRechnung with routing info
|
||||
const xrechnungInvoice = {
|
||||
format: 'xrechnung' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'XR-2024-001',
|
||||
issueDate: '2024-01-15',
|
||||
seller: {
|
||||
name: 'German Authority',
|
||||
address: 'Government Street 1',
|
||||
country: 'DE',
|
||||
taxId: 'DE123456789'
|
||||
},
|
||||
buyer: {
|
||||
name: 'Public Institution',
|
||||
address: 'Public Street 2',
|
||||
country: 'DE',
|
||||
taxId: 'DE987654321'
|
||||
},
|
||||
items: [{
|
||||
description: 'Public service',
|
||||
quantity: 1,
|
||||
unitPrice: 500.00,
|
||||
vatRate: 19
|
||||
}],
|
||||
// XRechnung-specific extensions
|
||||
extensions: {
|
||||
leitweg: '991-12345-67',
|
||||
buyerReference: 'BR-2024-001',
|
||||
processingCode: '01',
|
||||
specificationIdentifier: 'urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_2.3'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Convert to another format
|
||||
const converted = await einvoice.convertFormat(xrechnungInvoice, 'ubl');
|
||||
|
||||
// Check if XRechnung routing is preserved
|
||||
const routingPreserved = converted.data.extensions &&
|
||||
converted.data.extensions.xrechnung &&
|
||||
converted.data.extensions.xrechnung.leitweg === '991-12345-67';
|
||||
|
||||
return { routingPreserved, leitweg: xrechnungInvoice.data.extensions.leitweg };
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Preserve multiple extensions in round-trip conversion
|
||||
const roundTripExtensions = await performanceTracker.measureAsync(
|
||||
'round-trip-extension-preservation',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Create invoice with multiple extensions
|
||||
const originalInvoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'MULTI-2024-001',
|
||||
issueDate: '2024-01-15',
|
||||
seller: {
|
||||
name: 'Multi-Extension Corp',
|
||||
address: 'Complex Street 1',
|
||||
country: 'FR',
|
||||
taxId: 'FR12345678901'
|
||||
},
|
||||
buyer: {
|
||||
name: 'Extension Handler Ltd',
|
||||
address: 'Handler Street 2',
|
||||
country: 'IT',
|
||||
taxId: 'IT12345678901'
|
||||
},
|
||||
items: [{
|
||||
description: 'Complex product',
|
||||
quantity: 1,
|
||||
unitPrice: 250.00,
|
||||
vatRate: 22
|
||||
}],
|
||||
// Multiple format extensions
|
||||
extensions: {
|
||||
// Business extensions
|
||||
orderReference: 'ORD-2024-001',
|
||||
contractReference: 'CTR-2024-001',
|
||||
projectReference: 'PRJ-2024-001',
|
||||
// Payment extensions
|
||||
paymentTerms: {
|
||||
dueDate: '2024-02-15',
|
||||
discountPercentage: 2,
|
||||
discountDays: 10
|
||||
},
|
||||
// Custom fields
|
||||
customFields: {
|
||||
department: 'IT',
|
||||
costCenter: 'CC-001',
|
||||
approver: 'John Doe',
|
||||
priority: 'HIGH'
|
||||
},
|
||||
// Attachments metadata
|
||||
attachments: [
|
||||
{ name: 'terms.pdf', type: 'application/pdf', size: 102400 },
|
||||
{ name: 'delivery.jpg', type: 'image/jpeg', size: 204800 }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Convert UBL -> CII -> UBL
|
||||
const toCII = await einvoice.convertFormat(originalInvoice, 'cii');
|
||||
const backToUBL = await einvoice.convertFormat(toCII, 'ubl');
|
||||
|
||||
// Check if all extensions survived round-trip
|
||||
const extensionsPreserved = backToUBL.data.extensions &&
|
||||
backToUBL.data.extensions.orderReference === originalInvoice.data.extensions.orderReference &&
|
||||
backToUBL.data.extensions.customFields &&
|
||||
backToUBL.data.extensions.customFields.department === 'IT' &&
|
||||
backToUBL.data.extensions.attachments &&
|
||||
backToUBL.data.extensions.attachments.length === 2;
|
||||
|
||||
return {
|
||||
extensionsPreserved,
|
||||
originalCount: Object.keys(originalInvoice.data.extensions).length,
|
||||
preservedCount: backToUBL.data.extensions ? Object.keys(backToUBL.data.extensions).length : 0
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Corpus validation - check extension preservation in real files
|
||||
const corpusExtensions = await performanceTracker.measureAsync(
|
||||
'corpus-extension-analysis',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const extensionStats = {
|
||||
totalFiles: 0,
|
||||
filesWithExtensions: 0,
|
||||
extensionTypes: new Set<string>(),
|
||||
conversionTests: 0,
|
||||
preservationSuccess: 0
|
||||
};
|
||||
|
||||
// Sample up to 20 files for conversion testing
|
||||
const sampleFiles = files.slice(0, 20);
|
||||
|
||||
for (const file of sampleFiles) {
|
||||
try {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Detect format
|
||||
const format = await einvoice.detectFormat(content);
|
||||
if (!format || format === 'unknown') continue;
|
||||
|
||||
extensionStats.totalFiles++;
|
||||
|
||||
// Parse to check for extensions
|
||||
const parsed = await einvoice.parseInvoice(content, format);
|
||||
if (parsed.data.extensions && Object.keys(parsed.data.extensions).length > 0) {
|
||||
extensionStats.filesWithExtensions++;
|
||||
Object.keys(parsed.data.extensions).forEach(ext => extensionStats.extensionTypes.add(ext));
|
||||
|
||||
// Try conversion to test preservation
|
||||
const targetFormat = format === 'ubl' ? 'cii' : 'ubl';
|
||||
try {
|
||||
const converted = await einvoice.convertFormat(parsed, targetFormat);
|
||||
extensionStats.conversionTests++;
|
||||
|
||||
if (converted.data.extensions && Object.keys(converted.data.extensions).length > 0) {
|
||||
extensionStats.preservationSuccess++;
|
||||
}
|
||||
} catch (convError) {
|
||||
// Conversion not supported, skip
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// File parsing error, skip
|
||||
}
|
||||
}
|
||||
|
||||
return extensionStats;
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== CONV-08: Extension Preservation Test Summary ===');
|
||||
t.comment(`ZUGFeRD Profile Extensions: ${zugferdProfile.result.extensionPreserved ? 'PRESERVED' : 'LOST'}`);
|
||||
t.comment(`PEPPOL Customization ID: ${peppolCustomization.result.peppolPreserved ? 'PRESERVED' : 'LOST'}`);
|
||||
t.comment(`XRechnung Routing Info: ${xrechnungRouting.result.routingPreserved ? 'PRESERVED' : 'LOST'}`);
|
||||
t.comment(`Round-trip Extensions: ${roundTripExtensions.result.originalCount} original, ${roundTripExtensions.result.preservedCount} preserved`);
|
||||
t.comment('\nCorpus Analysis:');
|
||||
t.comment(`- Files analyzed: ${corpusExtensions.result.totalFiles}`);
|
||||
t.comment(`- Files with extensions: ${corpusExtensions.result.filesWithExtensions}`);
|
||||
t.comment(`- Extension types found: ${Array.from(corpusExtensions.result.extensionTypes).join(', ')}`);
|
||||
t.comment(`- Conversion tests: ${corpusExtensions.result.conversionTests}`);
|
||||
t.comment(`- Successful preservation: ${corpusExtensions.result.preservationSuccess}`);
|
||||
|
||||
// Performance summary
|
||||
t.comment('\n=== Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
429
test/suite/einvoice_conversion/test.conv-09.round-trip.ts
Normal file
429
test/suite/einvoice_conversion/test.conv-09.round-trip.ts
Normal file
@ -0,0 +1,429 @@
|
||||
/**
|
||||
* @file test.conv-09.round-trip.ts
|
||||
* @description Tests for round-trip conversion integrity between formats
|
||||
*/
|
||||
|
||||
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('CONV-09: Round-Trip Conversion');
|
||||
|
||||
tap.test('CONV-09: Round-Trip Conversion - should maintain data integrity through round-trip conversions', async (t) => {
|
||||
// Test 1: UBL -> CII -> UBL round-trip
|
||||
const ublRoundTrip = await performanceTracker.measureAsync(
|
||||
'ubl-cii-ubl-round-trip',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Create comprehensive UBL invoice
|
||||
const originalUBL = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'UBL-RT-2024-001',
|
||||
issueDate: '2024-01-20',
|
||||
dueDate: '2024-02-20',
|
||||
currency: 'EUR',
|
||||
seller: {
|
||||
name: 'UBL Test Seller GmbH',
|
||||
address: 'Seller Street 123',
|
||||
city: 'Berlin',
|
||||
postalCode: '10115',
|
||||
country: 'DE',
|
||||
taxId: 'DE123456789',
|
||||
email: 'seller@example.com',
|
||||
phone: '+49 30 12345678'
|
||||
},
|
||||
buyer: {
|
||||
name: 'UBL Test Buyer Ltd',
|
||||
address: 'Buyer Avenue 456',
|
||||
city: 'Munich',
|
||||
postalCode: '80331',
|
||||
country: 'DE',
|
||||
taxId: 'DE987654321',
|
||||
email: 'buyer@example.com'
|
||||
},
|
||||
items: [
|
||||
{
|
||||
description: 'Professional Services',
|
||||
quantity: 10,
|
||||
unitPrice: 150.00,
|
||||
vatRate: 19,
|
||||
lineTotal: 1500.00,
|
||||
itemId: 'SRV-001'
|
||||
},
|
||||
{
|
||||
description: 'Software License',
|
||||
quantity: 5,
|
||||
unitPrice: 200.00,
|
||||
vatRate: 19,
|
||||
lineTotal: 1000.00,
|
||||
itemId: 'LIC-001'
|
||||
}
|
||||
],
|
||||
totals: {
|
||||
netAmount: 2500.00,
|
||||
vatAmount: 475.00,
|
||||
grossAmount: 2975.00
|
||||
},
|
||||
paymentTerms: 'Net 30 days',
|
||||
notes: 'Thank you for your business!'
|
||||
}
|
||||
};
|
||||
|
||||
// Convert UBL -> CII
|
||||
const convertedToCII = await einvoice.convertFormat(originalUBL, 'cii');
|
||||
|
||||
// Convert CII -> UBL
|
||||
const backToUBL = await einvoice.convertFormat(convertedToCII, 'ubl');
|
||||
|
||||
// Compare key fields
|
||||
const comparison = {
|
||||
invoiceNumber: originalUBL.data.invoiceNumber === backToUBL.data.invoiceNumber,
|
||||
issueDate: originalUBL.data.issueDate === backToUBL.data.issueDate,
|
||||
sellerName: originalUBL.data.seller.name === backToUBL.data.seller.name,
|
||||
sellerTaxId: originalUBL.data.seller.taxId === backToUBL.data.seller.taxId,
|
||||
buyerName: originalUBL.data.buyer.name === backToUBL.data.buyer.name,
|
||||
itemCount: originalUBL.data.items.length === backToUBL.data.items.length,
|
||||
totalAmount: originalUBL.data.totals.grossAmount === backToUBL.data.totals.grossAmount,
|
||||
allFieldsMatch: JSON.stringify(originalUBL.data) === JSON.stringify(backToUBL.data)
|
||||
};
|
||||
|
||||
return { comparison, dataDifferences: !comparison.allFieldsMatch };
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: CII -> UBL -> CII round-trip
|
||||
const ciiRoundTrip = await performanceTracker.measureAsync(
|
||||
'cii-ubl-cii-round-trip',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Create CII invoice
|
||||
const originalCII = {
|
||||
format: 'cii' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'CII-RT-2024-001',
|
||||
issueDate: '2024-01-21',
|
||||
dueDate: '2024-02-21',
|
||||
currency: 'USD',
|
||||
seller: {
|
||||
name: 'CII Corporation',
|
||||
address: '100 Tech Park',
|
||||
city: 'San Francisco',
|
||||
postalCode: '94105',
|
||||
country: 'US',
|
||||
taxId: 'US12-3456789',
|
||||
registrationNumber: 'REG-12345'
|
||||
},
|
||||
buyer: {
|
||||
name: 'CII Customer Inc',
|
||||
address: '200 Business Center',
|
||||
city: 'New York',
|
||||
postalCode: '10001',
|
||||
country: 'US',
|
||||
taxId: 'US98-7654321'
|
||||
},
|
||||
items: [
|
||||
{
|
||||
description: 'Cloud Storage Service',
|
||||
quantity: 100,
|
||||
unitPrice: 9.99,
|
||||
vatRate: 8.875,
|
||||
lineTotal: 999.00
|
||||
}
|
||||
],
|
||||
totals: {
|
||||
netAmount: 999.00,
|
||||
vatAmount: 88.67,
|
||||
grossAmount: 1087.67
|
||||
},
|
||||
paymentReference: 'PAY-2024-001'
|
||||
}
|
||||
};
|
||||
|
||||
// Convert CII -> UBL
|
||||
const convertedToUBL = await einvoice.convertFormat(originalCII, 'ubl');
|
||||
|
||||
// Convert UBL -> CII
|
||||
const backToCII = await einvoice.convertFormat(convertedToUBL, 'cii');
|
||||
|
||||
// Compare essential fields
|
||||
const fieldsMatch = {
|
||||
invoiceNumber: originalCII.data.invoiceNumber === backToCII.data.invoiceNumber,
|
||||
currency: originalCII.data.currency === backToCII.data.currency,
|
||||
sellerCountry: originalCII.data.seller.country === backToCII.data.seller.country,
|
||||
vatAmount: Math.abs(originalCII.data.totals.vatAmount - backToCII.data.totals.vatAmount) < 0.01,
|
||||
grossAmount: Math.abs(originalCII.data.totals.grossAmount - backToCII.data.totals.grossAmount) < 0.01
|
||||
};
|
||||
|
||||
return { fieldsMatch, originalFormat: 'cii' };
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: Complex multi-format round-trip with ZUGFeRD
|
||||
const zugferdRoundTrip = await performanceTracker.measureAsync(
|
||||
'zugferd-multi-format-round-trip',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Create ZUGFeRD invoice
|
||||
const originalZugferd = {
|
||||
format: 'zugferd' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'ZF-RT-2024-001',
|
||||
issueDate: '2024-01-22',
|
||||
seller: {
|
||||
name: 'ZUGFeRD Handel GmbH',
|
||||
address: 'Handelsweg 10',
|
||||
city: 'Frankfurt',
|
||||
postalCode: '60311',
|
||||
country: 'DE',
|
||||
taxId: 'DE111222333',
|
||||
bankAccount: {
|
||||
iban: 'DE89370400440532013000',
|
||||
bic: 'COBADEFFXXX'
|
||||
}
|
||||
},
|
||||
buyer: {
|
||||
name: 'ZUGFeRD Käufer AG',
|
||||
address: 'Käuferstraße 20',
|
||||
city: 'Hamburg',
|
||||
postalCode: '20095',
|
||||
country: 'DE',
|
||||
taxId: 'DE444555666'
|
||||
},
|
||||
items: [
|
||||
{
|
||||
description: 'Büromaterial Set',
|
||||
quantity: 50,
|
||||
unitPrice: 24.99,
|
||||
vatRate: 19,
|
||||
lineTotal: 1249.50,
|
||||
articleNumber: 'BM-2024'
|
||||
},
|
||||
{
|
||||
description: 'Versandkosten',
|
||||
quantity: 1,
|
||||
unitPrice: 9.90,
|
||||
vatRate: 19,
|
||||
lineTotal: 9.90
|
||||
}
|
||||
],
|
||||
totals: {
|
||||
netAmount: 1259.40,
|
||||
vatAmount: 239.29,
|
||||
grossAmount: 1498.69
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Convert ZUGFeRD -> XRechnung -> UBL -> CII -> ZUGFeRD
|
||||
const toXRechnung = await einvoice.convertFormat(originalZugferd, 'xrechnung');
|
||||
const toUBL = await einvoice.convertFormat(toXRechnung, 'ubl');
|
||||
const toCII = await einvoice.convertFormat(toUBL, 'cii');
|
||||
const backToZugferd = await einvoice.convertFormat(toCII, 'zugferd');
|
||||
|
||||
// Check critical business data preservation
|
||||
const dataIntegrity = {
|
||||
invoiceNumber: originalZugferd.data.invoiceNumber === backToZugferd.data.invoiceNumber,
|
||||
sellerTaxId: originalZugferd.data.seller.taxId === backToZugferd.data.seller.taxId,
|
||||
buyerTaxId: originalZugferd.data.buyer.taxId === backToZugferd.data.buyer.taxId,
|
||||
itemCount: originalZugferd.data.items.length === backToZugferd.data.items.length,
|
||||
totalPreserved: Math.abs(originalZugferd.data.totals.grossAmount - backToZugferd.data.totals.grossAmount) < 0.01,
|
||||
bankAccountPreserved: backToZugferd.data.seller.bankAccount &&
|
||||
originalZugferd.data.seller.bankAccount.iban === backToZugferd.data.seller.bankAccount.iban
|
||||
};
|
||||
|
||||
return {
|
||||
dataIntegrity,
|
||||
conversionChain: 'ZUGFeRD -> XRechnung -> UBL -> CII -> ZUGFeRD',
|
||||
stepsCompleted: 4
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Round-trip with data validation at each step
|
||||
const validatedRoundTrip = await performanceTracker.measureAsync(
|
||||
'validated-round-trip',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const validationResults = [];
|
||||
|
||||
// Start with UBL invoice
|
||||
const startInvoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'VAL-RT-2024-001',
|
||||
issueDate: '2024-01-23',
|
||||
seller: {
|
||||
name: 'Validation Test Seller',
|
||||
address: 'Test Street 1',
|
||||
country: 'AT',
|
||||
taxId: 'ATU12345678'
|
||||
},
|
||||
buyer: {
|
||||
name: 'Validation Test Buyer',
|
||||
address: 'Test Street 2',
|
||||
country: 'AT',
|
||||
taxId: 'ATU87654321'
|
||||
},
|
||||
items: [{
|
||||
description: 'Test Service',
|
||||
quantity: 1,
|
||||
unitPrice: 1000.00,
|
||||
vatRate: 20,
|
||||
lineTotal: 1000.00
|
||||
}],
|
||||
totals: {
|
||||
netAmount: 1000.00,
|
||||
vatAmount: 200.00,
|
||||
grossAmount: 1200.00
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Validate original
|
||||
const originalValid = await einvoice.validateInvoice(startInvoice);
|
||||
validationResults.push({ step: 'original', valid: originalValid.isValid });
|
||||
|
||||
// Convert and validate at each step
|
||||
const formats = ['cii', 'xrechnung', 'zugferd', 'ubl'];
|
||||
let currentInvoice = startInvoice;
|
||||
|
||||
for (const targetFormat of formats) {
|
||||
try {
|
||||
currentInvoice = await einvoice.convertFormat(currentInvoice, targetFormat);
|
||||
const validation = await einvoice.validateInvoice(currentInvoice);
|
||||
validationResults.push({
|
||||
step: `converted-to-${targetFormat}`,
|
||||
valid: validation.isValid,
|
||||
errors: validation.errors?.length || 0
|
||||
});
|
||||
} catch (error) {
|
||||
validationResults.push({
|
||||
step: `converted-to-${targetFormat}`,
|
||||
valid: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we made it back to original format with valid data
|
||||
const fullCircle = currentInvoice.format === startInvoice.format;
|
||||
const dataPreserved = currentInvoice.data.invoiceNumber === startInvoice.data.invoiceNumber &&
|
||||
currentInvoice.data.totals.grossAmount === startInvoice.data.totals.grossAmount;
|
||||
|
||||
return { validationResults, fullCircle, dataPreserved };
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Corpus round-trip testing
|
||||
const corpusRoundTrip = await performanceTracker.measureAsync(
|
||||
'corpus-round-trip-analysis',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const roundTripStats = {
|
||||
tested: 0,
|
||||
successful: 0,
|
||||
dataLoss: 0,
|
||||
conversionFailed: 0,
|
||||
formatCombinations: new Map<string, number>()
|
||||
};
|
||||
|
||||
// Test a sample of files
|
||||
const sampleFiles = files.slice(0, 15);
|
||||
|
||||
for (const file of sampleFiles) {
|
||||
try {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Detect and parse original
|
||||
const format = await einvoice.detectFormat(content);
|
||||
if (!format || format === 'unknown') continue;
|
||||
|
||||
const original = await einvoice.parseInvoice(content, format);
|
||||
roundTripStats.tested++;
|
||||
|
||||
// Determine target format for round-trip
|
||||
const targetFormat = format === 'ubl' ? 'cii' : 'ubl';
|
||||
const key = `${format}->${targetFormat}->${format}`;
|
||||
|
||||
try {
|
||||
// Perform round-trip
|
||||
const converted = await einvoice.convertFormat(original, targetFormat);
|
||||
const backToOriginal = await einvoice.convertFormat(converted, format);
|
||||
|
||||
// Check data preservation
|
||||
const criticalFieldsMatch =
|
||||
original.data.invoiceNumber === backToOriginal.data.invoiceNumber &&
|
||||
original.data.seller?.taxId === backToOriginal.data.seller?.taxId &&
|
||||
Math.abs((original.data.totals?.grossAmount || 0) - (backToOriginal.data.totals?.grossAmount || 0)) < 0.01;
|
||||
|
||||
if (criticalFieldsMatch) {
|
||||
roundTripStats.successful++;
|
||||
} else {
|
||||
roundTripStats.dataLoss++;
|
||||
}
|
||||
|
||||
// Track format combination
|
||||
roundTripStats.formatCombinations.set(key,
|
||||
(roundTripStats.formatCombinations.get(key) || 0) + 1
|
||||
);
|
||||
|
||||
} catch (convError) {
|
||||
roundTripStats.conversionFailed++;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Skip files that can't be parsed
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...roundTripStats,
|
||||
successRate: roundTripStats.tested > 0 ?
|
||||
(roundTripStats.successful / roundTripStats.tested * 100).toFixed(2) + '%' : 'N/A',
|
||||
formatCombinations: Array.from(roundTripStats.formatCombinations.entries())
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== CONV-09: Round-Trip Conversion Test Summary ===');
|
||||
t.comment(`UBL -> CII -> UBL: ${ublRoundTrip.result.comparison.allFieldsMatch ? 'PERFECT MATCH' : 'DATA DIFFERENCES DETECTED'}`);
|
||||
t.comment(`CII -> UBL -> CII: ${Object.values(ciiRoundTrip.result.fieldsMatch).every(v => v) ? 'ALL FIELDS MATCH' : 'SOME FIELDS DIFFER'}`);
|
||||
t.comment(`Multi-format chain (${zugferdRoundTrip.result.conversionChain}): ${
|
||||
Object.values(zugferdRoundTrip.result.dataIntegrity).filter(v => v).length
|
||||
}/${Object.keys(zugferdRoundTrip.result.dataIntegrity).length} checks passed`);
|
||||
t.comment(`\nValidated Round-trip Results:`);
|
||||
validatedRoundTrip.result.validationResults.forEach(r => {
|
||||
t.comment(` - ${r.step}: ${r.valid ? 'VALID' : 'INVALID'} ${r.errors ? `(${r.errors} errors)` : ''}`);
|
||||
});
|
||||
t.comment(`\nCorpus Round-trip Analysis:`);
|
||||
t.comment(` - Files tested: ${corpusRoundTrip.result.tested}`);
|
||||
t.comment(` - Successful round-trips: ${corpusRoundTrip.result.successful}`);
|
||||
t.comment(` - Data loss detected: ${corpusRoundTrip.result.dataLoss}`);
|
||||
t.comment(` - Conversion failures: ${corpusRoundTrip.result.conversionFailed}`);
|
||||
t.comment(` - Success rate: ${corpusRoundTrip.result.successRate}`);
|
||||
t.comment(` - Format combinations tested:`);
|
||||
corpusRoundTrip.result.formatCombinations.forEach(([combo, count]) => {
|
||||
t.comment(` * ${combo}: ${count} files`);
|
||||
});
|
||||
|
||||
// Performance summary
|
||||
t.comment('\n=== Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
473
test/suite/einvoice_conversion/test.conv-10.batch-conversion.ts
Normal file
473
test/suite/einvoice_conversion/test.conv-10.batch-conversion.ts
Normal file
@ -0,0 +1,473 @@
|
||||
/**
|
||||
* @file test.conv-10.batch-conversion.ts
|
||||
* @description Tests for batch conversion operations and performance
|
||||
*/
|
||||
|
||||
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('CONV-10: Batch Conversion');
|
||||
|
||||
tap.test('CONV-10: Batch Conversion - should efficiently handle batch conversion operations', async (t) => {
|
||||
// Test 1: Sequential batch conversion
|
||||
const sequentialBatch = await performanceTracker.measureAsync(
|
||||
'sequential-batch-conversion',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const batchSize = 10;
|
||||
const results = {
|
||||
processed: 0,
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
totalTime: 0,
|
||||
averageTime: 0
|
||||
};
|
||||
|
||||
// Create test invoices
|
||||
const invoices = Array.from({ length: batchSize }, (_, i) => ({
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `BATCH-SEQ-2024-${String(i + 1).padStart(3, '0')}`,
|
||||
issueDate: '2024-01-25',
|
||||
seller: {
|
||||
name: `Seller Company ${i + 1}`,
|
||||
address: `Address ${i + 1}`,
|
||||
country: 'DE',
|
||||
taxId: `DE${String(123456789 + i).padStart(9, '0')}`
|
||||
},
|
||||
buyer: {
|
||||
name: `Buyer Company ${i + 1}`,
|
||||
address: `Buyer Address ${i + 1}`,
|
||||
country: 'DE',
|
||||
taxId: `DE${String(987654321 - i).padStart(9, '0')}`
|
||||
},
|
||||
items: [{
|
||||
description: `Product ${i + 1}`,
|
||||
quantity: i + 1,
|
||||
unitPrice: 100.00 + (i * 10),
|
||||
vatRate: 19,
|
||||
lineTotal: (i + 1) * (100.00 + (i * 10))
|
||||
}],
|
||||
totals: {
|
||||
netAmount: (i + 1) * (100.00 + (i * 10)),
|
||||
vatAmount: (i + 1) * (100.00 + (i * 10)) * 0.19,
|
||||
grossAmount: (i + 1) * (100.00 + (i * 10)) * 1.19
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Process sequentially
|
||||
const startTime = Date.now();
|
||||
|
||||
for (const invoice of invoices) {
|
||||
results.processed++;
|
||||
try {
|
||||
const converted = await einvoice.convertFormat(invoice, 'cii');
|
||||
if (converted) {
|
||||
results.successful++;
|
||||
}
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
results.totalTime = Date.now() - startTime;
|
||||
results.averageTime = results.totalTime / results.processed;
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: Parallel batch conversion
|
||||
const parallelBatch = await performanceTracker.measureAsync(
|
||||
'parallel-batch-conversion',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const batchSize = 10;
|
||||
const results = {
|
||||
processed: 0,
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
totalTime: 0,
|
||||
averageTime: 0
|
||||
};
|
||||
|
||||
// Create test invoices
|
||||
const invoices = Array.from({ length: batchSize }, (_, i) => ({
|
||||
format: 'cii' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `BATCH-PAR-2024-${String(i + 1).padStart(3, '0')}`,
|
||||
issueDate: '2024-01-25',
|
||||
seller: {
|
||||
name: `Parallel Seller ${i + 1}`,
|
||||
address: `Parallel Address ${i + 1}`,
|
||||
country: 'FR',
|
||||
taxId: `FR${String(12345678901 + i).padStart(11, '0')}`
|
||||
},
|
||||
buyer: {
|
||||
name: `Parallel Buyer ${i + 1}`,
|
||||
address: `Parallel Buyer Address ${i + 1}`,
|
||||
country: 'FR',
|
||||
taxId: `FR${String(98765432109 - i).padStart(11, '0')}`
|
||||
},
|
||||
items: [{
|
||||
description: `Service ${i + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 500.00 + (i * 50),
|
||||
vatRate: 20,
|
||||
lineTotal: 500.00 + (i * 50)
|
||||
}],
|
||||
totals: {
|
||||
netAmount: 500.00 + (i * 50),
|
||||
vatAmount: (500.00 + (i * 50)) * 0.20,
|
||||
grossAmount: (500.00 + (i * 50)) * 1.20
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Process in parallel
|
||||
const startTime = Date.now();
|
||||
|
||||
const conversionPromises = invoices.map(async (invoice) => {
|
||||
try {
|
||||
const converted = await einvoice.convertFormat(invoice, 'ubl');
|
||||
return { success: true, converted };
|
||||
} catch (error) {
|
||||
return { success: false, error };
|
||||
}
|
||||
});
|
||||
|
||||
const conversionResults = await Promise.all(conversionPromises);
|
||||
|
||||
results.processed = conversionResults.length;
|
||||
results.successful = conversionResults.filter(r => r.success).length;
|
||||
results.failed = conversionResults.filter(r => !r.success).length;
|
||||
results.totalTime = Date.now() - startTime;
|
||||
results.averageTime = results.totalTime / results.processed;
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: Mixed format batch conversion
|
||||
const mixedFormatBatch = await performanceTracker.measureAsync(
|
||||
'mixed-format-batch-conversion',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const formats = ['ubl', 'cii', 'zugferd', 'xrechnung'] as const;
|
||||
const results = {
|
||||
byFormat: new Map<string, { processed: number; successful: number; failed: number }>(),
|
||||
totalProcessed: 0,
|
||||
totalSuccessful: 0,
|
||||
conversionMatrix: new Map<string, number>()
|
||||
};
|
||||
|
||||
// Create mixed format invoices
|
||||
const mixedInvoices = formats.flatMap((format, formatIndex) =>
|
||||
Array.from({ length: 3 }, (_, i) => ({
|
||||
format,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `MIXED-${format.toUpperCase()}-${i + 1}`,
|
||||
issueDate: '2024-01-26',
|
||||
seller: {
|
||||
name: `${format.toUpperCase()} Seller ${i + 1}`,
|
||||
address: 'Mixed Street 1',
|
||||
country: 'DE',
|
||||
taxId: `DE${String(111111111 + formatIndex * 10 + i).padStart(9, '0')}`
|
||||
},
|
||||
buyer: {
|
||||
name: `${format.toUpperCase()} Buyer ${i + 1}`,
|
||||
address: 'Mixed Avenue 2',
|
||||
country: 'DE',
|
||||
taxId: `DE${String(999999999 - formatIndex * 10 - i).padStart(9, '0')}`
|
||||
},
|
||||
items: [{
|
||||
description: `${format} Product`,
|
||||
quantity: 1,
|
||||
unitPrice: 250.00,
|
||||
vatRate: 19,
|
||||
lineTotal: 250.00
|
||||
}],
|
||||
totals: {
|
||||
netAmount: 250.00,
|
||||
vatAmount: 47.50,
|
||||
grossAmount: 297.50
|
||||
}
|
||||
}
|
||||
}))
|
||||
);
|
||||
|
||||
// Process with different target formats
|
||||
const targetFormats = ['ubl', 'cii'] as const;
|
||||
|
||||
for (const invoice of mixedInvoices) {
|
||||
const sourceFormat = invoice.format;
|
||||
|
||||
if (!results.byFormat.has(sourceFormat)) {
|
||||
results.byFormat.set(sourceFormat, { processed: 0, successful: 0, failed: 0 });
|
||||
}
|
||||
|
||||
const formatStats = results.byFormat.get(sourceFormat)!;
|
||||
|
||||
for (const targetFormat of targetFormats) {
|
||||
if (sourceFormat === targetFormat) continue;
|
||||
|
||||
const conversionKey = `${sourceFormat}->${targetFormat}`;
|
||||
formatStats.processed++;
|
||||
results.totalProcessed++;
|
||||
|
||||
try {
|
||||
const converted = await einvoice.convertFormat(invoice, targetFormat);
|
||||
if (converted) {
|
||||
formatStats.successful++;
|
||||
results.totalSuccessful++;
|
||||
results.conversionMatrix.set(conversionKey,
|
||||
(results.conversionMatrix.get(conversionKey) || 0) + 1
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
formatStats.failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
formatStats: Array.from(results.byFormat.entries()),
|
||||
totalProcessed: results.totalProcessed,
|
||||
totalSuccessful: results.totalSuccessful,
|
||||
conversionMatrix: Array.from(results.conversionMatrix.entries()),
|
||||
successRate: (results.totalSuccessful / results.totalProcessed * 100).toFixed(2) + '%'
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Large batch with memory monitoring
|
||||
const largeBatchMemory = await performanceTracker.measureAsync(
|
||||
'large-batch-memory-monitoring',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const batchSize = 50;
|
||||
const memorySnapshots = [];
|
||||
|
||||
// Capture initial memory
|
||||
if (global.gc) global.gc();
|
||||
const initialMemory = process.memoryUsage();
|
||||
|
||||
// Create large batch
|
||||
const largeBatch = Array.from({ length: batchSize }, (_, i) => ({
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `LARGE-BATCH-${String(i + 1).padStart(4, '0')}`,
|
||||
issueDate: '2024-01-27',
|
||||
seller: {
|
||||
name: `Large Batch Seller ${i + 1}`,
|
||||
address: `Street ${i + 1}, Building ${i % 10 + 1}`,
|
||||
city: 'Berlin',
|
||||
postalCode: `${10000 + i}`,
|
||||
country: 'DE',
|
||||
taxId: `DE${String(100000000 + i).padStart(9, '0')}`
|
||||
},
|
||||
buyer: {
|
||||
name: `Large Batch Buyer ${i + 1}`,
|
||||
address: `Avenue ${i + 1}, Suite ${i % 20 + 1}`,
|
||||
city: 'Munich',
|
||||
postalCode: `${80000 + i}`,
|
||||
country: 'DE',
|
||||
taxId: `DE${String(200000000 + i).padStart(9, '0')}`
|
||||
},
|
||||
items: Array.from({ length: 5 }, (_, j) => ({
|
||||
description: `Product ${i + 1}-${j + 1} with detailed description`,
|
||||
quantity: j + 1,
|
||||
unitPrice: 50.00 + j * 10,
|
||||
vatRate: 19,
|
||||
lineTotal: (j + 1) * (50.00 + j * 10)
|
||||
})),
|
||||
totals: {
|
||||
netAmount: Array.from({ length: 5 }, (_, j) => (j + 1) * (50.00 + j * 10)).reduce((a, b) => a + b, 0),
|
||||
vatAmount: Array.from({ length: 5 }, (_, j) => (j + 1) * (50.00 + j * 10)).reduce((a, b) => a + b, 0) * 0.19,
|
||||
grossAmount: Array.from({ length: 5 }, (_, j) => (j + 1) * (50.00 + j * 10)).reduce((a, b) => a + b, 0) * 1.19
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Process in chunks and monitor memory
|
||||
const chunkSize = 10;
|
||||
let processed = 0;
|
||||
let successful = 0;
|
||||
|
||||
for (let i = 0; i < largeBatch.length; i += chunkSize) {
|
||||
const chunk = largeBatch.slice(i, i + chunkSize);
|
||||
|
||||
// Process chunk
|
||||
const chunkResults = await Promise.all(
|
||||
chunk.map(async (invoice) => {
|
||||
try {
|
||||
await einvoice.convertFormat(invoice, 'cii');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
processed += chunk.length;
|
||||
successful += chunkResults.filter(r => r).length;
|
||||
|
||||
// Capture memory snapshot
|
||||
const currentMemory = process.memoryUsage();
|
||||
memorySnapshots.push({
|
||||
processed,
|
||||
heapUsed: Math.round((currentMemory.heapUsed - initialMemory.heapUsed) / 1024 / 1024 * 100) / 100,
|
||||
external: Math.round((currentMemory.external - initialMemory.external) / 1024 / 1024 * 100) / 100
|
||||
});
|
||||
}
|
||||
|
||||
// Force garbage collection if available
|
||||
if (global.gc) global.gc();
|
||||
const finalMemory = process.memoryUsage();
|
||||
|
||||
return {
|
||||
processed,
|
||||
successful,
|
||||
successRate: (successful / processed * 100).toFixed(2) + '%',
|
||||
memoryIncrease: {
|
||||
heapUsed: Math.round((finalMemory.heapUsed - initialMemory.heapUsed) / 1024 / 1024 * 100) / 100,
|
||||
external: Math.round((finalMemory.external - initialMemory.external) / 1024 / 1024 * 100) / 100
|
||||
},
|
||||
memorySnapshots,
|
||||
averageMemoryPerInvoice: Math.round((finalMemory.heapUsed - initialMemory.heapUsed) / processed / 1024 * 100) / 100
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Corpus batch conversion
|
||||
const corpusBatch = await performanceTracker.measureAsync(
|
||||
'corpus-batch-conversion',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const batchStats = {
|
||||
totalFiles: 0,
|
||||
processed: 0,
|
||||
converted: 0,
|
||||
failedParsing: 0,
|
||||
failedConversion: 0,
|
||||
formatDistribution: new Map<string, number>(),
|
||||
processingTimes: [] as number[],
|
||||
formats: new Set<string>()
|
||||
};
|
||||
|
||||
// Process a batch of corpus files
|
||||
const batchFiles = files.slice(0, 25);
|
||||
batchStats.totalFiles = batchFiles.length;
|
||||
|
||||
// Process files in parallel batches
|
||||
const batchSize = 5;
|
||||
for (let i = 0; i < batchFiles.length; i += batchSize) {
|
||||
const batch = batchFiles.slice(i, i + batchSize);
|
||||
|
||||
await Promise.all(batch.map(async (file) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
|
||||
// Detect format
|
||||
const format = await einvoice.detectFormat(content);
|
||||
if (!format || format === 'unknown') {
|
||||
batchStats.failedParsing++;
|
||||
return;
|
||||
}
|
||||
|
||||
batchStats.formats.add(format);
|
||||
batchStats.formatDistribution.set(format,
|
||||
(batchStats.formatDistribution.get(format) || 0) + 1
|
||||
);
|
||||
|
||||
// Parse invoice
|
||||
const invoice = await einvoice.parseInvoice(content, format);
|
||||
batchStats.processed++;
|
||||
|
||||
// Try conversion to different format
|
||||
const targetFormat = format === 'ubl' ? 'cii' : 'ubl';
|
||||
try {
|
||||
await einvoice.convertFormat(invoice, targetFormat);
|
||||
batchStats.converted++;
|
||||
} catch (convError) {
|
||||
batchStats.failedConversion++;
|
||||
}
|
||||
|
||||
batchStats.processingTimes.push(Date.now() - startTime);
|
||||
|
||||
} catch (error) {
|
||||
batchStats.failedParsing++;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const avgProcessingTime = batchStats.processingTimes.length > 0 ?
|
||||
batchStats.processingTimes.reduce((a, b) => a + b, 0) / batchStats.processingTimes.length : 0;
|
||||
|
||||
return {
|
||||
...batchStats,
|
||||
formatDistribution: Array.from(batchStats.formatDistribution.entries()),
|
||||
formats: Array.from(batchStats.formats),
|
||||
averageProcessingTime: Math.round(avgProcessingTime),
|
||||
conversionSuccessRate: batchStats.processed > 0 ?
|
||||
(batchStats.converted / batchStats.processed * 100).toFixed(2) + '%' : 'N/A'
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== CONV-10: Batch Conversion Test Summary ===');
|
||||
t.comment(`\nSequential Batch (${sequentialBatch.result.processed} invoices):`);
|
||||
t.comment(` - Successful: ${sequentialBatch.result.successful}`);
|
||||
t.comment(` - Failed: ${sequentialBatch.result.failed}`);
|
||||
t.comment(` - Total time: ${sequentialBatch.result.totalTime}ms`);
|
||||
t.comment(` - Average time per invoice: ${sequentialBatch.result.averageTime.toFixed(2)}ms`);
|
||||
|
||||
t.comment(`\nParallel Batch (${parallelBatch.result.processed} invoices):`);
|
||||
t.comment(` - Successful: ${parallelBatch.result.successful}`);
|
||||
t.comment(` - Failed: ${parallelBatch.result.failed}`);
|
||||
t.comment(` - Total time: ${parallelBatch.result.totalTime}ms`);
|
||||
t.comment(` - Average time per invoice: ${parallelBatch.result.averageTime.toFixed(2)}ms`);
|
||||
t.comment(` - Speedup vs sequential: ${(sequentialBatch.result.totalTime / parallelBatch.result.totalTime).toFixed(2)}x`);
|
||||
|
||||
t.comment(`\nMixed Format Batch:`);
|
||||
t.comment(` - Total conversions: ${mixedFormatBatch.result.totalProcessed}`);
|
||||
t.comment(` - Success rate: ${mixedFormatBatch.result.successRate}`);
|
||||
t.comment(` - Format statistics:`);
|
||||
mixedFormatBatch.result.formatStats.forEach(([format, stats]) => {
|
||||
t.comment(` * ${format}: ${stats.successful}/${stats.processed} successful`);
|
||||
});
|
||||
|
||||
t.comment(`\nLarge Batch Memory Analysis (${largeBatchMemory.result.processed} invoices):`);
|
||||
t.comment(` - Success rate: ${largeBatchMemory.result.successRate}`);
|
||||
t.comment(` - Memory increase: ${largeBatchMemory.result.memoryIncrease.heapUsed}MB heap`);
|
||||
t.comment(` - Average memory per invoice: ${largeBatchMemory.result.averageMemoryPerInvoice}KB`);
|
||||
|
||||
t.comment(`\nCorpus Batch Conversion (${corpusBatch.result.totalFiles} files):`);
|
||||
t.comment(` - Successfully parsed: ${corpusBatch.result.processed}`);
|
||||
t.comment(` - Successfully converted: ${corpusBatch.result.converted}`);
|
||||
t.comment(` - Conversion success rate: ${corpusBatch.result.conversionSuccessRate}`);
|
||||
t.comment(` - Average processing time: ${corpusBatch.result.averageProcessingTime}ms`);
|
||||
t.comment(` - Formats found: ${corpusBatch.result.formats.join(', ')}`);
|
||||
|
||||
// Performance summary
|
||||
t.comment('\n=== Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
@ -0,0 +1,537 @@
|
||||
/**
|
||||
* @file test.conv-11.encoding-edge-cases.ts
|
||||
* @description Tests for character encoding edge cases and special scenarios during conversion
|
||||
*/
|
||||
|
||||
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('CONV-11: Character Encoding Edge Cases');
|
||||
|
||||
tap.test('CONV-11: Character Encoding - should handle encoding edge cases during conversion', async (t) => {
|
||||
// Test 1: Mixed encoding declarations
|
||||
const mixedEncodingDeclarations = await performanceTracker.measureAsync(
|
||||
'mixed-encoding-declarations',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
utf8ToUtf16: false,
|
||||
utf16ToIso: false,
|
||||
isoToUtf8: false,
|
||||
bomHandling: false
|
||||
};
|
||||
|
||||
// UTF-8 to UTF-16 conversion
|
||||
const utf8Invoice = {
|
||||
format: 'ubl' as const,
|
||||
encoding: 'UTF-8',
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'ENC-UTF8-2024-001',
|
||||
issueDate: '2024-01-28',
|
||||
seller: {
|
||||
name: 'UTF-8 Société Française €',
|
||||
address: 'Rue de la Paix № 42',
|
||||
country: 'FR',
|
||||
taxId: 'FR12345678901'
|
||||
},
|
||||
buyer: {
|
||||
name: 'Käufer GmbH & Co. KG',
|
||||
address: 'Hauptstraße 123½',
|
||||
country: 'DE',
|
||||
taxId: 'DE123456789'
|
||||
},
|
||||
items: [{
|
||||
description: 'Spécialité française – Délicieux',
|
||||
quantity: 1,
|
||||
unitPrice: 99.99,
|
||||
vatRate: 20,
|
||||
lineTotal: 99.99
|
||||
}],
|
||||
totals: {
|
||||
netAmount: 99.99,
|
||||
vatAmount: 20.00,
|
||||
grossAmount: 119.99
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Convert and force UTF-16 encoding
|
||||
const converted = await einvoice.convertFormat(utf8Invoice, 'cii');
|
||||
converted.encoding = 'UTF-16';
|
||||
|
||||
// Check if special characters are preserved
|
||||
results.utf8ToUtf16 = converted.data.seller.name.includes('€') &&
|
||||
converted.data.seller.address.includes('№') &&
|
||||
converted.data.items[0].description.includes('–');
|
||||
} catch (error) {
|
||||
// Encoding conversion may not be supported
|
||||
}
|
||||
|
||||
// ISO-8859-1 limitations test
|
||||
const isoInvoice = {
|
||||
format: 'cii' as const,
|
||||
encoding: 'ISO-8859-1',
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'ENC-ISO-2024-001',
|
||||
issueDate: '2024-01-28',
|
||||
seller: {
|
||||
name: 'Latin-1 Company',
|
||||
address: 'Simple Street 1',
|
||||
country: 'ES',
|
||||
taxId: 'ES12345678A'
|
||||
},
|
||||
buyer: {
|
||||
name: 'Buyer Limited',
|
||||
address: 'Plain Avenue 2',
|
||||
country: 'ES',
|
||||
taxId: 'ES87654321B'
|
||||
},
|
||||
items: [{
|
||||
description: 'Product with emoji 😀 and Chinese 中文',
|
||||
quantity: 1,
|
||||
unitPrice: 50.00,
|
||||
vatRate: 21,
|
||||
lineTotal: 50.00
|
||||
}],
|
||||
totals: {
|
||||
netAmount: 50.00,
|
||||
vatAmount: 10.50,
|
||||
grossAmount: 60.50
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const converted = await einvoice.convertFormat(isoInvoice, 'ubl');
|
||||
// Characters outside ISO-8859-1 should be handled (replaced or encoded)
|
||||
results.isoToUtf8 = converted.data.items[0].description !== isoInvoice.data.items[0].description;
|
||||
} catch (error) {
|
||||
// Expected behavior for unsupported characters
|
||||
results.isoToUtf8 = true;
|
||||
}
|
||||
|
||||
// BOM handling test
|
||||
const bomInvoice = {
|
||||
format: 'ubl' as const,
|
||||
encoding: 'UTF-8-BOM',
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'ENC-BOM-2024-001',
|
||||
issueDate: '2024-01-28',
|
||||
seller: {
|
||||
name: 'BOM Test Company',
|
||||
address: 'BOM Street 1',
|
||||
country: 'US',
|
||||
taxId: 'US12-3456789'
|
||||
},
|
||||
buyer: {
|
||||
name: 'BOM Buyer Inc',
|
||||
address: 'BOM Avenue 2',
|
||||
country: 'US',
|
||||
taxId: 'US98-7654321'
|
||||
},
|
||||
items: [{
|
||||
description: 'BOM-aware product',
|
||||
quantity: 1,
|
||||
unitPrice: 100.00,
|
||||
vatRate: 8,
|
||||
lineTotal: 100.00
|
||||
}],
|
||||
totals: {
|
||||
netAmount: 100.00,
|
||||
vatAmount: 8.00,
|
||||
grossAmount: 108.00
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const converted = await einvoice.convertFormat(bomInvoice, 'cii');
|
||||
results.bomHandling = converted.data.invoiceNumber === bomInvoice.data.invoiceNumber;
|
||||
} catch (error) {
|
||||
// BOM handling error
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: Unicode normalization during conversion
|
||||
const unicodeNormalization = await performanceTracker.measureAsync(
|
||||
'unicode-normalization',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Test with different Unicode normalization forms
|
||||
const testCases = [
|
||||
{
|
||||
name: 'NFC vs NFD',
|
||||
text1: 'café', // NFC: é as single character
|
||||
text2: 'café', // NFD: e + combining acute accent
|
||||
shouldMatch: true
|
||||
},
|
||||
{
|
||||
name: 'Precomposed vs Decomposed',
|
||||
text1: 'Å', // Precomposed
|
||||
text2: 'Å', // A + ring above
|
||||
shouldMatch: true
|
||||
},
|
||||
{
|
||||
name: 'Complex diacritics',
|
||||
text1: 'Việt Nam',
|
||||
text2: 'Việt Nam', // Different composition
|
||||
shouldMatch: true
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `NORM-${testCase.name.replace(/\s+/g, '-')}`,
|
||||
issueDate: '2024-01-28',
|
||||
seller: {
|
||||
name: testCase.text1,
|
||||
address: 'Normalization Test 1',
|
||||
country: 'VN',
|
||||
taxId: 'VN1234567890'
|
||||
},
|
||||
buyer: {
|
||||
name: testCase.text2,
|
||||
address: 'Normalization Test 2',
|
||||
country: 'VN',
|
||||
taxId: 'VN0987654321'
|
||||
},
|
||||
items: [{
|
||||
description: `Product from ${testCase.text1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100.00,
|
||||
vatRate: 10,
|
||||
lineTotal: 100.00
|
||||
}],
|
||||
totals: {
|
||||
netAmount: 100.00,
|
||||
vatAmount: 10.00,
|
||||
grossAmount: 110.00
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const converted = await einvoice.convertFormat(invoice, 'cii');
|
||||
const backToUBL = await einvoice.convertFormat(converted, 'ubl');
|
||||
|
||||
// Check if normalized strings are handled correctly
|
||||
const sellerMatch = backToUBL.data.seller.name === invoice.data.seller.name ||
|
||||
backToUBL.data.seller.name.normalize('NFC') === invoice.data.seller.name.normalize('NFC');
|
||||
|
||||
results.push({
|
||||
testCase: testCase.name,
|
||||
preserved: sellerMatch,
|
||||
original: testCase.text1,
|
||||
converted: backToUBL.data.seller.name
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
testCase: testCase.name,
|
||||
preserved: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: Zero-width and control characters
|
||||
const controlCharacters = await performanceTracker.measureAsync(
|
||||
'control-characters-handling',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Test various control and special characters
|
||||
const specialChars = {
|
||||
zeroWidth: '\u200B\u200C\u200D\uFEFF', // Zero-width characters
|
||||
control: '\u0001\u0002\u001F', // Control characters
|
||||
directional: '\u202A\u202B\u202C\u202D\u202E', // Directional marks
|
||||
combining: 'a\u0300\u0301\u0302\u0303', // Combining diacriticals
|
||||
surrogates: '𝕳𝖊𝖑𝖑𝖔', // Mathematical alphanumeric symbols
|
||||
emoji: '🧾💰📊' // Emoji characters
|
||||
};
|
||||
|
||||
const results = {};
|
||||
|
||||
for (const [charType, chars] of Object.entries(specialChars)) {
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `CTRL-${charType.toUpperCase()}-001`,
|
||||
issueDate: '2024-01-28',
|
||||
seller: {
|
||||
name: `Seller${chars}Company`,
|
||||
address: `Address ${chars} Line`,
|
||||
country: 'US',
|
||||
taxId: 'US12-3456789'
|
||||
},
|
||||
buyer: {
|
||||
name: `Buyer ${chars} Ltd`,
|
||||
address: 'Normal Address',
|
||||
country: 'US',
|
||||
taxId: 'US98-7654321'
|
||||
},
|
||||
items: [{
|
||||
description: `Product ${chars} Description`,
|
||||
quantity: 1,
|
||||
unitPrice: 100.00,
|
||||
vatRate: 10,
|
||||
lineTotal: 100.00
|
||||
}],
|
||||
totals: {
|
||||
netAmount: 100.00,
|
||||
vatAmount: 10.00,
|
||||
grossAmount: 110.00
|
||||
},
|
||||
notes: `Notes with ${chars} special characters`
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const converted = await einvoice.convertFormat(invoice, 'cii');
|
||||
const sanitized = await einvoice.convertFormat(converted, 'ubl');
|
||||
|
||||
// Check how special characters are handled
|
||||
results[charType] = {
|
||||
originalLength: invoice.data.seller.name.length,
|
||||
convertedLength: sanitized.data.seller.name.length,
|
||||
preserved: invoice.data.seller.name === sanitized.data.seller.name,
|
||||
cleaned: sanitized.data.seller.name.replace(/[\u0000-\u001F\u200B-\u200D\uFEFF]/g, '').length < invoice.data.seller.name.length
|
||||
};
|
||||
} catch (error) {
|
||||
results[charType] = {
|
||||
error: true,
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Encoding conflicts in multi-language invoices
|
||||
const multiLanguageEncoding = await performanceTracker.measureAsync(
|
||||
'multi-language-encoding',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Create invoice with multiple scripts/languages
|
||||
const multiLangInvoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'MULTI-LANG-2024-001',
|
||||
issueDate: '2024-01-28',
|
||||
seller: {
|
||||
name: 'Global Trading Company 全球贸易公司',
|
||||
address: 'International Plaza 国际广场 Διεθνής Πλατεία',
|
||||
country: 'SG',
|
||||
taxId: 'SG12345678X'
|
||||
},
|
||||
buyer: {
|
||||
name: 'المشتري العربي | Arabic Buyer | खरीदार',
|
||||
address: 'شارع العرب | Arab Street | अरब स्ट्रीट',
|
||||
country: 'AE',
|
||||
taxId: 'AE123456789012345'
|
||||
},
|
||||
items: [
|
||||
{
|
||||
description: 'Product 产品 Προϊόν منتج उत्पाद',
|
||||
quantity: 1,
|
||||
unitPrice: 100.00,
|
||||
vatRate: 5,
|
||||
lineTotal: 100.00
|
||||
},
|
||||
{
|
||||
description: 'Service 服务 Υπηρεσία خدمة सेवा',
|
||||
quantity: 2,
|
||||
unitPrice: 200.00,
|
||||
vatRate: 5,
|
||||
lineTotal: 400.00
|
||||
}
|
||||
],
|
||||
totals: {
|
||||
netAmount: 500.00,
|
||||
vatAmount: 25.00,
|
||||
grossAmount: 525.00
|
||||
},
|
||||
notes: 'Thank you 谢谢 Ευχαριστώ شكرا धन्यवाद'
|
||||
}
|
||||
};
|
||||
|
||||
// Test conversion through different formats
|
||||
const conversionTests = [
|
||||
{ from: 'ubl', to: 'cii' },
|
||||
{ from: 'cii', to: 'zugferd' },
|
||||
{ from: 'zugferd', to: 'xrechnung' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
let currentInvoice = multiLangInvoice;
|
||||
|
||||
for (const test of conversionTests) {
|
||||
try {
|
||||
const converted = await einvoice.convertFormat(currentInvoice, test.to);
|
||||
|
||||
// Check preservation of multi-language content
|
||||
const sellerNamePreserved = converted.data.seller.name.includes('全球贸易公司');
|
||||
const buyerNamePreserved = converted.data.buyer.name.includes('العربي') &&
|
||||
converted.data.buyer.name.includes('खरीदार');
|
||||
const itemsPreserved = converted.data.items[0].description.includes('产品') &&
|
||||
converted.data.items[0].description.includes('منتج');
|
||||
|
||||
results.push({
|
||||
conversion: `${test.from} -> ${test.to}`,
|
||||
sellerNamePreserved,
|
||||
buyerNamePreserved,
|
||||
itemsPreserved,
|
||||
allPreserved: sellerNamePreserved && buyerNamePreserved && itemsPreserved
|
||||
});
|
||||
|
||||
currentInvoice = converted;
|
||||
} catch (error) {
|
||||
results.push({
|
||||
conversion: `${test.from} -> ${test.to}`,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Corpus encoding analysis
|
||||
const corpusEncodingAnalysis = await performanceTracker.measureAsync(
|
||||
'corpus-encoding-edge-cases',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const encodingStats = {
|
||||
totalFiles: 0,
|
||||
encodingIssues: 0,
|
||||
specialCharFiles: 0,
|
||||
conversionFailures: 0,
|
||||
characterTypes: new Set<string>(),
|
||||
problematicFiles: [] as string[]
|
||||
};
|
||||
|
||||
// Sample files for analysis
|
||||
const sampleFiles = files.slice(0, 30);
|
||||
|
||||
for (const file of sampleFiles) {
|
||||
try {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
encodingStats.totalFiles++;
|
||||
|
||||
// Check for special characters
|
||||
const hasSpecialChars = /[^\x00-\x7F]/.test(content);
|
||||
const hasControlChars = /[\x00-\x1F\x7F]/.test(content);
|
||||
const hasRTL = /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/.test(content);
|
||||
const hasCJK = /[\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/.test(content);
|
||||
|
||||
if (hasSpecialChars || hasControlChars || hasRTL || hasCJK) {
|
||||
encodingStats.specialCharFiles++;
|
||||
if (hasControlChars) encodingStats.characterTypes.add('control');
|
||||
if (hasRTL) encodingStats.characterTypes.add('RTL');
|
||||
if (hasCJK) encodingStats.characterTypes.add('CJK');
|
||||
}
|
||||
|
||||
// Try format detection and conversion
|
||||
const format = await einvoice.detectFormat(content);
|
||||
if (format && format !== 'unknown') {
|
||||
try {
|
||||
const parsed = await einvoice.parseInvoice(content, format);
|
||||
const targetFormat = format === 'ubl' ? 'cii' : 'ubl';
|
||||
|
||||
// Test conversion with special characters
|
||||
await einvoice.convertFormat(parsed, targetFormat);
|
||||
} catch (convError) {
|
||||
encodingStats.conversionFailures++;
|
||||
if (hasSpecialChars) {
|
||||
encodingStats.problematicFiles.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
encodingStats.encodingIssues++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...encodingStats,
|
||||
characterTypes: Array.from(encodingStats.characterTypes),
|
||||
specialCharPercentage: (encodingStats.specialCharFiles / encodingStats.totalFiles * 100).toFixed(2) + '%',
|
||||
conversionFailureRate: (encodingStats.conversionFailures / encodingStats.totalFiles * 100).toFixed(2) + '%'
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== CONV-11: Character Encoding Edge Cases Test Summary ===');
|
||||
t.comment('\nMixed Encoding Declarations:');
|
||||
t.comment(` - UTF-8 to UTF-16: ${mixedEncodingDeclarations.result.utf8ToUtf16 ? 'SUPPORTED' : 'NOT SUPPORTED'}`);
|
||||
t.comment(` - UTF-16 to ISO-8859-1: ${mixedEncodingDeclarations.result.utf16ToIso ? 'HANDLED' : 'NOT HANDLED'}`);
|
||||
t.comment(` - ISO-8859-1 to UTF-8: ${mixedEncodingDeclarations.result.isoToUtf8 ? 'HANDLED' : 'NOT HANDLED'}`);
|
||||
t.comment(` - BOM handling: ${mixedEncodingDeclarations.result.bomHandling ? 'SUPPORTED' : 'NOT SUPPORTED'}`);
|
||||
|
||||
t.comment('\nUnicode Normalization:');
|
||||
unicodeNormalization.result.forEach(test => {
|
||||
t.comment(` - ${test.testCase}: ${test.preserved ? 'PRESERVED' : 'MODIFIED'}`);
|
||||
});
|
||||
|
||||
t.comment('\nControl Characters Handling:');
|
||||
Object.entries(controlCharacters.result).forEach(([type, result]: [string, any]) => {
|
||||
if (result.error) {
|
||||
t.comment(` - ${type}: ERROR - ${result.message}`);
|
||||
} else {
|
||||
t.comment(` - ${type}: ${result.preserved ? 'PRESERVED' : 'SANITIZED'} (${result.originalLength} -> ${result.convertedLength} chars)`);
|
||||
}
|
||||
});
|
||||
|
||||
t.comment('\nMulti-Language Encoding:');
|
||||
multiLanguageEncoding.result.forEach(test => {
|
||||
if (test.error) {
|
||||
t.comment(` - ${test.conversion}: ERROR - ${test.error}`);
|
||||
} else {
|
||||
t.comment(` - ${test.conversion}: ${test.allPreserved ? 'ALL PRESERVED' : 'PARTIAL LOSS'}`);
|
||||
}
|
||||
});
|
||||
|
||||
t.comment('\nCorpus Encoding Analysis:');
|
||||
t.comment(` - Files analyzed: ${corpusEncodingAnalysis.result.totalFiles}`);
|
||||
t.comment(` - Files with special characters: ${corpusEncodingAnalysis.result.specialCharFiles} (${corpusEncodingAnalysis.result.specialCharPercentage})`);
|
||||
t.comment(` - Character types found: ${corpusEncodingAnalysis.result.characterTypes.join(', ')}`);
|
||||
t.comment(` - Encoding issues: ${corpusEncodingAnalysis.result.encodingIssues}`);
|
||||
t.comment(` - Conversion failures: ${corpusEncodingAnalysis.result.conversionFailures} (${corpusEncodingAnalysis.result.conversionFailureRate})`);
|
||||
|
||||
// Performance summary
|
||||
t.comment('\n=== Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
490
test/suite/einvoice_conversion/test.conv-12.performance.ts
Normal file
490
test/suite/einvoice_conversion/test.conv-12.performance.ts
Normal file
@ -0,0 +1,490 @@
|
||||
/**
|
||||
* @file test.conv-12.performance.ts
|
||||
* @description Performance benchmarks for format conversion operations
|
||||
*/
|
||||
|
||||
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('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;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: Complex invoice conversion performance
|
||||
const complexInvoicePerformance = await performanceTracker.measureAsync(
|
||||
'complex-invoice-performance',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// 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 {
|
||||
const converted = await einvoice.convertFormat(complexInvoice, targetFormat);
|
||||
success = converted !== null;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
invoiceSize: {
|
||||
items: complexInvoice.data.items.length,
|
||||
netAmount: complexInvoice.data.totals.netAmount.toFixed(2),
|
||||
grossAmount: complexInvoice.data.totals.grossAmount.toFixed(2)
|
||||
},
|
||||
conversions: results
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// 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'
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
tap.start();
|
Reference in New Issue
Block a user