@accounter/shaam-uniform-format-generator
Version:
Fully typed application that generates, parses, and validates SHAAM uniform format tax reports (INI.TXT and BKMVDATA.TXT).
851 lines (724 loc) • 33.8 kB
text/typescript
/**
* Integration test for round-trip validation with actual fixture files
* This test validates the BKMVDATA.txt and ini.txt fixture files by:
* 1. Parsing them into structured data
* 2. Generating new files from the parsed data
* 3. Comparing the results to ensure data integrity
*/
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { generateUniformFormatReport } from '../../src/api/generate-report.js';
import { parseUniformFormatFiles } from '../../src/api/parse-files.js';
import {
parseA000,
parseA000Sum,
parseA100,
parseB100,
parseB110,
parseC100,
parseD110,
parseD120,
parseM100,
parseZ900,
} from '../../src/generator/records/index.js';
import type {
Account,
Document,
InventoryItem,
JournalEntry,
ReportInput,
} from '../../src/types/index';
const FIXTURES_DIR = join(__dirname, '..', 'fixtures');
interface ParsedFixtureData {
business: ReportInput['business'] | null;
documents: Document[];
journalEntries: JournalEntry[];
accounts: Account[];
inventory: InventoryItem[];
rawRecords: {
a100: ReturnType<typeof parseA100> | null;
b100: ReturnType<typeof parseB100>[];
b110: ReturnType<typeof parseB110>[];
c100: ReturnType<typeof parseC100>[];
d110: ReturnType<typeof parseD110>[];
d120: ReturnType<typeof parseD120>[];
m100: ReturnType<typeof parseM100>[];
z900: ReturnType<typeof parseZ900> | null;
};
}
interface RecordParsingResult {
success: boolean;
recordType?: string;
parsedRecord?:
| ReturnType<typeof parseA100>
| ReturnType<typeof parseB100>
| ReturnType<typeof parseB110>
| ReturnType<typeof parseC100>
| ReturnType<typeof parseD110>
| ReturnType<typeof parseD120>
| ReturnType<typeof parseM100>
| ReturnType<typeof parseZ900>;
error?: Error;
}
/**
* Shared helper function to parse a single record line based on its record type
* @param line The line to parse (should already be cleaned of line endings)
* @returns Object containing parsing result and parsed record if successful
*/
function parseRecordLine(line: string): RecordParsingResult {
if (line.length < 4) {
return { success: false };
}
const recordType = line.substring(0, 4);
try {
switch (recordType) {
case 'A100':
return { success: true, recordType, parsedRecord: parseA100(line) };
case 'B100':
return { success: true, recordType, parsedRecord: parseB100(line) };
case 'B110':
return { success: true, recordType, parsedRecord: parseB110(line) };
case 'C100':
return { success: true, recordType, parsedRecord: parseC100(line) };
case 'D110':
return { success: true, recordType, parsedRecord: parseD110(line) };
case 'D120':
return { success: true, recordType, parsedRecord: parseD120(line) };
case 'M100':
return { success: true, recordType, parsedRecord: parseM100(line) };
case 'Z900':
return { success: true, recordType, parsedRecord: parseZ900(line) };
default:
// Unknown record type
return { success: false, recordType };
}
} catch (error) {
return { success: false, recordType, error: error as Error };
}
}
/**
* Parses the BKMVDATA.txt fixture file into structured data
*/
async function parseFixtureData(bkmvDataPath: string): Promise<ParsedFixtureData> {
const content = await readFile(bkmvDataPath, 'utf-8');
const lines = content.split('\n').filter(line => line.trim().length > 0);
const result: ParsedFixtureData = {
business: null,
documents: [],
journalEntries: [],
accounts: [],
inventory: [],
rawRecords: {
a100: null,
b100: [],
b110: [],
c100: [],
d110: [],
d120: [],
m100: [],
z900: null,
},
};
// Parse each line based on record type
for (const line of lines) {
if (line.length < 4) continue;
// Only remove CRLF, not trailing spaces which might be significant
const cleanLine = line.replace(/\r?\n?$/, '');
const parseResult = parseRecordLine(cleanLine);
if (!parseResult.success) {
if (parseResult.error) {
// For debugging, log the line that caused the parsing error.
// eslint-disable-next-line no-console
console.error(`Failed to parse line: ${cleanLine}`, parseResult.error);
}
// Skip parsing errors for invalid lines
continue;
}
const { recordType, parsedRecord } = parseResult;
switch (recordType) {
case 'A100': {
result.rawRecords.a100 = parsedRecord as ReturnType<typeof parseA100>;
if (result.rawRecords.a100) {
result.business = {
businessId: result.rawRecords.a100.primaryIdentifier.toString(), // Convert number to string
name: 'Unknown Business', // Dummy value to satisfy schema
taxId: result.rawRecords.a100.vatId,
reportingPeriod: {
startDate: '1900-01-01', // Dummy value to satisfy schema
endDate: '1900-12-31', // Dummy value to satisfy schema
},
};
}
break;
}
case 'B100': {
const b100 = parsedRecord as ReturnType<typeof parseB100>;
result.rawRecords.b100.push(b100);
result.journalEntries.push({
id: `JE_${b100.transactionNumber}`,
date: b100.date.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3'),
amount: b100.transactionAmount, // Use transactionAmount as-is since it already contains the correct sign
accountId: b100.accountKey,
description: b100.details, // Preserve exact original details (including empty strings)
transactionNumber: b100.transactionNumber,
transactionLineNumber: b100.transactionLineNumber,
batchNumber: b100.batchNumber,
transactionType: b100.transactionType,
referenceDocument: b100.referenceDocument,
referenceDocumentType: b100.referenceDocumentType,
referenceDocument2: b100.referenceDocument2,
referenceDocumentType2: b100.referenceDocumentType2,
valueDate: b100.valueDate,
counterAccountKey: b100.counterAccountKey,
debitCreditIndicator: b100.debitCreditIndicator,
currencyCode: b100.currencyCode,
transactionAmount: b100.transactionAmount, // Preserve original transaction amount with sign
foreignCurrencyAmount: b100.foreignCurrencyAmount,
quantityField: b100.quantityField,
matchingField1: b100.matchingField1,
matchingField2: b100.matchingField2,
branchId: b100.branchId,
entryDate: b100.entryDate,
operatorUsername: b100.operatorUsername,
reserved: b100.reserved,
});
break;
}
case 'B110': {
const b110 = parsedRecord as ReturnType<typeof parseB110>;
result.rawRecords.b110.push(b110);
result.accounts.push({
id: b110.accountKey,
name: b110.accountName || '', // Preserve exact original account name (including empty strings)
sortCode: {
key: b110.trialBalanceCode || 'Other',
name: b110.trialBalanceCodeDescription || '', // Preserve exact original description (including empty strings)
},
address: {
street: b110.customerSupplierAddressStreet,
houseNumber: b110.customerSupplierAddressHouseNumber,
city: b110.customerSupplierAddressCity,
zip: b110.customerSupplierAddressZip,
country: b110.customerSupplierAddressCountry,
},
countryCode: b110.countryCode,
parentAccountKey: b110.parentAccountKey,
vatId: b110.supplierCustomerTaxId,
accountOpeningBalance: b110.accountOpeningBalance ?? 0,
totalDebits: b110.totalDebits,
totalCredits: b110.totalCredits,
accountingClassificationCode: b110.accountingClassificationCode?.toString(),
branchId: b110.branchId,
openingBalanceForeignCurrency: b110.openingBalanceForeignCurrency,
foreignCurrencyCode: b110.foreignCurrencyCode,
});
break;
}
case 'C100': {
const c100 = parsedRecord as ReturnType<typeof parseC100>;
result.rawRecords.c100.push(c100);
result.documents.push({
id: c100.documentId,
type: c100.documentType,
date: c100.documentIssueDate.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3'),
amount: parseFloat(c100.amountIncludingVat || '0'),
description: c100.customerName || 'Document',
});
break;
}
case 'D110':
result.rawRecords.d110.push(parsedRecord as ReturnType<typeof parseD110>);
break;
case 'D120':
result.rawRecords.d120.push(parsedRecord as ReturnType<typeof parseD120>);
break;
case 'M100': {
const m100 = parsedRecord as ReturnType<typeof parseM100>;
result.rawRecords.m100.push(m100);
result.inventory.push({
id: m100.internalItemCode,
name: m100.itemName,
quantity: parseInt(m100.totalStockOut || '0'),
});
break;
}
case 'Z900':
result.rawRecords.z900 = parsedRecord as ReturnType<typeof parseZ900>;
break;
default:
// Should not reach here due to parseRecordLine filtering
break;
}
}
return result;
}
describe.skip('Fixture Files Round-trip Integration Test', () => {
it('should parse and validate the BKMVDATA.txt fixture file', async () => {
const bkmvDataPath = join(FIXTURES_DIR, 'BKMVDATA.txt');
const content = await readFile(bkmvDataPath, 'utf-8');
const firstLine = content.split('\n').find(line => line.trim().length > 0);
// Debug: Check the first line (should be A100)
expect(firstLine).toBeDefined();
if (!firstLine) {
throw new Error('The first non-empty line in BKMVDATA.txt is undefined.');
}
expect(firstLine.substring(0, 4)).toBe('A100');
// Try to parse the A100 line directly (only removing CRLF, not internal spaces)
const cleanLine = firstLine.replace(/\r?\n?$/, '').replace(/\r$/, '');
const a100 = parseA100(cleanLine);
expect(a100).toBeDefined();
expect(a100.vatId).toBeTruthy();
const parsedData = await parseFixtureData(bkmvDataPath);
// Debug: Check what was parsed
expect(parsedData.rawRecords.a100).toBeDefined();
if (parsedData.rawRecords.a100) {
// Check that A100 has the expected fields
expect(parsedData.rawRecords.a100.vatId).toBeTruthy();
expect(parsedData.rawRecords.a100.primaryIdentifier).toBeTruthy();
}
// Validate that we parsed some data
expect(parsedData.business).toBeDefined();
expect(parsedData.business?.taxId).toBeTruthy(); // Check that we have some tax ID
expect(parsedData.business?.name).toBeTruthy();
// Check that we have various record types
expect(parsedData.rawRecords.a100).toBeDefined();
expect(parsedData.rawRecords.b100.length).toBeGreaterThan(0);
expect(parsedData.rawRecords.z900).toBeDefined();
// Validate some business logic
if (parsedData.rawRecords.z900) {
const totalDataRecords = parsedData.rawRecords.z900.totalRecords; // totalRecords is now a number
const actualDataRecords =
(parsedData.rawRecords.a100 ? 1 : 0) +
parsedData.rawRecords.b100.length +
parsedData.rawRecords.b110.length +
parsedData.rawRecords.c100.length +
parsedData.rawRecords.d110.length +
parsedData.rawRecords.d120.length +
parsedData.rawRecords.m100.length;
// The fixture may have more records than we parse, so just ensure we parsed some
expect(actualDataRecords).toBeGreaterThan(0);
expect(totalDataRecords).toBeGreaterThan(0);
}
// Validate parsed data structure
expect(parsedData.business).toBeDefined();
expect(parsedData.business?.taxId).toBe('516232675'); // Actual tax ID from fixture
expect(parsedData.business?.name).toBeTruthy();
});
it('should perform a complete round-trip with fixture data', async () => {
const bkmvDataPath = join(FIXTURES_DIR, 'BKMVDATA.txt');
const bkmvContent = await readFile(bkmvDataPath, 'utf-8');
const iniDataPath = join(FIXTURES_DIR, 'ini.txt');
const iniContent = await readFile(iniDataPath, 'utf-8');
const parsedData = await parseUniformFormatFiles(iniContent, bkmvContent);
// Skip test if we couldn't parse meaningful data
if (!parsedData.data.business) {
expect(
parsedData.data.business,
'Business data could not be parsed from fixture',
).toBeDefined();
return; // This line is for type-safety and will not be reached if the assertion fails.
}
// Generate new files from the parsed data
const generatedReport = generateUniformFormatReport(parsedData.data);
expect(generatedReport).toBeDefined();
expect(generatedReport.dataText).toBeDefined();
expect(generatedReport.iniText).toBeDefined();
// Parse the generated data back using the new parsing function
const reparsedData = parseUniformFormatFiles(generatedReport.iniText, generatedReport.dataText);
// Validate basic structure
expect(reparsedData.data.business).toBeDefined();
expect(reparsedData.data.business.taxId).toBe(parsedData.data.business.taxId);
// Validate that we preserved the data structure through round-trip
if (parsedData.data.journalEntries.length > 0) {
expect(reparsedData.data.journalEntries).toHaveLength(parsedData.data.journalEntries.length);
}
if (parsedData.data.accounts.length > 0) {
expect(reparsedData.data.accounts).toHaveLength(parsedData.data.accounts.length);
}
if (parsedData.data.documents.length > 0) {
expect(reparsedData.data.documents).toHaveLength(parsedData.data.documents.length);
}
if (parsedData.data.inventory.length > 0) {
expect(reparsedData.data.inventory).toHaveLength(parsedData.data.inventory.length);
}
// Validate round-trip completed successfully
expect(generatedReport.summary.totalRecords).toBeGreaterThan(0);
// DEEP DATA FILE CONTENT COMPARISON: Compare actual file content line by line
// The round-trip should produce nearly identical SHAAM records
const originalContent = await readFile(bkmvDataPath, 'utf-8');
const originalLines = originalContent.split('\n').filter(line => line.trim().length > 0);
const generatedLines = generatedReport.dataText.split('\r\n').filter(line => line.length > 0);
// Both files should have the same number of data record lines
expect(generatedLines.length).toBe(originalLines.length);
// Compare each line - any differences should be investigated
for (let i = 0; i < originalLines.length; i++) {
const originalLine = originalLines[i].replace(/\r$/, ''); // Remove CR if present
const generatedLine = generatedLines[i];
if (originalLine.length < 4) continue;
const recordType = originalLine.substring(0, 4);
// Normalize both lines by masking variable fields that are expected to differ
const normalizedOriginal = normalizeLineForComparison(originalLine, recordType);
const normalizedGenerated = normalizeLineForComparison(generatedLine, recordType);
if (normalizedOriginal === normalizedGenerated) {
// Perfect match after normalization - continue
continue;
}
// Found a mismatch - provide detailed information
const maxLen = Math.max(normalizedOriginal.length, normalizedGenerated.length);
let differenceReport = `\nDATA FILE ${recordType} record ${i + 1} mismatch after normalization:\n`;
differenceReport += `Original: "${originalLine}"\n`;
differenceReport += `Generated: "${generatedLine}"\n`;
differenceReport += `Normalized Original: "${normalizedOriginal}"\n`;
differenceReport += `Normalized Generated: "${normalizedGenerated}"\n`;
differenceReport += `Original length: ${normalizedOriginal.length}, Generated length: ${normalizedGenerated.length}\n`;
// Show character-by-character differences in normalized versions
for (let j = 0; j < maxLen; j++) {
const origChar = normalizedOriginal[j] || '∅';
const genChar = normalizedGenerated[j] || '∅';
if (origChar !== genChar) {
differenceReport += ` Position ${j}: "${origChar}" vs "${genChar}"\n`;
}
}
// Fail with detailed report
throw new Error(differenceReport);
}
// DEEP INI FILE CONTENT COMPARISON: Compare INI file structure and validate generated content
const iniPath = join(FIXTURES_DIR, 'ini.txt');
const originalIniContent = await readFile(iniPath, 'utf-8');
const originalIniLines = originalIniContent.split('\n').filter(line => line.trim().length > 0);
const generatedIniLines = generatedReport.iniText
.split('\r\n')
.filter(line => line.trim().length > 0);
// Validate that both INI files have at least the A000 header
expect(generatedIniLines.length).toBeGreaterThanOrEqual(1);
expect(originalIniLines.length).toBeGreaterThanOrEqual(1);
// Both INI files should start with A000 header
expect(generatedIniLines[0].startsWith('A000')).toBe(true);
expect(originalIniLines[0].startsWith('A000')).toBe(true);
// Parse the A000 headers from both files for comparison
const originalA000Line = originalIniLines[0].replace(/\r$/, '');
const generatedA000Line = generatedIniLines[0];
// Normalize and compare A000 headers
const normalizedOriginalA000 = normalizeIniLineForComparison(originalA000Line, 'A000');
const normalizedGeneratedA000 = normalizeIniLineForComparison(generatedA000Line, 'A000');
if (normalizedOriginalA000 !== normalizedGeneratedA000) {
const maxA000Len = Math.max(normalizedOriginalA000.length, normalizedGeneratedA000.length);
let a000DifferenceReport = `\nINI FILE A000 header mismatch after normalization:\n`;
a000DifferenceReport += `Original: "${originalA000Line}"\n`;
a000DifferenceReport += `Generated: "${generatedA000Line}"\n`;
a000DifferenceReport += `Normalized Original: "${normalizedOriginalA000}"\n`;
a000DifferenceReport += `Normalized Generated: "${normalizedGeneratedA000}"\n`;
a000DifferenceReport += `Original length: ${normalizedOriginalA000.length}, Generated length: ${normalizedGeneratedA000.length}\n`;
// Show character-by-character differences in normalized versions
for (let j = 0; j < maxA000Len; j++) {
const origA000Char = normalizedOriginalA000[j] || '∅';
const genA000Char = normalizedGeneratedA000[j] || '∅';
if (origA000Char !== genA000Char) {
a000DifferenceReport += ` Position ${j}: "${origA000Char}" vs "${genA000Char}"\n`;
}
}
throw new Error(a000DifferenceReport);
}
// Parse and validate A000Sum records (summary lines) - these should be consistent with the actual data
const generatedSummaryLines = generatedIniLines.slice(1); // Skip A000 header
// Create map of record type to count for generated INI
const generatedSummaryCounts = new Map<string, number>();
// Parse generated summary lines
for (const line of generatedSummaryLines) {
if (line.length === 19) {
const recordType = line.substring(0, 4);
const count = parseInt(line.substring(4, 19), 10);
generatedSummaryCounts.set(recordType, count);
}
}
// Validate that generated summary counts make sense with actual parsed data
// Note: We don't require exact match with original because parsed data might differ
// but we validate that the generated counts are consistent with what we actually have
// Check that if we have data for a record type, we have a summary line for it
if (parsedData.summary.perType.b100 > 0) {
expect(generatedSummaryCounts.has('B100')).toBe(true);
expect(generatedSummaryCounts.get('B100')).toBeGreaterThan(0);
}
if (parsedData.summary.perType.b110 > 0) {
expect(generatedSummaryCounts.has('B110')).toBe(true);
expect(generatedSummaryCounts.get('B110')).toBeGreaterThan(0);
}
if (parsedData.summary.perType.c100 > 0) {
expect(generatedSummaryCounts.has('C100')).toBe(true);
expect(generatedSummaryCounts.get('C100')).toBeGreaterThan(0);
}
// Validate that summary line format is correct for generated lines
for (const line of generatedSummaryLines) {
expect(line.length).toBe(19); // A000Sum records should be exactly 19 characters
expect(line.match(/^[A-Z]\d{3}\d{15}$/)).toBeTruthy(); // Should match pattern: RecordType + 15-digit count
}
});
/**
* Normalizes a SHAAM record line for comparison by replacing variable fields
* with consistent placeholders
*/
function normalizeLineForComparison(line: string, recordType: string): string {
if (line.length < 4) return line;
switch (recordType) {
case 'A100':
// A100: Replace primary identifier (pos 22-36) - generated ad-hoc per file
return (
line.substring(0, 22) +
'XXXXXXXXXXXXXXX' + // primary identifier placeholder (15 chars)
line.substring(37)
);
case 'Z900':
// Z900: Replace primary identifier and total records count
return (
line.substring(0, 22) +
'XXXXXXXXXXXXXXX' + // primary identifier placeholder (15 chars)
line.substring(37)
);
case 'B110':
// B110: Normalize supplier customer tax ID field (pos 326-334) to handle formatting differences
// Original might have "14216790 " while generated has "014216790"
if (line.length >= 335) {
const taxIdField = line.substring(326, 335); // 9 chars
const normalizedTaxId = taxIdField.trim().replace(/^0+/, '').padEnd(9, ' ');
return line.substring(0, 326) + normalizedTaxId + line.substring(335);
}
return line;
default:
return line;
}
}
/**
* Normalizes an INI file line for comparison by replacing variable fields
* with consistent placeholders
*/
function normalizeIniLineForComparison(line: string, recordType: string): string {
if (line.length < 4) return line;
switch (recordType) {
case 'A000':
// A000: This is a complex header record with many variable fields
// We need to normalize most fields since they vary between original and generated
if (line.length >= 466) {
// Based on A000 record specification, normalize key variable fields:
// Field positions calculated based on the encoding order in a000.ts
return (
line.substring(0, 33) +
'XXXXXXXXXXXXXXX' + // pos 33-48: Primary Identifier (15 chars) - varies
line.substring(48, 56) +
'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' + // pos 56-133: Software information (77 chars) - varies
line.substring(133, 134) + // Keep pos 133: softwareType
'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' + // pos 134-184: Saved files path (50 chars) - varies
line.substring(184, 384) + // Keep pos 184-384
'XXXXXXXXXX' + // pos 384-394: Execution date and time - varies
line.substring(394, 396) + // Keep pos 394-396: Language and encoding - should be similar
'XXXXXXXXXXXXXXXXXXXX' + // pos 396-416: Compression software (20 chars) - varies
line.substring(416) // Keep the rest - should be similar
);
}
return line;
case 'A000Sum':
// A000Sum: These are count records, but the counts should match exactly
// No normalization needed - counts should be identical
return line;
default:
return line;
}
}
it('should validate the ini.txt fixture file structure', async () => {
const iniPath = join(FIXTURES_DIR, 'ini.txt');
const iniContent = await readFile(iniPath, 'utf-8');
expect(iniContent).toBeDefined();
expect(iniContent.length).toBeGreaterThan(0);
// Check basic structure - should start with A000 record
const lines = iniContent.split('\n').filter(line => line.trim().length > 0);
expect(lines.length).toBeGreaterThan(0);
expect(lines[0].startsWith('A000')).toBe(true);
// Should contain summary records (A000Sum format: B1, B11, C10, D11, D12, M10)
const summaryLines = lines.filter(line => line.match(/^[BCDM]1[0-9]/));
expect(summaryLines.length).toBeGreaterThan(0);
// Validate INI file structure
expect(summaryLines.length).toBeGreaterThan(0);
expect(lines[0].startsWith('A000')).toBe(true);
});
it('should perform a complete round-trip with ini.txt fixture file', async () => {
const iniPath = join(FIXTURES_DIR, 'ini.txt');
const iniContent = await readFile(iniPath, 'utf-8');
const lines = iniContent.split('\n').filter(line => line.trim().length > 0);
// Parse the INI file structure
let a000Record: ReturnType<typeof parseA000> | null = null;
const a000SumRecords: ReturnType<typeof parseA000Sum>[] = [];
for (const line of lines) {
if (line.length < 4) continue;
const cleanLine = line.replace(/\r?\n?$/, '').replace(/\r$/, '');
// Check line length to determine record type
if (cleanLine.length === 466) {
// A000 header record
const recordType = cleanLine.slice(0, 4);
try {
if (recordType === 'A000') {
a000Record = parseA000(cleanLine);
}
} catch (error) {
// Re-throwing the error will make the test fail with a descriptive message.
throw new Error(`Failed to parse A000 line: ${cleanLine}`, { cause: error });
}
} else if (cleanLine.length === 19) {
// A000Sum record (record type summary)
try {
a000SumRecords.push(parseA000Sum(cleanLine));
} catch (error) {
// Re-throwing the error will make the test fail with a descriptive message.
throw new Error(`Failed to parse A000Sum line: ${cleanLine}`, { cause: error });
}
} else {
throw new Error(
`Unknown line length ${cleanLine.length}, line: ${cleanLine.slice(0, 20)}...`,
);
}
}
// Validate that we parsed the A000 header record
expect(a000Record).toBeDefined();
expect(a000Record?.vatId).toBeTruthy();
expect(a000Record?.totalRecords).toBeTruthy();
expect(a000Record?.primaryIdentifier).toBeTruthy();
// Validate that we parsed summary records
expect(a000SumRecords.length).toBeGreaterThan(0);
// Verify that we have summary records for different record types
const summarizedRecordTypes = a000SumRecords.map(record => record.code);
// Use actual record types found in the INI file instead of hardcoded expectations
expect(summarizedRecordTypes.length).toBeGreaterThan(0);
// Now test round-trip by getting corresponding BKMVDATA and generating new INI
const bkmvDataPath = join(FIXTURES_DIR, 'BKMVDATA.txt');
const parsedData = await parseFixtureData(bkmvDataPath);
if (parsedData.business) {
// Create ReportInput from parsed BKMV data
// Note: This is a simplified round-trip test using minimal data
const reportInput = {
business: parsedData.business,
documents: parsedData.documents,
journalEntries: parsedData.journalEntries,
accounts: parsedData.accounts,
inventory: parsedData.inventory,
} as ReportInput;
// Generate new files from the parsed data
const generatedReport = generateUniformFormatReport(reportInput);
expect(generatedReport.iniText).toBeDefined();
// Parse the generated INI content
const generatedIniLines = generatedReport.iniText
.split('\r\n')
.filter(line => line.trim().length > 0);
// Validate structure of generated INI
expect(generatedIniLines.length).toBeGreaterThan(0);
expect(generatedIniLines[0].startsWith('A000')).toBe(true);
// Parse the generated A000 record
const generatedA000Line = generatedIniLines.find(line => line.startsWith('A000'));
expect(generatedA000Line).toBeDefined();
const generatedA000 = parseA000(generatedA000Line!);
expect(generatedA000.vatId).toBe(parsedData.business.taxId);
// The generated file may have fewer records than the original fixture
// since we're only using the parsed business data structure
// Just validate that the structure is correct
const generatedTotalRecords = parseInt(generatedA000.totalRecords);
expect(generatedTotalRecords).toBeGreaterThanOrEqual(0);
// Validate that generated INI has summary records (they are 19 chars, not matched by record code pattern)
const generatedSummaryLines = generatedIniLines.filter(
line => line.replace(/\r?\n$/, '').length === 19,
);
expect(generatedSummaryLines.length).toBeGreaterThanOrEqual(0);
// Parse generated summary records and validate they have counts > 0
for (const summaryLine of generatedSummaryLines) {
const summaryRecord = parseA000Sum(summaryLine);
expect(parseInt(summaryRecord.recordCount)).toBeGreaterThan(0);
}
}
});
it('should handle edge cases in fixture data gracefully', async () => {
const bkmvDataPath = join(FIXTURES_DIR, 'BKMVDATA.txt');
const content = await readFile(bkmvDataPath, 'utf-8');
// Test with various line endings and whitespace
const normalizedContent = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const lines = normalizedContent.split('\n');
let validRecords = 0;
let invalidRecords = 0;
for (const line of lines) {
if (line.trim().length === 0) continue;
if (line.length < 4) {
invalidRecords++;
continue;
}
const parseResult = parseRecordLine(line);
if (parseResult.success) {
validRecords++;
} else {
invalidRecords++;
}
}
// Validate edge case handling results
expect(validRecords).toBeGreaterThan(0);
expect(validRecords).toBeGreaterThan(invalidRecords);
// We should be able to parse most records successfully
expect(validRecords).toBeGreaterThan(0);
expect(validRecords).toBeGreaterThan(invalidRecords);
});
it('should preserve monetary precision in round-trip', async () => {
const bkmvDataPath = join(FIXTURES_DIR, 'BKMVDATA.txt');
const parsedData = await parseFixtureData(bkmvDataPath);
if (parsedData.rawRecords.b100.length === 0) {
// Skip test for fixtures without B100 records
expect(parsedData.rawRecords.b100).toHaveLength(0);
return;
}
// Skip test if we don't have business data required for report generation
if (!parsedData.business) {
expect(parsedData.business, 'Business data could not be parsed from fixture').toBeDefined();
return;
}
// Get original monetary values from journal entries (already have correct sign applied)
const originalAmounts = parsedData.journalEntries.map((entry, index) => ({
amount: Math.abs(entry.amount),
debit: entry.amount >= 0 ? '1' : '2',
signedAmount: entry.amount,
index,
}));
// Validate that we can parse monetary values correctly from the fixture
expect(originalAmounts.length).toBeGreaterThan(0);
expect(originalAmounts[0].amount).toBeTruthy();
expect(['1', '2']).toContain(originalAmounts[0].debit);
// COMPLETE THE ROUND-TRIP: Generate new report from parsed data
const reportInput = {
business: parsedData.business,
documents: parsedData.documents,
journalEntries: parsedData.journalEntries,
accounts: parsedData.accounts,
inventory: parsedData.inventory,
} as ReportInput;
const generatedReport = generateUniformFormatReport(reportInput);
expect(generatedReport).toBeDefined();
expect(generatedReport.dataText).toBeDefined();
// Parse the generated data back to extract monetary values
const reparsedData = parseUniformFormatFiles(generatedReport.iniText, generatedReport.dataText);
expect(reparsedData.data.journalEntries).toBeDefined();
// Get monetary values from the re-parsed data
const reparsedAmounts = reparsedData.data.journalEntries.map((entry, index) => ({
amount: Math.abs(entry.amount),
debit: entry.amount >= 0 ? '1' : '2',
signedAmount: entry.amount,
index,
}));
// PRECISION VALIDATION: Compare original vs reparsed monetary values
expect(reparsedAmounts.length).toBe(originalAmounts.length);
// Test monetary precision preservation - the core test
for (let i = 0; i < originalAmounts.length; i++) {
const original = originalAmounts[i];
const reparsed = reparsedAmounts[i];
// Most important: verify the signed amounts match exactly (monetary precision)
expect(reparsed.signedAmount).toBe(original.signedAmount);
// Additional verification: absolute amounts match
expect(Math.abs(reparsed.signedAmount)).toBe(Math.abs(original.signedAmount));
}
// Additional validation: ensure total amounts are preserved
const originalTotal = originalAmounts.reduce((sum, amt) => sum + amt.signedAmount, 0);
const reparsedTotal = reparsedAmounts.reduce((sum, amt) => sum + amt.signedAmount, 0);
expect(reparsedTotal).toBe(originalTotal);
});
});