Files
tcp-ingestion/test/fixtures/_loader.ts
T
julian 381287bacc Implement Phase 1 tasks 1.5-1.7 + 1.9 (Codec 8/8E/16 parsers + fixture suite)
- Codec 8 parser (1-byte IO IDs, no NX/Generation Type)
- Codec 8 Extended parser (2-byte IO IDs + variable-length NX section)
- Codec 16 parser (mixed widths + Generation Type, supports IO IDs > 255)
- Shared GPS element / timestamp helpers in gps-element.ts
- Fixture loader with bigint/Buffer sentinel encoding and auto-discovery
- 12 fixture pairs across codec8/8E/16 (canonical doc + synthetic edge cases)
- Cross-checked Codec 8 against Traccar's TeltonikaProtocolDecoder (no discrepancies)

26 new tests. Total 62 passing across 10 test files.
typecheck/lint/test/build all clean.
2026-04-30 16:24:17 +02:00

243 lines
7.8 KiB
TypeScript

/**
* Fixture loader for codec parser tests.
*
* Each fixture is a pair of files:
* <name>.hex — hex-encoded AVL body (CodecID + N1 + records + N2)
* <name>.expected.json — parsed expected output as { positions, ack_record_count }
*
* Special JSON sentinel values:
* "__bigint:<decimal>" → BigInt(decimal)
* "__buffer_b64:<base64>" → 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<string, JsonAttributeValue>;
};
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<string, number | bigint | Buffer> = {};
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;
}