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: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'); }); });