/** * Conformance Test Harness for EN16931 Validation * Tests validators against official samples and generates coverage reports */ import * as plugins from '../../plugins.js'; import * as fs from 'fs'; import * as path from 'path'; import { IntegratedValidator } from './schematron.integration.js'; import { EN16931BusinessRulesValidator } from './en16931.business-rules.validator.js'; import { CodeListValidator } from './codelist.validator.js'; import { VATCategoriesValidator } from './vat-categories.validator.js'; import type { ValidationResult, ValidationReport } from './validation.types.js'; import type { EInvoice } from '../../einvoice.js'; import { XMLToEInvoiceConverter } from '../converters/xml-to-einvoice.converter.js'; /** * Test sample metadata */ interface TestSample { id: string; name: string; path: string; format: 'UBL' | 'CII'; standard: string; expectedValid: boolean; description?: string; focusRules?: string[]; } /** * Test result for a single sample */ interface TestResult { sampleId: string; sampleName: string; passed: boolean; errors: ValidationResult[]; warnings: ValidationResult[]; rulesTriggered: string[]; executionTime: number; validatorResults: { typescript: ValidationResult[]; schematron: ValidationResult[]; vatCategories: ValidationResult[]; codeLists: ValidationResult[]; }; } /** * Coverage report for all rules */ interface CoverageReport { totalRules: number; coveredRules: number; coveragePercentage: number; ruleDetails: Map; uncoveredRules: string[]; byCategory: { document: { total: number; covered: number }; calculation: { total: number; covered: number }; vat: { total: number; covered: number }; lineLevel: { total: number; covered: number }; codeLists: { total: number; covered: number }; }; } /** * Conformance Test Harness */ export class ConformanceTestHarness { private integratedValidator: IntegratedValidator; private businessRulesValidator: EN16931BusinessRulesValidator; private codeListValidator: CodeListValidator; private vatCategoriesValidator: VATCategoriesValidator; private xmlConverter: XMLToEInvoiceConverter; private testSamples: TestSample[] = []; private results: TestResult[] = []; constructor() { this.integratedValidator = new IntegratedValidator(); this.businessRulesValidator = new EN16931BusinessRulesValidator(); this.codeListValidator = new CodeListValidator(); this.vatCategoriesValidator = new VATCategoriesValidator(); this.xmlConverter = new XMLToEInvoiceConverter(); } /** * Load test samples from directory */ public async loadTestSamples(baseDir: string = 'test-samples'): Promise { this.testSamples = []; // Load PEPPOL BIS 3.0 samples const peppolDir = path.join(baseDir, 'peppol-bis3'); if (fs.existsSync(peppolDir)) { const peppolFiles = fs.readdirSync(peppolDir); for (const file of peppolFiles) { if (file.endsWith('.xml')) { this.testSamples.push({ id: `peppol-${path.basename(file, '.xml')}`, name: file, path: path.join(peppolDir, file), format: 'UBL', standard: 'PEPPOL-BIS-3.0', expectedValid: true, description: this.getDescriptionFromFilename(file), focusRules: this.getFocusRulesFromFilename(file) }); } } } // Load CEN TC434 samples const cenDir = path.join(baseDir, 'cen-tc434'); if (fs.existsSync(cenDir)) { const cenFiles = fs.readdirSync(cenDir); for (const file of cenFiles) { if (file.endsWith('.xml')) { const format = file.includes('ubl') ? 'UBL' : 'CII'; this.testSamples.push({ id: `cen-${path.basename(file, '.xml')}`, name: file, path: path.join(cenDir, file), format, standard: 'EN16931', expectedValid: true, description: `CEN TC434 ${format} example` }); } } } console.log(`Loaded ${this.testSamples.length} test samples`); } /** * Run all validators against a single test sample */ private async runTestSample(sample: TestSample): Promise { const startTime = Date.now(); const result: TestResult = { sampleId: sample.id, sampleName: sample.name, passed: false, errors: [], warnings: [], rulesTriggered: [], executionTime: 0, validatorResults: { typescript: [], schematron: [], vatCategories: [], codeLists: [] } }; try { // Read XML content const xmlContent = fs.readFileSync(sample.path, 'utf-8'); // Convert XML to EInvoice const invoice = await this.xmlConverter.convert(xmlContent, sample.format); // Run TypeScript validators const businessRules = this.businessRulesValidator.validate(invoice); result.validatorResults.typescript = businessRules; const codeLists = this.codeListValidator.validate(invoice); result.validatorResults.codeLists = codeLists; const vatCategories = this.vatCategoriesValidator.validate(invoice); result.validatorResults.vatCategories = vatCategories; // Try to run Schematron if available try { await this.integratedValidator.loadSchematron('EN16931', sample.format); const report = await this.integratedValidator.validate(invoice, xmlContent); result.validatorResults.schematron = report.results.filter(r => r.source === 'Schematron' ); } catch (error) { console.warn(`Schematron not available for ${sample.format}: ${error.message}`); } // Aggregate results const allResults = [ ...businessRules, ...codeLists, ...vatCategories, ...result.validatorResults.schematron ]; result.errors = allResults.filter(r => r.severity === 'error'); result.warnings = allResults.filter(r => r.severity === 'warning'); result.rulesTriggered = [...new Set(allResults.map(r => r.ruleId))]; result.passed = result.errors.length === 0 === sample.expectedValid; } catch (error) { console.error(`Error testing ${sample.name}: ${error.message}`); result.errors.push({ ruleId: 'TEST-ERROR', source: 'TestHarness', severity: 'error', message: `Test execution failed: ${error.message}` }); } result.executionTime = Date.now() - startTime; return result; } /** * Run conformance tests on all samples */ public async runConformanceTests(): Promise { console.log('\nšŸ”¬ Running conformance tests...\n'); this.results = []; for (const sample of this.testSamples) { process.stdout.write(`Testing ${sample.name}... `); const result = await this.runTestSample(sample); this.results.push(result); if (result.passed) { console.log('āœ… PASSED'); } else { console.log(`āŒ FAILED (${result.errors.length} errors)`); } } console.log('\n' + '='.repeat(60)); this.printSummary(); } /** * Generate BR coverage matrix */ public generateCoverageMatrix(): CoverageReport { // Define all EN16931 business rules const allRules = this.getAllEN16931Rules(); const ruleDetails = new Map(); // Initialize rule details for (const rule of allRules) { ruleDetails.set(rule, { covered: false, samplesCovering: [], errorCount: 0, warningCount: 0 }); } // Process test results for (const result of this.results) { for (const ruleId of result.rulesTriggered) { if (ruleDetails.has(ruleId)) { const detail = ruleDetails.get(ruleId); detail.covered = true; detail.samplesCovering.push(result.sampleId); detail.errorCount += result.errors.filter(e => e.ruleId === ruleId).length; detail.warningCount += result.warnings.filter(w => w.ruleId === ruleId).length; } } } // Calculate coverage by category const categories = { document: { total: 0, covered: 0 }, calculation: { total: 0, covered: 0 }, vat: { total: 0, covered: 0 }, lineLevel: { total: 0, covered: 0 }, codeLists: { total: 0, covered: 0 } }; for (const [rule, detail] of ruleDetails) { const category = this.getRuleCategory(rule); if (category && categories[category]) { categories[category].total++; if (detail.covered) { categories[category].covered++; } } } // Find uncovered rules const uncoveredRules = Array.from(ruleDetails.entries()) .filter(([_, detail]) => !detail.covered) .map(([rule, _]) => rule); const coveredCount = Array.from(ruleDetails.values()) .filter(d => d.covered).length; return { totalRules: allRules.length, coveredRules: coveredCount, coveragePercentage: (coveredCount / allRules.length) * 100, ruleDetails, uncoveredRules, byCategory: categories }; } /** * Print test summary */ private printSummary(): void { const passed = this.results.filter(r => r.passed).length; const failed = this.results.filter(r => !r.passed).length; const totalErrors = this.results.reduce((sum, r) => sum + r.errors.length, 0); const totalWarnings = this.results.reduce((sum, r) => sum + r.warnings.length, 0); console.log('\nšŸ“Š Test Summary:'); console.log(` Total samples: ${this.testSamples.length}`); console.log(` āœ… Passed: ${passed}`); console.log(` āŒ Failed: ${failed}`); console.log(` šŸ”“ Total errors: ${totalErrors}`); console.log(` 🟔 Total warnings: ${totalWarnings}`); // Show failed samples if (failed > 0) { console.log('\nāŒ Failed samples:'); for (const result of this.results.filter(r => !r.passed)) { console.log(` - ${result.sampleName} (${result.errors.length} errors)`); for (const error of result.errors.slice(0, 3)) { console.log(` • ${error.ruleId}: ${error.message}`); } if (result.errors.length > 3) { console.log(` ... and ${result.errors.length - 3} more errors`); } } } } /** * Generate HTML coverage report */ public async generateHTMLReport(outputPath: string = 'coverage-report.html'): Promise { const coverage = this.generateCoverageMatrix(); const html = ` EN16931 Conformance Test Report

EN16931 Conformance Test Report

Overall Coverage

${coverage.coveragePercentage.toFixed(1)}%
Total Coverage
${coverage.coveredRules}
Rules Covered
${coverage.totalRules}
Total Rules

Coverage by Category

${Object.entries(coverage.byCategory).map(([cat, data]) => ` `).join('')}
Category Covered Total Percentage
${cat.charAt(0).toUpperCase() + cat.slice(1)} ${data.covered} ${data.total} ${data.total > 0 ? ((data.covered / data.total) * 100).toFixed(1) : 0}%

Test Samples

${this.results.map(r => ` `).join('')}
Sample Status Errors Warnings Rules Triggered
${r.sampleName} ${r.passed ? 'āœ… PASSED' : 'āŒ FAILED'} ${r.errors.length} ${r.warnings.length} ${r.rulesTriggered.length}

Uncovered Rules

${coverage.uncoveredRules.length === 0 ? '

All rules covered! šŸŽ‰

' : `

The following ${coverage.uncoveredRules.length} rules need test coverage:

${coverage.uncoveredRules.map(rule => `${rule}` ).join('')}
`}

Generated: ${new Date().toISOString()}

`; fs.writeFileSync(outputPath, html); console.log(`\nšŸ“„ HTML report generated: ${outputPath}`); } /** * Get all EN16931 business rules */ private getAllEN16931Rules(): string[] { return [ // Document level rules 'BR-01', 'BR-02', 'BR-03', 'BR-04', 'BR-05', 'BR-06', 'BR-07', 'BR-08', 'BR-09', 'BR-10', 'BR-11', 'BR-12', 'BR-13', 'BR-14', 'BR-15', 'BR-16', 'BR-17', 'BR-18', 'BR-19', 'BR-20', // Line level rules 'BR-21', 'BR-22', 'BR-23', 'BR-24', 'BR-25', 'BR-26', 'BR-27', 'BR-28', 'BR-29', 'BR-30', // Allowances and charges 'BR-31', 'BR-32', 'BR-33', 'BR-34', 'BR-35', 'BR-36', 'BR-37', 'BR-38', 'BR-39', 'BR-40', 'BR-41', 'BR-42', 'BR-43', 'BR-44', 'BR-45', 'BR-46', 'BR-47', 'BR-48', 'BR-49', 'BR-50', 'BR-51', 'BR-52', 'BR-53', 'BR-54', 'BR-55', 'BR-56', 'BR-57', 'BR-58', 'BR-59', 'BR-60', 'BR-61', 'BR-62', 'BR-63', 'BR-64', 'BR-65', // Calculation rules 'BR-CO-01', 'BR-CO-02', 'BR-CO-03', 'BR-CO-04', 'BR-CO-05', 'BR-CO-06', 'BR-CO-07', 'BR-CO-08', 'BR-CO-09', 'BR-CO-10', 'BR-CO-11', 'BR-CO-12', 'BR-CO-13', 'BR-CO-14', 'BR-CO-15', 'BR-CO-16', 'BR-CO-17', 'BR-CO-18', 'BR-CO-19', 'BR-CO-20', // VAT rules - Standard rate 'BR-S-01', 'BR-S-02', 'BR-S-03', 'BR-S-04', 'BR-S-05', 'BR-S-06', 'BR-S-07', 'BR-S-08', // VAT rules - Zero rated 'BR-Z-01', 'BR-Z-02', 'BR-Z-03', 'BR-Z-04', 'BR-Z-05', 'BR-Z-06', 'BR-Z-07', 'BR-Z-08', // VAT rules - Exempt 'BR-E-01', 'BR-E-02', 'BR-E-03', 'BR-E-04', 'BR-E-05', 'BR-E-06', 'BR-E-07', 'BR-E-08', // VAT rules - Reverse charge 'BR-AE-01', 'BR-AE-02', 'BR-AE-03', 'BR-AE-04', 'BR-AE-05', 'BR-AE-06', 'BR-AE-07', 'BR-AE-08', // VAT rules - Intra-community 'BR-K-01', 'BR-K-02', 'BR-K-03', 'BR-K-04', 'BR-K-05', 'BR-K-06', 'BR-K-07', 'BR-K-08', 'BR-K-09', 'BR-K-10', // VAT rules - Export 'BR-G-01', 'BR-G-02', 'BR-G-03', 'BR-G-04', 'BR-G-05', 'BR-G-06', 'BR-G-07', 'BR-G-08', // VAT rules - Out of scope 'BR-O-01', 'BR-O-02', 'BR-O-03', 'BR-O-04', 'BR-O-05', 'BR-O-06', 'BR-O-07', 'BR-O-08', // Code list rules 'BR-CL-01', 'BR-CL-02', 'BR-CL-03', 'BR-CL-04', 'BR-CL-05', 'BR-CL-06', 'BR-CL-07', 'BR-CL-08', 'BR-CL-09', 'BR-CL-10', 'BR-CL-11', 'BR-CL-12', 'BR-CL-13', 'BR-CL-14', 'BR-CL-15', 'BR-CL-16', 'BR-CL-17', 'BR-CL-18', 'BR-CL-19', 'BR-CL-20', 'BR-CL-21', 'BR-CL-22', 'BR-CL-23', 'BR-CL-24', 'BR-CL-25', 'BR-CL-26' ]; } /** * Get category for a rule */ private getRuleCategory(ruleId: string): keyof CoverageReport['byCategory'] | null { if (ruleId.startsWith('BR-CO-')) return 'calculation'; if (ruleId.match(/^BR-[SZAEKG0]-/)) return 'vat'; if (ruleId.startsWith('BR-CL-')) return 'codeLists'; if (ruleId.match(/^BR-2[0-9]/) || ruleId.match(/^BR-3[0-9]/)) return 'lineLevel'; if (ruleId.match(/^BR-[0-9]/) || ruleId.match(/^BR-1[0-9]/)) return 'document'; return null; } /** * Get description from filename */ private getDescriptionFromFilename(filename: string): string { const descriptions: Record = { 'Allowance-example': 'Invoice with document level allowances', 'base-example': 'Basic EN16931 compliant invoice', 'base-negative-inv-correction': 'Negative invoice correction', 'vat-category-E': 'VAT Exempt invoice', 'vat-category-O': 'Out of scope services', 'vat-category-S': 'Standard rated VAT', 'vat-category-Z': 'Zero rated VAT', 'vat-category-AE': 'Reverse charge VAT', 'vat-category-K': 'Intra-community supply', 'vat-category-G': 'Export outside EU' }; const key = filename.replace('.xml', ''); return descriptions[key] || filename; } /** * Get focus rules from filename */ private getFocusRulesFromFilename(filename: string): string[] { const focusMap: Record = { 'vat-category-E': ['BR-E-01', 'BR-E-02', 'BR-E-03', 'BR-E-04', 'BR-E-05', 'BR-E-06'], 'vat-category-S': ['BR-S-01', 'BR-S-02', 'BR-S-03', 'BR-S-04', 'BR-S-05'], 'vat-category-Z': ['BR-Z-01', 'BR-Z-02', 'BR-Z-03', 'BR-Z-04', 'BR-Z-05'], 'vat-category-AE': ['BR-AE-01', 'BR-AE-02', 'BR-AE-03', 'BR-AE-04', 'BR-AE-05', 'BR-AE-06'], 'vat-category-K': ['BR-K-01', 'BR-K-02', 'BR-K-03', 'BR-K-04', 'BR-K-05', 'BR-K-06'], 'vat-category-G': ['BR-G-01', 'BR-G-02', 'BR-G-03', 'BR-G-04', 'BR-G-05', 'BR-G-06'], 'vat-category-O': ['BR-O-01', 'BR-O-02', 'BR-O-03', 'BR-O-04', 'BR-O-05', 'BR-O-06'] }; const key = filename.replace('.xml', ''); return focusMap[key] || []; } } /** * Export convenience function to run conformance tests */ export async function runConformanceTests( samplesDir: string = 'test-samples', generateReport: boolean = true ): Promise { const harness = new ConformanceTestHarness(); // Load samples await harness.loadTestSamples(samplesDir); // Run tests await harness.runConformanceTests(); // Generate reports if (generateReport) { const coverage = harness.generateCoverageMatrix(); console.log('\nšŸ“Š Coverage Report:'); console.log(` Overall: ${coverage.coveragePercentage.toFixed(1)}%`); console.log(` Rules covered: ${coverage.coveredRules}/${coverage.totalRules}`); // Show category breakdown console.log('\n By Category:'); for (const [category, data] of Object.entries(coverage.byCategory)) { const pct = data.total > 0 ? ((data.covered / data.total) * 100).toFixed(1) : '0'; console.log(` - ${category}: ${data.covered}/${data.total} (${pct}%)`); } // Generate HTML report await harness.generateHTMLReport(); } }