Files
aprs.ts/test/frame.test.ts
2026-03-15 20:21:26 +01:00

1061 lines
35 KiB
TypeScript

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:<IGATE,MSG_CNT", type: "<" },
{ data: "CALL>APRS:{01", type: "{" },
{ data: "CALL>APRS:}W1AW>APRS:test", type: "}" },
];
for (const testCase of testCases) {
const frame = Frame.fromString(testCase.data);
expect(frame.getDataTypeIdentifier()).toBe(testCase.type);
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");
});
});