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.
This commit is contained in:
Vendored
+242
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user