diff --git a/test/frame.test.ts b/test/frame.test.ts index 98c1641..49d9c74 100644 --- a/test/frame.test.ts +++ b/test/frame.test.ts @@ -1,215 +1,79 @@ import { describe, expect, it } from 'vitest'; import { Address, Frame, Timestamp } from '../src/frame'; -import { Payload, PositionPayload } from '../src/frame.types'; +import type { Payload, PositionPayload, ObjectPayload, StatusPayload, ITimestamp } from '../src/frame.types'; import { FieldType, PacketSegment, PacketStructure } from '../src/parser.types'; -describe('parseAddress', () => { +// 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, - }); + 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, - }); + 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, - }); + 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, - }); + 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', () => { +// 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); - expect(typeof result.decode).toBe('function'); - expect(typeof result.getDataTypeIdentifier).toBe('function'); }); +}); - it('should get correct data type identifier for position', () => { +// 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('should get correct data type identifier for Mic-E', () => { + 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('should get correct data type identifier for message', () => { + it('returns : for message identifier', () => { 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', () => { + it('returns > for status identifier', () => { 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'); +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 in decode', () => { + 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: '=' }, @@ -226,266 +90,212 @@ it('should call decode method and return position data', () => { { 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); - } - }); +// 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!'); }); - 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; + 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'); + }); - expect(decoded).not.toBeNull(); - expect(decoded?.type).toBe('object'); + 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'); + }); - 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('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('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 }; + it('throws for frame without route separator', () => { + const data = 'NOCALL-1>APRS'; + expect(() => Frame.fromString(data)).toThrow('APRS: invalid frame, no route separator found'); + }); - 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(); - }); + it('throws for frame with invalid addresses', () => { + const data = 'NOCALL:payload'; + expect(() => Frame.fromString(data)).toThrow('APRS: invalid addresses in route'); }); }); -describe('Timestamp class', () => { +// 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 }); @@ -577,12 +387,11 @@ describe('Timestamp class', () => { }); }); -describe('Mic-E decoding', () => { +// 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)', () => { - // 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; @@ -602,7 +411,6 @@ describe('Mic-E decoding', () => { }); 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; @@ -612,9 +420,8 @@ describe('Mic-E decoding', () => { }); }); - describe('Latitude decoding from destination', () => { + describe('Frame.decodeLatitude', () => { 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; @@ -622,14 +429,11 @@ describe('Mic-E decoding', () => { 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; @@ -637,13 +441,11 @@ describe('Mic-E decoding', () => { 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; @@ -651,14 +453,11 @@ describe('Mic-E decoding', () => { 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; @@ -666,7 +465,6 @@ describe('Mic-E decoding', () => { expect(decoded).not.toBeNull(); if (decoded && decoded.type === 'position') { - // Should be negative for south expect(decoded.position.latitude).toBeLessThan(0); } }); @@ -674,8 +472,6 @@ describe('Mic-E decoding', () => { 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; @@ -684,15 +480,12 @@ describe('Mic-E decoding', () => { 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; @@ -700,14 +493,11 @@ describe('Mic-E decoding', () => { 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; @@ -716,7 +506,6 @@ describe('Mic-E decoding', () => { 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); } }); @@ -731,10 +520,8 @@ describe('Mic-E decoding', () => { 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'); } } @@ -748,7 +535,6 @@ describe('Mic-E decoding', () => { 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); @@ -757,7 +543,6 @@ describe('Mic-E decoding', () => { }); 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; @@ -765,7 +550,6 @@ describe('Mic-E decoding', () => { expect(decoded).not.toBeNull(); if (decoded && decoded.type === 'position') { - // Speed of 0 should not be set expect(decoded.position.speed).toBeUndefined(); } }); @@ -778,7 +562,6 @@ describe('Mic-E decoding', () => { 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); @@ -814,14 +597,11 @@ describe('Mic-E decoding', () => { 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; @@ -829,10 +609,7 @@ describe('Mic-E decoding', () => { 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(); } } @@ -846,7 +623,6 @@ describe('Mic-E decoding', () => { expect(decoded).not.toBeNull(); if (decoded && decoded.type === 'position') { - // Should use /A= format (5000 feet) expect(decoded.position.altitude).toBeCloseTo(5000 * 0.3048, 1); } }); @@ -865,10 +641,8 @@ describe('Mic-E decoding', () => { }); }); - describe('Message type decoding', () => { + describe('Frame.decodeMessage', () => { 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; @@ -881,8 +655,6 @@ describe('Mic-E decoding', () => { }); 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; @@ -890,7 +662,6 @@ describe('Mic-E decoding', () => { 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'); } @@ -931,7 +702,6 @@ describe('Mic-E decoding', () => { expect(decoded).not.toBeNull(); if (decoded && decoded.type === 'position') { - // Empty comment should still be defined but empty expect(decoded.position.comment).toBeDefined(); } }); @@ -943,7 +713,6 @@ describe('Mic-E decoding', () => { const frame = Frame.fromString(data); const decoded = frame.decode() as Payload; - // Should fail because destination is too short expect(decoded).toBeNull(); }); @@ -952,7 +721,6 @@ describe('Mic-E decoding', () => { 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(); }); @@ -961,26 +729,21 @@ describe('Mic-E decoding', () => { 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; @@ -995,7 +758,6 @@ describe('Mic-E decoding', () => { 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); } @@ -1011,7 +773,6 @@ describe('Mic-E decoding', () => { expect(decoded).not.toBeNull(); if (decoded && decoded.type === 'position') { - // Mic-E always has messaging capability expect(decoded.messaging).toBe(true); } }); @@ -1029,13 +790,11 @@ describe('Packet dissection with sections', () => { 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); @@ -1053,14 +812,12 @@ describe('Packet dissection with sections', () => { 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?.data.length).toBe(19); expect(positionSection?.fields).toBeDefined(); expect(positionSection?.fields?.length).toBeGreaterThan(0); }); @@ -1070,7 +827,6 @@ describe('Packet dissection with sections', () => { 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(); @@ -1103,9 +859,8 @@ describe('Packet dissection with sections', () => { const positionSection = result.structure?.find(s => s.name === 'position'); expect(positionSection).toBeDefined(); - expect(positionSection?.data.length).toBe(13); // Compressed position is 13 bytes + expect(positionSection?.data.length).toBe(13); - // Check for base91 encoded attributes const latAttr = positionSection?.fields?.find(a => a.name === 'latitude'); expect(latAttr).toBeDefined(); expect(latAttr?.type).toBe(FieldType.STRING); @@ -1123,3 +878,81 @@ describe('Packet dissection with sections', () => { 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'); + }); +});