381287bacc
- 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.
243 lines
7.8 KiB
TypeScript
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;
|
|
}
|