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

1126 lines
40 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { Address, Frame, Timestamp } from '../src/frame';
import { Payload, PositionPayload } from '../src/frame.types';
import { FieldType, PacketSegment, PacketStructure } from '../src/parser.types';
describe('parseAddress', () => {
it('should parse callsign without SSID', () => {
const result = Address.parse('NOCALL');
expect(result).toEqual({
call: 'NOCALL',
ssid: '',
isRepeated: false,
});
});
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,
});
});
});
describe('Frame.fromString', () => {
it('should parse 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('should parse 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('should parse 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).toEqual({
call: 'KB1ABC',
ssid: '5',
isRepeated: false,
});
expect(result.destination).toEqual({
call: 'APRS',
ssid: '',
isRepeated: false,
});
expect(result.path).toHaveLength(3);
expect(result.path[0]).toEqual({
call: 'WIDE1',
ssid: '1',
isRepeated: false,
});
expect(result.path[1]).toEqual({
call: 'WIDE2',
ssid: '2',
isRepeated: true,
});
expect(result.path[2]).toEqual({
call: 'IGATE',
ssid: '',
isRepeated: false,
});
expect(result.payload).toBe('!4903.50N/07201.75W-Test');
});
it('should parse frame with no path', () => {
const data = 'W1AW>APRS::STATUS:Testing';
const result = Frame.fromString(data);
expect(result.source).toEqual({
call: 'W1AW',
ssid: '',
isRepeated: false,
});
expect(result.destination).toEqual({
call: 'APRS',
ssid: '',
isRepeated: false,
});
expect(result.path).toHaveLength(0);
expect(result.payload).toBe(':STATUS:Testing');
});
it('should throw error for frame without route separator', () => {
const data = 'NOCALL-1>APRS';
expect(() => Frame.fromString(data)).toThrow('APRS: invalid frame, no route separator found');
});
it('should throw error for frame with invalid addresses', () => {
const data = 'NOCALL:payload';
expect(() => Frame.fromString(data)).toThrow('APRS: invalid addresses in route');
});
});
describe('Frame class', () => {
it('should return a Frame instance from Frame.fromString', () => {
const data = 'W1AW>APRS:>Status message';
const result = Frame.fromString(data);
expect(result).toBeInstanceOf(Object);
expect(typeof result.decode).toBe('function');
expect(typeof result.getDataTypeIdentifier).toBe('function');
});
it('should get correct data type identifier for position', () => {
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data);
expect(frame.getDataTypeIdentifier()).toBe('@');
});
it('should get correct data type identifier for Mic-E', () => {
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('should get correct data type identifier for message', () => {
const data = 'W1AW>APRS::KB1ABC-5 :Hello World';
const frame = Frame.fromString(data);
expect(frame.getDataTypeIdentifier()).toBe(':');
});
it('should get correct data type identifier for status', () => {
const data = 'W1AW>APRS:>Status message';
const frame = Frame.fromString(data);
expect(frame.getDataTypeIdentifier()).toBe('>');
});
it('should call decode method and return position data', () => {
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;
// The test vector actually decodes to position data now that we've implemented it
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
});
it('should handle various data type identifiers in decode', () => {
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);
// decode() returns null for now since implementations are TODO
expect(() => frame.decode()).not.toThrow();
}
});
describe('Position decoding', () => {
it('should decode position with timestamp and compressed format (test vector 1)', () => {
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');
if (decoded && decoded.type === 'position') {
expect(decoded.messaging).toBe(true);
expect(decoded.timestamp).toBeDefined();
expect(decoded.timestamp?.day).toBe(9);
expect(decoded.timestamp?.hours).toBe(23);
expect(decoded.timestamp?.minutes).toBe(45);
expect(decoded.timestamp?.format).toBe('DHM');
expect(decoded.timestamp?.zulu).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.position.altitude).toBeCloseTo(88132 * 0.3048, 1); // feet to meters
expect(decoded.position.comment).toContain('Hello World!');
}
});
it('should decode 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');
if (decoded && decoded.type === 'position') {
expect(decoded.messaging).toBe(false);
expect(decoded.timestamp).toBeUndefined();
// 49 degrees + 3.50 minutes = 49.0583
expect(decoded.position.latitude).toBeCloseTo(49 + 3.50/60, 3);
// -72 degrees - 1.75 minutes = -72.0292
expect(decoded.position.longitude).toBeCloseTo(-(72 + 1.75/60), 3);
expect(decoded.position.symbol?.table).toBe('/');
expect(decoded.position.symbol?.code).toBe('-');
expect(decoded.position.comment).toBe('Test message');
}
});
it('should decode uncompressed position with alternate symbol table (\\)', () => {
const data = String.raw`W1AW>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');
if (decoded && decoded.type === 'position') {
expect(decoded.position.symbol?.table).toBe('\\');
expect(decoded.position.comment).toBe('Test message');
}
});
it('should decode position with messaging capability', () => {
const data = 'W1AW>APRS:=4903.50N/07201.75W-';
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.timestamp).toBeUndefined();
}
});
it('should decode position with timestamp (HMS format)', () => {
const data = 'CALL>APRS:/234517h4903.50N/07201.75W>';
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(false);
expect(decoded.timestamp).toBeDefined();
expect(decoded.timestamp?.hours).toBe(23);
expect(decoded.timestamp?.minutes).toBe(45);
expect(decoded.timestamp?.seconds).toBe(17);
expect(decoded.timestamp?.format).toBe('HMS');
}
});
it('should extract altitude from comment', () => {
const data = 'CALL>APRS:!4903.50N/07201.75W>/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 handle southern and western hemispheres', () => {
const data = 'CALL>APRS:!3345.67S/15112.34E-';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// 33 degrees + 45.67 minutes = 33.7612 degrees South = -33.7612
expect(decoded.position.latitude).toBeLessThan(0);
expect(decoded.position.longitude).toBeGreaterThan(0);
expect(decoded.position.latitude).toBeCloseTo(-(33 + 45.67/60), 3);
// 151 degrees + 12.34 minutes = 151.2057 degrees East
expect(decoded.position.longitude).toBeCloseTo(151 + 12.34/60, 3);
}
});
it('should return null for invalid position data', () => {
const data = 'CALL>APRS:!invalid';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).toBeNull();
});
it('should handle position with ambiguity level 1 (1 space)', () => {
// Spaces mask the rightmost digits for privacy
// 4903.5 means ambiguity level 1 (±0.05 minute)
const data = 'CALL>APRS:!4903.5 N/07201.75W-';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.ambiguity).toBe(1);
// Spaces are replaced with 0 for parsing, so 4903.5 becomes 4903.50
expect(decoded.position.latitude).toBeCloseTo(49 + 3.5/60, 3);
}
});
it('should handle position with ambiguity level 2 (2 spaces)', () => {
// 4903. means ambiguity level 2 (±0.5 minutes) - spaces replace last 2 decimal digits
const data = 'CALL>APRS:!4903. N/07201.75W-';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.ambiguity).toBe(2);
expect(decoded.position.latitude).toBeCloseTo(49 + 3/60, 3);
}
});
it('should handle position with ambiguity level 1 in both lat and lon', () => {
// Both lat and lon have 1 space for ambiguity 1 (±0.05 minute)
const data = 'CALL>APRS:!4903.5 N/07201.7 W-';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.ambiguity).toBe(1);
expect(decoded.position.latitude).toBeCloseTo(49 + 3.5/60, 3);
expect(decoded.position.longitude).toBeCloseTo(-(72 + 1.7/60), 3);
}
});
it('should handle position with ambiguity level 2 in both lat and lon', () => {
// Both lat and lon have 2 spaces for ambiguity 2 (±0.5 minutes)
const data = 'CALL>APRS:!4903. N/07201. W-';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.ambiguity).toBe(2);
expect(decoded.position.latitude).toBeCloseTo(49 + 3/60, 3);
expect(decoded.position.longitude).toBeCloseTo(-(72 + 1/60), 3);
}
});
it('should have toDate method on timestamp', () => {
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position' && decoded.timestamp) {
expect(typeof decoded.timestamp.toDate).toBe('function');
const date = decoded.timestamp.toDate();
expect(date).toBeInstanceOf(Date);
expect(date.getUTCDate()).toBe(9);
expect(date.getUTCHours()).toBe(23);
expect(date.getUTCMinutes()).toBe(45);
}
});
});
describe('Object decoding', () => {
it('should decode 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);
expect(decoded.timestamp).toBeDefined();
expect(decoded.position).toBeDefined();
expect(typeof decoded.position.latitude).toBe('number');
expect(typeof decoded.position.longitude).toBe('number');
expect(decoded.position.comment).toBe('Test object');
}
});
it('should emit 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.payload?.type).toBe('object');
expect(result.structure.length).toBeGreaterThan(0);
const nameSection = result.structure.find((s) => s.name === 'object name');
expect(nameSection).toBeDefined();
const stateSection = result.structure.find((s) => s.name === 'object state');
expect(stateSection).toBeDefined();
const timestampSection = result.structure.find((s) => s.name === 'timestamp');
expect(timestampSection).toBeDefined();
const positionSection = result.structure.find((s) => s.name === 'position');
expect(positionSection).toBeDefined();
});
});
});
describe('Timestamp class', () => {
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);
}
});
});
describe('Mic-E decoding', () => {
describe('Basic Mic-E frames', () => {
it('should decode a basic Mic-E packet (current format)', () => {
// Destination: T2TQ5U encodes latitude ~42.3456N
// T=1, 2=2, T=1, Q=1, 5=5, U=1 (digits for latitude)
// Information field encodes longitude, speed, course, and symbols
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)', () => {
// Similar to above but with single quote (') data type identifier for old Mic-E
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('Latitude decoding from destination', () => {
it('should decode latitude from numeric digits (0-9)', () => {
// Destination: 123456 -> 12°34.56'N with specific message bits
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') {
// 12 degrees + 34.56 minutes = 12.576 degrees
expect(decoded.position.latitude).toBeCloseTo(12 + 34.56/60, 3);
}
});
it('should decode latitude from letter digits (A-J)', () => {
// A=0, B=1, C=2, D=3, E=4, F=5, G=6, H=7, I=8, J=9
// ABC0EF -> 012045 -> 01°20.45' (using 0 at position 3 for North)
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') {
// 01 degrees + 20.45 minutes = 1.340833 degrees North
expect(decoded.position.latitude).toBeCloseTo(1 + 20.45/60, 3);
}
});
it('should decode latitude with mixed digits and letters', () => {
// 4AB2DE -> 401234 -> 40°12.34'N (using 2 at position 3 for North)
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') {
// 40 degrees + 12.34 minutes
expect(decoded.position.latitude).toBeCloseTo(40 + 12.34/60, 3);
}
});
it('should decode latitude for southern hemisphere', () => {
// When messageBits[3] == 1, it's southern hemisphere
// For 'P' char at position 3 (msgBit=1 for south), combined appropriately
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') {
// Should be negative for south
expect(decoded.position.latitude).toBeLessThan(0);
}
});
});
describe('Longitude decoding from information field', () => {
it('should decode longitude from information field', () => {
// Mic-E info field bytes encode longitude degrees, minutes, hundredths
// Testing with constructed values
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');
// Longitude should be within valid range
expect(decoded.position.longitude).toBeGreaterThanOrEqual(-180);
expect(decoded.position.longitude).toBeLessThanOrEqual(180);
}
});
it('should handle eastern hemisphere longitude', () => {
// When messageBits[4] == 1, it's eastern hemisphere (positive)
// Need specific destination encoding for this
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') {
// Eastern hemisphere could be positive (depends on other flags)
expect(typeof decoded.position.longitude).toBe('number');
}
});
it('should handle longitude offset +100', () => {
// When messageBits[5] == 1, add 100 to longitude degrees
// Need 'P' or similar at position 5
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');
// Longitude offset should be applied (100+ degrees)
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') {
// Speed might be 0 or present
if (decoded.position.speed !== undefined) {
expect(decoded.position.speed).toBeGreaterThanOrEqual(0);
// Speed should be in km/h
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') {
// Course might be 0 or present
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', () => {
// When speed is 0, it should not be included in the 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') {
// Speed of 0 should not be set
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') {
// Course of 0 or >= 360 should not be set
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') {
// 1234 feet = 1234 * 0.3048 meters
expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1);
}
});
it('should decode altitude from base-91 format }abc', () => {
// Base-91 altitude format: }xyz where xyz is base-91 encoded altitude
// The altitude encoding is (altitude in feet + 10000) in base-91
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') {
// Base-91 altitude should be decoded (using valid base-91 characters)
// Even if altitude calculation results in negative value, it should be defined
if (decoded.position.comment?.startsWith('}')) {
// Altitude should be extracted from base-91 format
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') {
// Should use /A= format (5000 feet)
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('Message type decoding', () => {
it('should decode message type M0 (Off Duty)', () => {
// Message bits 0,1,2 = 0,0,0 -> M0: Off Duty
// Use digits 0-9 for message bit 0
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)', () => {
// Message bits 0,1,2 = 1,1,1 -> M7: Emergency
// Use letters A-J for message bit 1
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') {
// Should contain a message type
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') {
// Empty comment should still be defined but empty
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;
// Should fail because destination is too short
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;
// Should fail because payload is too short (needs at least 9 bytes with data type)
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;
// Should fail because destination contains invalid characters
expect(decoded).toBeNull();
});
it('should handle exceptions gracefully', () => {
// Malformed data that might cause exceptions
const data = 'CALL>4ABCDE:`\x00\x00\x00\x00\x00\x00\x00\x00';
const frame = Frame.fromString(data);
// Should not throw, just return null
expect(() => frame.decode()).not.toThrow();
const decoded = frame.decode() as Payload;
// Might be null or might decode with weird values
expect(decoded === null || decoded?.type === 'position').toBe(true);
});
});
describe('Real-world test vectors', () => {
it('should decode real Mic-E packet from test vector 2', () => {
// From the existing test: N83MZ>T2TQ5U with specific encoding
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();
// Verify reasonable coordinate ranges
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') {
// Mic-E always has messaging capability
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);
// Check routing section (capital R as implemented)
const routingSection = result.structure.find(s => s.name === 'Routing');
expect(routingSection).toBeDefined();
expect(routingSection?.fields).toBeDefined();
expect(routingSection?.fields?.length).toBeGreaterThan(0);
// Check for source and destination fields
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');
// Check if result has sections at top level
expect(result.structure).toBeDefined();
expect(result.structure?.length).toBeGreaterThan(0);
// Find position section
const positionSection = result.structure?.find(s => s.name === 'position');
expect(positionSection).toBeDefined();
expect(positionSection?.data.length).toBe(19); // Uncompressed position is 19 bytes
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;
// Result should be just the DecodedPayload, not an object with payload and sections
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); // Compressed position is 13 bytes
// Check for base91 encoded attributes
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');
});
});