/** * Fixture loader for codec parser tests. * * Each fixture is a pair of files: * .hex — hex-encoded AVL body (CodecID + N1 + records + N2) * .expected.json — parsed expected output as { positions, ack_record_count } * * Special JSON sentinel values: * "__bigint:" → BigInt(decimal) * "__buffer_b64:" → Buffer.from(base64, 'base64') * * The loader returns typed fixture objects; the auto-discovery helper * `loadFixturesFromDir` reads all pairs in a directory so adding a new * fixture file automatically produces a new test. */ import fs from 'node:fs'; import path from 'node:path'; import type { Position } from '../../src/core/types.js'; // --------------------------------------------------------------------------- // JSON representation types (what lives on disk) // --------------------------------------------------------------------------- type JsonAttributeValue = number | string; // "__bigint:..." | "__buffer_b64:..." | number type JsonPosition = { readonly device_id: string; readonly timestamp: string; // ISO 8601 readonly latitude: number; readonly longitude: number; readonly altitude: number; readonly angle: number; readonly speed: number; readonly satellites: number; readonly priority: 0 | 1 | 2; readonly attributes: Record; }; type FixtureJson = { readonly positions: readonly JsonPosition[]; readonly ack_record_count: number; }; // --------------------------------------------------------------------------- // Sentinel decoders // --------------------------------------------------------------------------- function decodeAttributeValue(raw: JsonAttributeValue): number | bigint | Buffer { if (typeof raw === 'number') { return raw; } if (raw.startsWith('__bigint:')) { return BigInt(raw.slice('__bigint:'.length)); } if (raw.startsWith('__buffer_b64:')) { const b64 = raw.slice('__buffer_b64:'.length); return Buffer.from(b64, 'base64'); } throw new Error(`Unknown fixture sentinel value: ${JSON.stringify(raw)}`); } function decodePosition(json: JsonPosition): Position { const attributes: Record = {}; for (const [key, value] of Object.entries(json.attributes)) { attributes[key] = decodeAttributeValue(value); } return { device_id: json.device_id, timestamp: new Date(json.timestamp), latitude: json.latitude, longitude: json.longitude, altitude: json.altitude, angle: json.angle, speed: json.speed, satellites: json.satellites, priority: json.priority, attributes, }; } // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export type LoadedFixture = { readonly name: string; readonly body: Buffer; readonly expected: { readonly positions: readonly Position[]; readonly ack_record_count: number; }; }; // --------------------------------------------------------------------------- // Loader // --------------------------------------------------------------------------- /** * Loads a single fixture pair by base path (without extension). */ export function loadFixture(basePath: string): LoadedFixture { const hexPath = `${basePath}.hex`; const jsonPath = `${basePath}.expected.json`; const rawHex = fs.readFileSync(hexPath, 'utf8'); // Strip all non-hex characters (whitespace, newlines) const cleanHex = rawHex.replace(/[^0-9a-fA-F]/g, ''); const body = Buffer.from(cleanHex, 'hex'); const rawJson = fs.readFileSync(jsonPath, 'utf8'); const parsed = JSON.parse(rawJson) as FixtureJson; const positions = parsed.positions.map(decodePosition); return { name: path.basename(basePath), body, expected: { positions, ack_record_count: parsed.ack_record_count, }, }; } /** * Discovers all fixture pairs in a directory. * A "fixture pair" is a `.hex` file that also has a corresponding * `.expected.json` file with the same base name. * * Returns fixtures sorted by filename so test order is deterministic. */ export function loadFixturesFromDir(dirPath: string): readonly LoadedFixture[] { const entries = fs.readdirSync(dirPath); const hexFiles = entries .filter((f) => f.endsWith('.hex')) .sort(); return hexFiles.map((hexFile) => { const baseName = hexFile.slice(0, -'.hex'.length); return loadFixture(path.join(dirPath, baseName)); }); } // --------------------------------------------------------------------------- // Deep-equality comparator (bigint + Buffer aware) // --------------------------------------------------------------------------- /** * Compares two attribute values for equality. Handles number, bigint, and * Buffer types correctly (vitest's expect().toEqual does not handle Buffer * and bigint mixed comparisons out of the box in all edge cases). */ function attributeValuesEqual( actual: number | bigint | Buffer, expected: number | bigint | Buffer, ): boolean { if (Buffer.isBuffer(actual) && Buffer.isBuffer(expected)) { return actual.equals(expected); } if (typeof actual === 'bigint' && typeof expected === 'bigint') { return actual === expected; } if (typeof actual === 'number' && typeof expected === 'number') { return actual === expected; } return false; } /** * Deep-equality check between actual parsed positions and expected positions. * Returns a descriptive mismatch string on failure, or null on success. */ export function compareToExpected( actual: readonly Position[], expected: readonly Position[], ): string | null { if (actual.length !== expected.length) { return `Position count mismatch: got ${actual.length}, expected ${expected.length}`; } for (let i = 0; i < actual.length; i++) { const a = actual[i]; const e = expected[i]; if (a === undefined || e === undefined) { return `Undefined position at index ${i}`; } if (a.device_id !== e.device_id) { return `[${i}] device_id: got "${a.device_id}", expected "${e.device_id}"`; } if (a.timestamp.getTime() !== e.timestamp.getTime()) { return `[${i}] timestamp: got "${a.timestamp.toISOString()}", expected "${e.timestamp.toISOString()}"`; } if (a.latitude !== e.latitude) { return `[${i}] latitude: got ${a.latitude}, expected ${e.latitude}`; } if (a.longitude !== e.longitude) { return `[${i}] longitude: got ${a.longitude}, expected ${e.longitude}`; } if (a.altitude !== e.altitude) { return `[${i}] altitude: got ${a.altitude}, expected ${e.altitude}`; } if (a.angle !== e.angle) { return `[${i}] angle: got ${a.angle}, expected ${e.angle}`; } if (a.speed !== e.speed) { return `[${i}] speed: got ${a.speed}, expected ${e.speed}`; } if (a.satellites !== e.satellites) { return `[${i}] satellites: got ${a.satellites}, expected ${e.satellites}`; } if (a.priority !== e.priority) { return `[${i}] priority: got ${a.priority}, expected ${e.priority}`; } const actualKeys = Object.keys(a.attributes).sort(); const expectedKeys = Object.keys(e.attributes).sort(); if (JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys)) { return `[${i}] attribute keys mismatch: got [${actualKeys.join(', ')}], expected [${expectedKeys.join(', ')}]`; } for (const key of actualKeys) { const av = a.attributes[key]; const ev = e.attributes[key]; if (av === undefined || ev === undefined) { return `[${i}] attribute "${key}" undefined`; } if (!attributeValuesEqual(av, ev)) { return ( `[${i}] attribute "${key}": ` + `got ${Buffer.isBuffer(av) ? `Buffer<${av.toString('hex')}>` : String(av)}, ` + `expected ${Buffer.isBuffer(ev) ? `Buffer<${ev.toString('hex')}>` : String(ev)}` ); } } } return null; }