Files
einvoice/ts/formats/validation/conformance.harness.ts

591 lines
20 KiB
TypeScript
Raw Normal View History

/**
* 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<string, {
covered: boolean;
samplesCovering: string[];
errorCount: number;
warningCount: number;
}>;
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<void> {
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<TestResult> {
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<void> {
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<string, any>();
// 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<void> {
const coverage = this.generateCoverageMatrix();
const html = `
<!DOCTYPE html>
<html>
<head>
<title>EN16931 Conformance Test Report</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
.summary { background: #f0f0f0; padding: 15px; border-radius: 5px; margin: 20px 0; }
.metric { display: inline-block; margin: 10px 20px 10px 0; }
.metric-value { font-size: 24px; font-weight: bold; color: #007bff; }
.coverage-bar { width: 100%; height: 30px; background: #e0e0e0; border-radius: 5px; overflow: hidden; }
.coverage-fill { height: 100%; background: linear-gradient(90deg, #28a745, #ffc107); }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { padding: 10px; text-align: left; border: 1px solid #ddd; }
th { background: #f8f9fa; font-weight: bold; }
.covered { background: #d4edda; }
.uncovered { background: #f8d7da; }
.category-section { margin: 30px 0; }
.rule-tag { display: inline-block; padding: 2px 8px; margin: 2px; background: #007bff; color: white; border-radius: 3px; font-size: 12px; }
</style>
</head>
<body>
<h1>EN16931 Conformance Test Report</h1>
<div class="summary">
<h2>Overall Coverage</h2>
<div class="metric">
<div class="metric-value">${coverage.coveragePercentage.toFixed(1)}%</div>
<div>Total Coverage</div>
</div>
<div class="metric">
<div class="metric-value">${coverage.coveredRules}</div>
<div>Rules Covered</div>
</div>
<div class="metric">
<div class="metric-value">${coverage.totalRules}</div>
<div>Total Rules</div>
</div>
<div class="coverage-bar">
<div class="coverage-fill" style="width: ${coverage.coveragePercentage}%"></div>
</div>
</div>
<div class="category-section">
<h2>Coverage by Category</h2>
<table>
<tr>
<th>Category</th>
<th>Covered</th>
<th>Total</th>
<th>Percentage</th>
</tr>
${Object.entries(coverage.byCategory).map(([cat, data]) => `
<tr>
<td>${cat.charAt(0).toUpperCase() + cat.slice(1)}</td>
<td>${data.covered}</td>
<td>${data.total}</td>
<td>${data.total > 0 ? ((data.covered / data.total) * 100).toFixed(1) : 0}%</td>
</tr>
`).join('')}
</table>
</div>
<div class="category-section">
<h2>Test Samples</h2>
<table>
<tr>
<th>Sample</th>
<th>Status</th>
<th>Errors</th>
<th>Warnings</th>
<th>Rules Triggered</th>
</tr>
${this.results.map(r => `
<tr class="${r.passed ? 'covered' : 'uncovered'}">
<td>${r.sampleName}</td>
<td>${r.passed ? '✅ PASSED' : '❌ FAILED'}</td>
<td>${r.errors.length}</td>
<td>${r.warnings.length}</td>
<td>${r.rulesTriggered.length}</td>
</tr>
`).join('')}
</table>
</div>
<div class="category-section">
<h2>Uncovered Rules</h2>
${coverage.uncoveredRules.length === 0 ? '<p>All rules covered! 🎉</p>' : `
<p>The following ${coverage.uncoveredRules.length} rules need test coverage:</p>
<div>
${coverage.uncoveredRules.map(rule =>
`<span class="rule-tag">${rule}</span>`
).join('')}
</div>
`}
</div>
<div class="category-section">
<p>Generated: ${new Date().toISOString()}</p>
</div>
</body>
</html>
`;
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<string, string> = {
'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<string, string[]> = {
'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<void> {
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();
}
}