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