Files
aprs.js/test/frame.test.ts
2026-03-11 17:59:02 +01:00

959 lines
34 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { Address, Frame, Timestamp } from '../src/frame';
import type { Payload, PositionPayload, ObjectPayload, StatusPayload, ITimestamp } from '../src/frame.types';
import { FieldType, PacketSegment, PacketStructure } from '../src/parser.types';
// Address parsing: split by method
describe('Address.parse', () => {
it('should parse callsign without SSID', () => {
const result = Address.parse('NOCALL');
expect(result).toEqual({ call: 'NOCALL', ssid: '', isRepeated: false });
});
});
describe('Address.fromString', () => {
it('should parse callsign with SSID', () => {
const result = Address.fromString('NOCALL-1');
expect(result).toEqual({ call: 'NOCALL', ssid: '1', isRepeated: false });
});
it('should parse repeated address', () => {
const result = Address.fromString('WA1PLE-4*');
expect(result).toEqual({ call: 'WA1PLE', ssid: '4', isRepeated: true });
});
it('should parse address without SSID but with repeat marker', () => {
const result = Address.fromString('WIDE1*');
expect(result).toEqual({ call: 'WIDE1', ssid: '', isRepeated: true });
});
});
// Frame constructor first
describe('Frame.constructor', () => {
it('returns a Frame instance from Frame.fromString', () => {
const data = 'W1AW>APRS:>Status message';
const result = Frame.fromString(data);
expect(result).toBeInstanceOf(Object);
});
});
// Frame properties / instance methods
describe('Frame.getDataTypeIdentifier', () => {
it('returns @ for position identifier', () => {
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data);
expect(frame.getDataTypeIdentifier()).toBe('@');
});
it('returns ` for Mic-E identifier', () => {
const data = 'N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&\'/\'"G:} KJ6TMS|!:&0\'p|!w#f!|3';
const frame = Frame.fromString(data);
expect(frame.getDataTypeIdentifier()).toBe('`');
});
it('returns : for message identifier', () => {
const data = 'W1AW>APRS::KB1ABC-5 :Hello World';
const frame = Frame.fromString(data);
expect(frame.getDataTypeIdentifier()).toBe(':');
});
it('returns > for status identifier', () => {
const data = 'W1AW>APRS:>Status message';
const frame = Frame.fromString(data);
expect(frame.getDataTypeIdentifier()).toBe('>');
});
});
describe('Frame.decode (basic)', () => {
it('should call decode and return position payload', () => {
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data);
const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
});
it('should handle various data type identifiers without throwing', () => {
const testCases = [
{ data: 'CALL>APRS:!4903.50N/07201.75W-', type: '!' },
{ data: 'CALL>APRS:=4903.50N/07201.75W-', type: '=' },
{ data: 'CALL>APRS:/092345z4903.50N/07201.75W>', type: '/' },
{ data: 'CALL>APRS:>Status Text', type: '>' },
{ data: 'CALL>APRS::ADDRESS :Message', type: ':' },
{ data: 'CALL>APRS:;OBJECT *092345z4903.50N/07201.75W>', type: ';' },
{ data: 'CALL>APRS:)ITEM!4903.50N/07201.75W-', type: ')' },
{ data: 'CALL>APRS:?APRS?', type: '?' },
{ data: 'CALL>APRS:T#001,123,456,789', type: 'T' },
{ data: 'CALL>APRS:_10090556c...', type: '_' },
{ data: 'CALL>APRS:$GPRMC,...', type: '$' },
{ data: 'CALL>APRS:<IGATE,MSG_CNT', type: '<' },
{ data: 'CALL>APRS:{01', type: '{' },
{ data: 'CALL>APRS:}W1AW>APRS:test', type: '}' },
];
for (const testCase of testCases) {
const frame = Frame.fromString(testCase.data);
expect(frame.getDataTypeIdentifier()).toBe(testCase.type);
expect(() => frame.decode()).not.toThrow();
}
});
});
// Static functions
describe('Frame.fromString', () => {
it('parses APRS position frame (test vector 1)', () => {
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const result = Frame.fromString(data);
expect(result.source).toEqual({ call: 'NOCALL', ssid: '1', isRepeated: false });
expect(result.destination).toEqual({ call: 'APRS', ssid: '', isRepeated: false });
expect(result.path).toHaveLength(1);
expect(result.path[0]).toEqual({ call: 'WIDE1', ssid: '1', isRepeated: false });
expect(result.payload).toBe('@092345z/:*E";qZ=OMRC/A=088132Hello World!');
});
it('parses APRS Mic-E frame with repeated digipeater (test vector 2)', () => {
const data = 'N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&\'/\'"G:} KJ6TMS|!:&0\'p|!w#f!|3';
const result = Frame.fromString(data);
expect(result.source).toEqual({ call: 'N83MZ', ssid: '', isRepeated: false });
expect(result.destination).toEqual({ call: 'T2TQ5U', ssid: '', isRepeated: false });
expect(result.path).toHaveLength(1);
expect(result.path[0]).toEqual({ call: 'WA1PLE', ssid: '4', isRepeated: true });
expect(result.payload).toBe('`c.l+@&\'/\'"G:} KJ6TMS|!:&0\'p|!w#f!|3');
});
it('parses frame with multiple path elements', () => {
const data = 'KB1ABC-5>APRS,WIDE1-1,WIDE2-2*,IGATE:!4903.50N/07201.75W-Test';
const result = Frame.fromString(data);
expect(result.source.call).toBe('KB1ABC');
expect(result.path).toHaveLength(3);
expect(result.payload).toBe('!4903.50N/07201.75W-Test');
});
it('parses frame with no path', () => {
const data = 'W1AW>APRS::STATUS:Testing';
const result = Frame.fromString(data);
expect(result.path).toHaveLength(0);
expect(result.payload).toBe(':STATUS:Testing');
});
it('throws for frame without route separator', () => {
const data = 'NOCALL-1>APRS';
expect(() => Frame.fromString(data)).toThrow('APRS: invalid frame, no route separator found');
});
it('throws for frame with invalid addresses', () => {
const data = 'NOCALL:payload';
expect(() => Frame.fromString(data)).toThrow('APRS: invalid addresses in route');
});
});
// Timestamp class (toDate / constructors)
describe('Timestamp.toDate', () => {
it('creates DHM timestamp and converts to Date', () => {
const ts = new Timestamp(14, 30, 'DHM', { day: 15, zulu: true });
expect(ts.hours).toBe(14);
expect(ts.minutes).toBe(30);
expect(ts.day).toBe(15);
const date = ts.toDate();
expect(date).toBeInstanceOf(Date);
expect(date.getUTCDate()).toBe(15);
});
it('creates HMS timestamp and converts to Date', () => {
const ts = new Timestamp(12, 45, 'HMS', { seconds: 30, zulu: true });
const date = ts.toDate();
expect(date).toBeInstanceOf(Date);
expect(date.getUTCHours()).toBe(12);
});
it('creates MDHM timestamp and converts to Date', () => {
const ts = new Timestamp(16, 20, 'MDHM', { month: 3, day: 5, zulu: false });
const date = ts.toDate();
expect(date).toBeInstanceOf(Date);
expect(date.getMonth()).toBe(2);
});
});
// Private decode functions (alphabetical order)
describe('Frame.decodeMicE', () => {
it('decodes a basic Mic-E packet (current format)', () => {
const data = 'N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&\'/\'"G:} KJ6TMS|!:&0\'p|!w#f!|3';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
});
it('decodes a Mic-E packet with old format (single quote)', () => {
const data = 'CALL>T2TQ5U:\'c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
});
});
describe('Frame.decodeMessage', () => {
it('decodes a standard APRS message with 9-char recipient field', () => {
const raw = 'W1AW>APRS::KB1ABC-5 :Hello World';
const frame = Frame.fromString(raw);
const decoded = frame.decode() as any;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('message');
expect(decoded?.to).toBe('KB1ABC-5');
expect(decoded?.text).toBe('Hello World');
});
it('emits recipient and text sections when emitSections is true', () => {
const raw = 'W1AW>APRS::KB1ABC :Test via spec';
const frame = Frame.fromString(raw);
const res = frame.decode(true) as { payload: any; structure: PacketStructure };
expect(res.payload).not.toBeNull();
expect(res.payload.type).toBe('message');
const recipientSection = res.structure.find(s => s.name === 'recipient');
const textSection = res.structure.find(s => s.name === 'text');
expect(recipientSection).toBeDefined();
expect(textSection).toBeDefined();
expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe('KB1ABC');
expect(new TextDecoder().decode(textSection!.data)).toBe('Test via spec');
});
});
describe('Frame.decodeObject', () => {
it('decodes object payload with uncompressed position', () => {
const data = 'CALL>APRS:;OBJECT *092345z4903.50N/07201.75W>Test object';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('object');
if (decoded && decoded.type === 'object') {
expect(decoded.name).toBe('OBJECT');
expect(decoded.alive).toBe(true);
}
});
it('emits object sections when emitSections is true', () => {
const data = 'CALL>APRS:;OBJECT *092345z4903.50N/07201.75W>Test object';
const frame = Frame.fromString(data);
const result = frame.decode(true) as { payload: Payload | null; structure: PacketStructure };
expect(result.payload).not.toBeNull();
expect(result.structure.length).toBeGreaterThan(0);
});
});
describe('Frame.decodePosition', () => {
it('decodes position with timestamp and compressed format', () => {
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data);
const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
});
it('decodes uncompressed position without timestamp', () => {
const data = 'KB1ABC>APRS:!4903.50N/07201.75W-Test message';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
});
it('handles ambiguity masking in position', () => {
const data = 'CALL>APRS:!4903.5 N/07201.75W-';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
});
});
describe('Frame.decodeStatus', () => {
it('decodes a status payload with timestamp and Maidenhead', () => {
const raw = 'N0CALL>APRS:>120912zTesting status FN20';
const frame = Frame.parse(raw);
const res = frame.decode(true) as { payload: Payload | null; structure: PacketStructure };
expect(res.payload).not.toBeNull();
if (res.payload?.type !== 'status') throw new Error('expected status payload');
const payload = res.payload as StatusPayload & { timestamp?: ITimestamp };
expect(payload.text).toBe('Testing status');
expect(payload.maidenhead).toBe('FN20');
});
});
// Packet dissection and sections
describe('Frame.decode (sections)', () => {
it('emits routing sections when emitSections is true', () => {
const data = 'KB1ABC-5>APRS,WIDE1-1,WIDE2-2*:!4903.50N/07201.75W-Test';
const frame = Frame.fromString(data);
const result = frame.decode(true) as { payload: Payload; structure: PacketStructure };
expect(result.structure.length).toBeGreaterThan(0);
});
it('emits position payload sections when emitSections is true', () => {
const data = 'CALL>APRS:!4903.50N/07201.75W-Test message';
const frame = Frame.fromString(data);
const result = frame.decode(true) as { payload: Payload; structure: PacketStructure };
expect(result.payload?.type).toBe('position');
});
});
describe('Timestamp.toDate', () => {
it('should create DHM timestamp and convert to Date', () => {
const ts = new Timestamp(14, 30, 'DHM', { day: 15, zulu: true });
expect(ts.hours).toBe(14);
expect(ts.minutes).toBe(30);
expect(ts.day).toBe(15);
expect(ts.format).toBe('DHM');
expect(ts.zulu).toBe(true);
const date = ts.toDate();
expect(date).toBeInstanceOf(Date);
expect(date.getUTCDate()).toBe(15);
expect(date.getUTCHours()).toBe(14);
expect(date.getUTCMinutes()).toBe(30);
});
it('should create HMS timestamp and convert to Date', () => {
const ts = new Timestamp(12, 45, 'HMS', { seconds: 30, zulu: true });
expect(ts.hours).toBe(12);
expect(ts.minutes).toBe(45);
expect(ts.seconds).toBe(30);
expect(ts.format).toBe('HMS');
const date = ts.toDate();
expect(date).toBeInstanceOf(Date);
expect(date.getUTCHours()).toBe(12);
expect(date.getUTCMinutes()).toBe(45);
expect(date.getUTCSeconds()).toBe(30);
});
it('should create MDHM timestamp and convert to Date', () => {
const ts = new Timestamp(16, 20, 'MDHM', { month: 3, day: 5, zulu: false });
expect(ts.hours).toBe(16);
expect(ts.minutes).toBe(20);
expect(ts.month).toBe(3);
expect(ts.day).toBe(5);
expect(ts.format).toBe('MDHM');
expect(ts.zulu).toBe(false);
const date = ts.toDate();
expect(date).toBeInstanceOf(Date);
expect(date.getMonth()).toBe(2); // 0-indexed
expect(date.getDate()).toBe(5);
expect(date.getHours()).toBe(16);
expect(date.getMinutes()).toBe(20);
});
it('should handle DHM timestamp that is in the future (use previous month)', () => {
const now = new Date();
const futureDay = now.getUTCDate() + 5;
const ts = new Timestamp(12, 0, 'DHM', { day: futureDay, zulu: true });
const date = ts.toDate();
// Should be in the past or very close to now
expect(date <= now).toBe(true);
});
it('should handle HMS timestamp that is in the future (use yesterday)', () => {
const now = new Date();
const futureHours = now.getUTCHours() + 2;
if (futureHours < 24) {
const ts = new Timestamp(futureHours, 0, 'HMS', { seconds: 0, zulu: true });
const date = ts.toDate();
// Should be in the past
expect(date <= now).toBe(true);
}
});
it('should handle MDHM timestamp that is in the future (use last year)', () => {
const now = new Date();
const futureMonth = now.getMonth() + 2;
if (futureMonth < 12) {
const ts = new Timestamp(12, 0, 'MDHM', {
month: futureMonth + 1,
day: 1,
zulu: false
});
const date = ts.toDate();
// Should be in the past
expect(date <= now).toBe(true);
}
});
});
// Private decode functions and related parsing tests (alphabetical grouping)
describe('Frame.decodeMicE', () => {
describe('Basic Mic-E frames', () => {
it('should decode a basic Mic-E packet (current format)', () => {
const data = 'N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&\'/\'"G:} KJ6TMS|!:&0\'p|!w#f!|3';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
if (decoded && decoded.type === 'position') {
expect(decoded.messaging).toBe(true);
expect(decoded.position).toBeDefined();
expect(typeof decoded.position.latitude).toBe('number');
expect(typeof decoded.position.longitude).toBe('number');
expect(decoded.position.symbol).toBeDefined();
expect(decoded.micE).toBeDefined();
expect(decoded.micE?.messageType).toBeDefined();
}
});
it('should decode a Mic-E packet with old format (single quote)', () => {
const data = 'CALL>T2TQ5U:\'c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
});
});
describe('Frame.decodeLatitude', () => {
it('should decode latitude from numeric digits (0-9)', () => {
const data = 'CALL>123456:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.latitude).toBeCloseTo(12 + 34.56/60, 3);
}
});
it('should decode latitude from letter digits (A-J)', () => {
const data = 'CALL>ABC0EF:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.latitude).toBeCloseTo(1 + 20.45/60, 3);
}
});
it('should decode latitude with mixed digits and letters', () => {
const data = 'CALL>4AB2DE:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.latitude).toBeCloseTo(40 + 12.34/60, 3);
}
});
it('should decode latitude for southern hemisphere', () => {
const data = 'CALL>4A0P0U:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.latitude).toBeLessThan(0);
}
});
});
describe('Longitude decoding from information field', () => {
it('should decode longitude from information field', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(typeof decoded.position.longitude).toBe('number');
expect(decoded.position.longitude).toBeGreaterThanOrEqual(-180);
expect(decoded.position.longitude).toBeLessThanOrEqual(180);
}
});
it('should handle eastern hemisphere longitude', () => {
const data = 'CALL>4ABPDE:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(typeof decoded.position.longitude).toBe('number');
}
});
it('should handle longitude offset +100', () => {
const data = 'CALL>4ABCDP:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(typeof decoded.position.longitude).toBe('number');
expect(Math.abs(decoded.position.longitude)).toBeGreaterThan(90);
}
});
});
describe('Speed and course decoding', () => {
it('should decode speed from information field', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}Speed test';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
if (decoded.position.speed !== undefined) {
expect(decoded.position.speed).toBeGreaterThanOrEqual(0);
expect(typeof decoded.position.speed).toBe('number');
}
}
});
it('should decode course from information field', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
if (decoded.position.course !== undefined) {
expect(decoded.position.course).toBeGreaterThanOrEqual(0);
expect(decoded.position.course).toBeLessThan(360);
}
}
});
it('should not include zero speed in result', () => {
const data = 'CALL>4ABCDE:`\x1c\x1c\x1c\x1c\x1c\x1c/>}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.speed).toBeUndefined();
}
});
it('should not include zero or 360+ course in result', () => {
const data = 'CALL>4ABCDE:`c.l\x1c\x1c\x1c/>}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
if (decoded.position.course !== undefined) {
expect(decoded.position.course).toBeGreaterThan(0);
expect(decoded.position.course).toBeLessThan(360);
}
}
});
});
describe('Symbol decoding', () => {
it('should decode symbol table and code', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.symbol).toBeDefined();
expect(decoded.position.symbol?.table).toBeDefined();
expect(decoded.position.symbol?.code).toBeDefined();
expect(typeof decoded.position.symbol?.table).toBe('string');
expect(typeof decoded.position.symbol?.code).toBe('string');
}
});
});
describe('Altitude decoding', () => {
it('should decode altitude from /A=NNNNNN format', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}/A=001234';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1);
}
});
it('should decode altitude from base-91 format }abc', () => {
const data = 'CALL>4AB2DE:`c.l+@&\'/\'"G:}}S^X';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
if (decoded.position.comment?.startsWith('}')) {
expect(decoded.position.altitude).toBeDefined();
}
}
});
it('should prefer /A= format over base-91 when both present', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}}!!!/A=005000';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.altitude).toBeCloseTo(5000 * 0.3048, 1);
}
});
it('should handle comment without altitude', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}Just a comment';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.altitude).toBeUndefined();
expect(decoded.position.comment).toContain('Just a comment');
}
});
});
describe('Frame.decodeMessage', () => {
it('should decode message type M0 (Off Duty)', () => {
const data = 'CALL>012345:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.micE?.messageType).toBe('M0: Off Duty');
}
});
it('should decode message type M7 (Emergency)', () => {
const data = 'CALL>ABCDEF:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.micE?.messageType).toBeDefined();
expect(typeof decoded.micE?.messageType).toBe('string');
}
});
it('should decode standard vs custom message indicator', () => {
const data = 'CALL>ABCDEF:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.micE?.isStandard).toBeDefined();
expect(typeof decoded.micE?.isStandard).toBe('boolean');
}
});
});
describe('Comment and telemetry', () => {
it('should extract comment from remaining data', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}This is a test comment';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.comment).toContain('This is a test comment');
}
});
it('should handle empty comment', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.comment).toBeDefined();
}
});
});
describe('Error handling', () => {
it('should return null for destination address too short', () => {
const data = 'CALL>SHORT:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).toBeNull();
});
it('should return null for payload too short', () => {
const data = 'CALL>4ABCDE:`short';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).toBeNull();
});
it('should return null for invalid destination characters', () => {
const data = 'CALL>4@BC#E:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).toBeNull();
});
it('should handle exceptions gracefully', () => {
const data = 'CALL>4ABCDE:`\x00\x00\x00\x00\x00\x00\x00\x00';
const frame = Frame.fromString(data);
expect(() => frame.decode()).not.toThrow();
const decoded = frame.decode() as Payload;
expect(decoded === null || decoded?.type === 'position').toBe(true);
});
});
describe('Real-world test vectors', () => {
it('should decode real Mic-E packet from test vector 2', () => {
const data = 'N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&\'/\'"G:} KJ6TMS|!:&0\'p|!w#f!|3';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
if (decoded && decoded.type === 'position') {
expect(decoded.messaging).toBe(true);
expect(decoded.position.latitude).toBeDefined();
expect(decoded.position.longitude).toBeDefined();
expect(decoded.position.symbol).toBeDefined();
expect(decoded.micE).toBeDefined();
expect(Math.abs(decoded.position.latitude)).toBeLessThanOrEqual(90);
expect(Math.abs(decoded.position.longitude)).toBeLessThanOrEqual(180);
}
});
});
describe('Messaging capability', () => {
it('should always set messaging to true for Mic-E', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.messaging).toBe(true);
}
});
});
});
describe('Packet dissection with sections', () => {
it('should emit routing sections when emitSections is true', () => {
const data = 'KB1ABC-5>APRS,WIDE1-1,WIDE2-2*:!4903.50N/07201.75W-Test';
const frame = Frame.fromString(data);
const result = frame.decode(true) as { payload: Payload; structure: PacketStructure };
expect(result).toHaveProperty('payload');
expect(result).toHaveProperty('structure');
expect(result.structure).toBeDefined();
expect(result.structure.length).toBeGreaterThan(0);
const routingSection = result.structure.find(s => s.name === 'Routing');
expect(routingSection).toBeDefined();
expect(routingSection?.fields).toBeDefined();
expect(routingSection?.fields?.length).toBeGreaterThan(0);
const sourceField = routingSection?.fields?.find(a => a.name === 'Source address');
expect(sourceField).toBeDefined();
expect(sourceField?.size).toBeGreaterThan(0);
const destField = routingSection?.fields?.find(a => a.name === 'Destination address');
expect(destField).toBeDefined();
expect(destField?.size).toBeGreaterThan(0);
});
it('should emit position payload sections when emitSections is true', () => {
const data = 'CALL>APRS:!4903.50N/07201.75W-Test message';
const frame = Frame.fromString(data);
const result = frame.decode(true) as { payload: Payload; structure: PacketStructure };
expect(result.payload).not.toBeNull();
expect(result.payload?.type).toBe('position');
expect(result.structure).toBeDefined();
expect(result.structure?.length).toBeGreaterThan(0);
const positionSection = result.structure?.find(s => s.name === 'position');
expect(positionSection).toBeDefined();
expect(positionSection?.data.length).toBe(19);
expect(positionSection?.fields).toBeDefined();
expect(positionSection?.fields?.length).toBeGreaterThan(0);
});
it('should not emit sections when emitSections is false or omitted', () => {
const data = 'CALL>APRS:!4903.50N/07201.75W-Test';
const frame = Frame.fromString(data);
const result = frame.decode() as Payload;
expect(result).not.toBeNull();
expect(result?.type).toBe('position');
expect((result as any).sections).toBeUndefined();
});
it('should emit timestamp section when present', () => {
const data = 'CALL>APRS:@092345z4903.50N/07201.75W>';
const frame = Frame.fromString(data);
const result = frame.decode(true) as { payload: Payload; structure: PacketStructure };
expect(result.payload?.type).toBe('position');
const timestampSection = result.structure?.find(s => s.name === 'timestamp');
expect(timestampSection).toBeDefined();
expect(timestampSection?.data.length).toBe(7);
expect(timestampSection?.fields?.map(a => a.name)).toEqual([
'day (DD)',
'hour (HH)',
'minute (MM)',
'timezone indicator',
]);
});
it('should emit compressed position sections', () => {
const data = 'NOCALL-1>APRS:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data);
const result = frame.decode(true) as { payload: Payload; structure: PacketStructure };
expect(result.payload?.type).toBe('position');
const positionSection = result.structure?.find(s => s.name === 'position');
expect(positionSection).toBeDefined();
expect(positionSection?.data.length).toBe(13);
const latAttr = positionSection?.fields?.find(a => a.name === 'latitude');
expect(latAttr).toBeDefined();
expect(latAttr?.type).toBe(FieldType.STRING);
});
it('should emit comment section', () => {
const data = 'CALL>APRS:!4903.50N/07201.75W-Test message';
const frame = Frame.fromString(data);
const result = frame.decode(true) as { payload: Payload; structure: PacketStructure };
expect(result.payload?.type).toBe('position');
const commentSection = result.structure?.find(s => s.name === 'comment');
expect(commentSection).toBeDefined();
expect(commentSection?.data.length).toBe('Test message'.length);
expect(commentSection?.fields?.[0]?.name).toBe('text');
});
});
describe('Frame.decodeMessage', () => {
it('decodes a standard APRS message with 9-char recipient field', () => {
const raw = 'W1AW>APRS::KB1ABC-5 :Hello World';
const frame = Frame.fromString(raw);
const decoded = frame.decode() as any;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('message');
expect(decoded?.to).toBe('KB1ABC-5');
expect(decoded?.text).toBe('Hello World');
});
it('emits recipient and text sections when emitSections is true', () => {
const raw = 'W1AW>APRS::KB1ABC :Test via spec';
const frame = Frame.fromString(raw);
const res = frame.decode(true) as { payload: any; structure: PacketStructure };
expect(res.payload).not.toBeNull();
expect(res.payload.type).toBe('message');
const recipientSection = res.structure.find(s => s.name === 'recipient');
const textSection = res.structure.find(s => s.name === 'text');
expect(recipientSection).toBeDefined();
expect(textSection).toBeDefined();
expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe('KB1ABC');
expect(new TextDecoder().decode(textSection!.data)).toBe('Test via spec');
});
});
describe('Frame.decoding: object and status', () => {
it('decodes an object payload (uncompressed position + comment)', () => {
const raw = 'N0CALL>APRS:;OBJECT001*120912z4903.50N/07201.75W>Test object';
const frame = Frame.parse(raw);
const res = frame.decode(true) as { payload: Payload | null; structure: PacketStructure };
expect(res).toHaveProperty('payload');
expect(res.payload).not.toBeNull();
if (res.payload?.type !== 'object') throw new Error('expected object payload');
const payload = res.payload as ObjectPayload & { timestamp?: ITimestamp };
expect(payload.name).toBe('OBJECT001');
expect(payload.alive).toBe(true);
expect(payload.timestamp).toBeDefined();
expect(payload.timestamp?.day).toBe(12);
expect(payload.timestamp?.hours).toBe(9);
expect(payload.timestamp?.minutes).toBe(12);
expect(payload.position).toBeDefined();
expect(payload.position.latitude).toBeCloseTo(49.058333, 4);
expect(payload.position.longitude).toBeCloseTo(-72.029166, 4);
expect(payload.position.comment).toBe('Test object');
});
it('decodes a status payload with timestamp and Maidenhead', () => {
const raw = 'N0CALL>APRS:>120912zTesting status FN20';
const frame = Frame.parse(raw);
const res = frame.decode(true) as { payload: Payload | null; structure: PacketStructure };
expect(res).toHaveProperty('payload');
expect(res.payload).not.toBeNull();
if (res.payload?.type !== 'status') throw new Error('expected status payload');
const payload = res.payload as StatusPayload & { timestamp?: ITimestamp };
expect(payload.type).toBe('status');
expect(payload.timestamp).toBeDefined();
expect(payload.timestamp?.day).toBe(12);
expect(payload.text).toBe('Testing status');
expect(payload.maidenhead).toBe('FN20');
});
});