import { describe, expect, it } from "vitest"; import { Address, Frame, Timestamp } from "../src/frame"; import type { Payload, PositionPayload, ObjectPayload, StatusPayload, ITimestamp, MessagePayload, } from "../src/frame.types"; import { Dissected, FieldType } from "@hamradio/packet"; // 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 MessagePayload; expect(decoded).not.toBeNull(); expect(decoded?.type).toBe("message"); expect(decoded?.addressee).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: MessagePayload | null; structure: Dissected; }; 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: ObjectPayload | null; structure: Dissected; }; 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 PositionPayload; 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 PositionPayload; 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: StatusPayload | null; structure: Dissected; }; 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: ObjectPayload | null; structure: Dissected; }; 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: PositionPayload | null; structure: Dissected; }; 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: Dissected; }; 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?.length).toBeGreaterThan(0); const destField = routingSection?.fields?.find( (a) => a.name === "Destination address", ); expect(destField).toBeDefined(); expect(destField?.length).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: Dissected; }; 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?.byteLength).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"); // eslint-disable-next-line @typescript-eslint/no-explicit-any 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: Dissected; }; expect(result.payload?.type).toBe("position"); const timestampSection = result.structure?.find( (s) => s.name === "timestamp", ); expect(timestampSection).toBeDefined(); expect(timestampSection?.data?.byteLength).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: Dissected; }; expect(result.payload?.type).toBe("position"); const positionSection = result.structure?.find( (s) => s.name === "position", ); expect(positionSection).toBeDefined(); expect(positionSection?.data?.byteLength).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: Dissected; }; expect(result.payload?.type).toBe("position"); const commentSection = result.structure?.find((s) => s.name === "comment"); expect(commentSection).toBeDefined(); expect(commentSection?.data?.byteLength).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 MessagePayload; expect(decoded).not.toBeNull(); expect(decoded?.type).toBe("message"); expect(decoded?.addressee).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: MessagePayload; structure: Dissected; }; 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: Dissected; }; 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: Dissected; }; 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"); }); });