1032 lines
35 KiB
TypeScript
1032 lines
35 KiB
TypeScript
import { Dissected, FieldType } from "@hamradio/packet";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { Address, Frame, Timestamp } from "../src/frame";
|
|
import {
|
|
DataType,
|
|
type ITimestamp,
|
|
type MessagePayload,
|
|
MicEPayload,
|
|
type ObjectPayload,
|
|
type Payload,
|
|
type PositionPayload,
|
|
type StatusPayload
|
|
} from "../src/frame.types";
|
|
|
|
// Address parsing: split by method
|
|
describe("Address.parse", () => {
|
|
it("should parse callsign without SSID", () => {
|
|
const result = Address.parse("NOCALL");
|
|
expect(result).toEqual({ call: "NOCALL", ssid: "", isRepeated: false });
|
|
});
|
|
});
|
|
|
|
describe("Address.fromString", () => {
|
|
it("should parse callsign with SSID", () => {
|
|
const result = Address.fromString("NOCALL-1");
|
|
expect(result).toEqual({ call: "NOCALL", ssid: "1", isRepeated: false });
|
|
});
|
|
|
|
it("should parse repeated address", () => {
|
|
const result = Address.fromString("WA1PLE-4*");
|
|
expect(result).toEqual({ call: "WA1PLE", ssid: "4", isRepeated: true });
|
|
});
|
|
|
|
it("should parse address without SSID but with repeat marker", () => {
|
|
const result = Address.fromString("WIDE1*");
|
|
expect(result).toEqual({ call: "WIDE1", ssid: "", isRepeated: true });
|
|
});
|
|
});
|
|
|
|
// Frame constructor first
|
|
describe("Frame.constructor", () => {
|
|
it("returns a Frame instance from Frame.fromString", () => {
|
|
const data = "W1AW>APRS:>Status message";
|
|
const result = Frame.fromString(data);
|
|
expect(result).toBeInstanceOf(Object);
|
|
});
|
|
});
|
|
|
|
// Frame properties / instance methods
|
|
describe("Frame.getDataTypeIdentifier", () => {
|
|
it("returns @ for position identifier", () => {
|
|
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
|
|
const frame = Frame.fromString(data);
|
|
expect(frame.getDataTypeIdentifier()).toBe("@");
|
|
});
|
|
|
|
it("returns ` for Mic-E identifier", () => {
|
|
const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
|
|
const frame = Frame.fromString(data);
|
|
expect(frame.getDataTypeIdentifier()).toBe("`");
|
|
});
|
|
|
|
it("returns : for message identifier", () => {
|
|
const data = "W1AW>APRS::KB1ABC-5 :Hello World";
|
|
const frame = Frame.fromString(data);
|
|
expect(frame.getDataTypeIdentifier()).toBe(":");
|
|
});
|
|
|
|
it("returns > for status identifier", () => {
|
|
const data = "W1AW>APRS:>Status message";
|
|
const frame = Frame.fromString(data);
|
|
expect(frame.getDataTypeIdentifier()).toBe(">");
|
|
});
|
|
});
|
|
|
|
describe("Frame.decode (basic)", () => {
|
|
it("should call decode and return position payload", () => {
|
|
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
|
|
const frame = Frame.fromString(data);
|
|
const decoded = frame.decode() as PositionPayload;
|
|
expect(decoded).not.toBeNull();
|
|
expect(decoded?.type).toBe(DataType.PositionWithTimestampWithMessaging);
|
|
});
|
|
|
|
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 position frame without messaging (test vector 3)", () => {
|
|
const data = "N0CALL-7>APLRT1,qAO,DG2EAZ-10:!/4CkRP&-V>76Q";
|
|
const result = Frame.fromString(data);
|
|
expect(result.source).toEqual({
|
|
call: "N0CALL",
|
|
ssid: "7",
|
|
isRepeated: false
|
|
});
|
|
expect(result.destination).toEqual({
|
|
call: "APLRT1",
|
|
ssid: "",
|
|
isRepeated: false
|
|
});
|
|
expect(result.path).toHaveLength(2);
|
|
|
|
const payload = result.decode(false);
|
|
expect(payload).not.toBeNull();
|
|
});
|
|
|
|
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 MicEPayload;
|
|
expect(decoded).not.toBeNull();
|
|
expect(decoded?.type).toBe(DataType.MicECurrent);
|
|
});
|
|
|
|
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 MicEPayload;
|
|
expect(decoded).not.toBeNull();
|
|
expect(decoded?.type).toBe(DataType.MicEOld);
|
|
});
|
|
});
|
|
|
|
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(DataType.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(DataType.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 ObjectPayload;
|
|
expect(decoded).not.toBeNull();
|
|
expect(decoded?.type).toBe(DataType.Object);
|
|
if (decoded && decoded.type === DataType.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(DataType.PositionWithTimestampWithMessaging);
|
|
});
|
|
|
|
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(DataType.PositionNoTimestampNoMessaging);
|
|
});
|
|
|
|
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 !== DataType.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(DataType.PositionNoTimestampNoMessaging);
|
|
});
|
|
});
|
|
|
|
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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
expect(decoded?.type).toBe(DataType.MicECurrent);
|
|
|
|
if (decoded && decoded.type === DataType.MicECurrent) {
|
|
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.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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
expect(decoded?.type).toBe(DataType.MicEOld);
|
|
});
|
|
});
|
|
|
|
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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
|
|
if (decoded && decoded.type === DataType.MicECurrent) {
|
|
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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
|
|
if (decoded && decoded.type === DataType.MicECurrent) {
|
|
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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
|
|
if (decoded && decoded.type === DataType.MicECurrent) {
|
|
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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
|
|
if (decoded && decoded.type === DataType.MicECurrent) {
|
|
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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
|
|
if (decoded && decoded.type === DataType.MicECurrent) {
|
|
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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
|
|
if (decoded && decoded.type === DataType.MicECurrent) {
|
|
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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
|
|
if (decoded && decoded.type === DataType.MicECurrent) {
|
|
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 === DataType.MicECurrent) {
|
|
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 === DataType.MicECurrent) {
|
|
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 === DataType.MicECurrent) {
|
|
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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
|
|
if (decoded && decoded.type === DataType.MicECurrent) {
|
|
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 === DataType.MicECurrent) {
|
|
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 === DataType.MicECurrent) {
|
|
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 === DataType.MicECurrent) {
|
|
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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
|
|
if (decoded && decoded.type === DataType.MicECurrent) {
|
|
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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
|
|
if (decoded && decoded.type === DataType.MicECurrent) {
|
|
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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
|
|
if (decoded && decoded.type === DataType.MicECurrent) {
|
|
expect(decoded.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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
|
|
if (decoded && decoded.type === DataType.MicECurrent) {
|
|
expect(decoded.messageType).toBeDefined();
|
|
expect(typeof decoded.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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
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 === DataType.MicECurrent) {
|
|
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 === DataType.MicECurrent) {
|
|
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 MicEPayload;
|
|
expect(decoded === null || decoded?.type === DataType.MicECurrent).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 MicEPayload;
|
|
|
|
expect(decoded).not.toBeNull();
|
|
expect(decoded?.type).toBe(DataType.MicECurrent);
|
|
|
|
if (decoded && decoded.type === DataType.MicECurrent) {
|
|
expect(decoded.position.latitude).toBeDefined();
|
|
expect(decoded.position.longitude).toBeDefined();
|
|
expect(decoded.position.symbol).toBeDefined();
|
|
|
|
expect(Math.abs(decoded.position.latitude)).toBeLessThanOrEqual(90);
|
|
expect(Math.abs(decoded.position.longitude)).toBeLessThanOrEqual(180);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
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: PositionPayload;
|
|
structure: Dissected;
|
|
};
|
|
|
|
expect(result.payload).not.toBeNull();
|
|
expect(result.payload?.type).toBe(DataType.PositionNoTimestampNoMessaging);
|
|
|
|
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 PositionPayload;
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.type).toBe(DataType.PositionNoTimestampNoMessaging);
|
|
// 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: PositionPayload;
|
|
structure: Dissected;
|
|
};
|
|
|
|
expect(result.payload?.type).toBe(DataType.PositionWithTimestampWithMessaging);
|
|
|
|
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: PositionPayload;
|
|
structure: Dissected;
|
|
};
|
|
|
|
expect(result.payload?.type).toBe(DataType.PositionWithTimestampWithMessaging);
|
|
|
|
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: PositionPayload;
|
|
structure: Dissected;
|
|
};
|
|
|
|
expect(result.payload?.type).toBe(DataType.PositionNoTimestampNoMessaging);
|
|
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(DataType.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(DataType.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 !== DataType.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 !== DataType.Status) throw new Error("expected status payload");
|
|
|
|
const payload = res.payload as StatusPayload & { timestamp?: ITimestamp };
|
|
|
|
expect(payload.type).toBe(DataType.Status);
|
|
|
|
expect(payload.timestamp).toBeDefined();
|
|
expect(payload.timestamp?.day).toBe(12);
|
|
|
|
expect(payload.text).toBe("Testing status");
|
|
expect(payload.maidenhead).toBe("FN20");
|
|
});
|
|
});
|