Files
beacon-web/tests/features/map/packet-path.test.ts
T

206 lines
9.1 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { buildPacketPaths, PATH_COLORS, packetPathsToFeatures, type PacketPath } from "../../../src/features/map/packet-path";
import type { Observation, PacketDetail, ResolvedHop } from "../../../src/types/api";
import { PayloadType } from "../../../src/types/enums";
function hop(id: string, lng?: number, lat?: number): ResolvedHop {
const nodes = lng != null && lat != null ? [{ id, publicKey: "pk", longitude: lng, latitude: lat }] : [];
return { confidence: nodes.length ? "high" : "none", nodes };
}
function obs(id: number, hops: ResolvedHop[], over: Partial<Observation> = {}): Observation {
return {
id, observerId: `observer-${id}`, iata: "YYZ", heardAt: 0,
pathLength: { raw: "", hashSize: 1, hopCount: hops.length },
sourceBroker: "b", resolvedPath: hops, ...over,
} as Observation;
}
function detail(observations: Observation[], over: Partial<PacketDetail> = {}): PacketDetail {
return { header: { payloadType: PayloadType.TEXT, routeType: 1 }, observations, ...over } as unknown as PacketDetail;
}
describe("buildPacketPaths", () => {
it("keys each path by observerId and carries propagation, fastest first", () => {
const d = detail([
obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-slow", observerName: "Slow", propagationTimeMs: 900 }),
obs(2, [hop("c", -80, 44), hop("d", -76, 46)], { observerId: "obs-fast", observerName: "Fast", propagationTimeMs: 100 }),
]);
const paths = buildPacketPaths(d);
expect(paths.map((p) => p.key)).toEqual(["obs-fast", "obs-slow"]); // fastest first
expect(paths[0]).toMatchObject({ key: "obs-fast", label: "Fast", propagationMs: 100, color: PATH_COLORS[0] });
expect(paths[1]).toMatchObject({ key: "obs-slow", propagationMs: 900, color: PATH_COLORS[1] }); // colors follow sort order
});
it("sorts observations with missing propagation after timed ones", () => {
const d = detail([
obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-none" }), // no propagation
obs(2, [hop("c", -80, 44), hop("d", -76, 46)], { observerId: "obs-fast", propagationTimeMs: 50 }),
]);
const keys = buildPacketPaths(d).map((p) => p.key);
expect(keys[0]).toBe("obs-fast");
expect(keys[keys.length - 1]).toBe("obs-none"); // missing propagation sorts last
});
it("prepends resolvedSource and appends resolvedDestination to the observation line", () => {
const d = detail([
obs(1, [hop("relay", -78, 44)], {
observerId: "obs-1", propagationTimeMs: 100,
resolvedSource: hop("src", -79, 43),
resolvedDestination: hop("dst", -77, 45),
}),
]);
const paths = buildPacketPaths(d);
expect(paths).toHaveLength(1);
expect(paths[0]!.points.map((p) => p.id)).toEqual(["src", "relay", "dst"]);
});
it("draws a source->destination line for a directed message with no relay hops", () => {
const d = detail([
obs(1, [], {
observerId: "obs-1", propagationTimeMs: 50,
resolvedSource: hop("src", -79, 43),
resolvedDestination: hop("dst", -77, 45),
}),
]);
const paths = buildPacketPaths(d);
expect(paths).toHaveLength(1);
expect(paths[0]!.points.map((p) => p.id)).toEqual(["src", "dst"]);
});
it("dedupes a resolvedSource that matches the first relay hop", () => {
const d = detail([
obs(1, [hop("src", -79, 43), hop("relay", -78, 44)], {
observerId: "obs-1", propagationTimeMs: 100,
resolvedSource: hop("src", -79, 43),
resolvedDestination: hop("dst", -77, 45),
}),
]);
const [path] = buildPacketPaths(d);
expect(path!.points.map((p) => p.id)).toEqual(["src", "relay", "dst"]);
});
it("skips an unresolved endpoint rather than drawing a misleading line", () => {
const d = detail([
obs(1, [hop("relay1", -79, 43), hop("relay2", -78, 44)], {
observerId: "obs-1", propagationTimeMs: 100,
resolvedSource: hop("src"), // unlocated — no coords
resolvedDestination: hop("dst", -77, 45),
}),
]);
const [path] = buildPacketPaths(d);
expect(path!.points.map((p) => p.id)).toEqual(["relay1", "relay2", "dst"]);
});
it("skips an ambiguous endpoint instead of guessing one of its candidates", () => {
// 1-byte source/dest prefixes resolve to several candidate nodes; the backend flags this
// "ambiguous" so the client shouldn't pick one and draw it as a definitive endpoint.
const ambiguousSource: ResolvedHop = {
confidence: "ambiguous",
nodes: [
{ id: "cand-a", publicKey: "pa", longitude: -71, latitude: 46 },
{ id: "cand-b", publicKey: "pb", longitude: -73, latitude: 48 },
],
};
const d = detail([
obs(1, [hop("relay1", -79, 43), hop("relay2", -78, 44)], {
observerId: "obs-1", propagationTimeMs: 100,
resolvedSource: ambiguousSource,
resolvedDestination: hop("dst", -77, 45), // high confidence — still drawn
}),
]);
const [path] = buildPacketPaths(d);
expect(path!.points.map((p) => p.id)).toEqual(["relay1", "relay2", "dst"]);
});
it("draws only the trace route for TRACE packets, suppressing per-observation lines", () => {
const d = detail(
[obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-1", propagationTimeMs: 100 })],
{
header: { payloadType: PayloadType.TRACE, routeType: 1 },
resolvedRoute: [hop("e", -81, 47), hop("f", -77, 48)],
} as unknown as Partial<PacketDetail>,
);
expect(buildPacketPaths(d).map((p) => p.key)).toEqual(["trace"]);
});
it("draws nothing for a TRACE with no resolved route", () => {
// per-observation lines are suppressed for TRACE, so without resolvedRoute there is nothing to draw
const d = detail(
[obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-1", propagationTimeMs: 100 })],
{ header: { payloadType: PayloadType.TRACE, routeType: 1 } } as unknown as Partial<PacketDetail>,
);
expect(buildPacketPaths(d)).toEqual([]);
});
it("uses the first located candidate for an ambiguous relay hop", () => {
const multi: ResolvedHop = {
confidence: "ambiguous",
nodes: [
{ id: "unlocated", publicKey: "p0" }, // no coords — skipped
{ id: "located", publicKey: "p1", longitude: -78, latitude: 44 }, // first with coords — used
],
};
const d = detail([
obs(1, [multi, hop("relay2", -77, 45)], { observerId: "obs-1", propagationTimeMs: 100 }),
]);
const [path] = buildPacketPaths(d);
expect(path!.points.map((p) => p.id)).toEqual(["located", "relay2"]);
});
it("omits observations that resolve to fewer than 2 located hops", () => {
const d = detail([
obs(1, [hop("a", -79, 43), hop("x")], { observerId: "obs-1" }),
obs(2, [hop("b", -80, 44), hop("c", -76, 46)], { observerId: "obs-2" }),
]);
expect(buildPacketPaths(d).map((p) => p.key)).toEqual(["obs-2"]);
});
it("returns empty when nothing is drawable", () => {
expect(buildPacketPaths(detail([obs(1, [hop("a", -79, 43)], { observerId: "obs-1" })]))).toEqual([]);
});
});
const P: PacketPath[] = [
{ key: "1", label: "A", color: "#111", points: [
{ id: "a", lng: -79, lat: 43 }, { id: "b", lng: -78, lat: 44 }, { id: "c", lng: -77, lat: 45 },
] },
{ key: "2", label: "B", color: "#222", points: [
{ id: "d", lng: -80, lat: 46 }, { id: "e", lng: -76, lat: 47 },
] },
];
describe("packetPathsToFeatures", () => {
it("emits one line per path and one point per hop, with the path color", () => {
const { lines, points, bounds } = packetPathsToFeatures(P, null);
expect(lines.features).toHaveLength(2);
expect(lines.features[0]!.properties).toEqual({ key: "1", color: "#111" });
expect(lines.features[0]!.geometry.coordinates).toEqual([[-79, 43], [-78, 44], [-77, 45]]);
expect(points.features).toHaveLength(5);
expect(bounds).toHaveLength(5);
});
it("marks first/last/middle hops as start/end/mid", () => {
const { points } = packetPathsToFeatures([P[0]!], null);
expect(points.features.map((f) => f.properties.endpoint)).toEqual(["start", "mid", "end"]);
expect(points.features[0]!.properties.label).toBe("a");
});
it("shows only the selected path when a key is given", () => {
const { lines, points } = packetPathsToFeatures(P, "2");
expect(lines.features.map((f) => f.properties.key)).toEqual(["2"]);
expect(points.features).toHaveLength(2);
});
it("carries the untruncated node identity as title", () => {
const path: PacketPath = { key: "k", label: "L", color: "#111", points: [
{ id: "abcdef123456", lng: -79, lat: 43 }, // no name -> title is the full id
{ id: "z", name: "Repeater North", lng: -78, lat: 44 }, // named -> title is the name
] };
const { points } = packetPathsToFeatures([path], null);
expect(points.features[0]!.properties.title).toBe("abcdef123456");
expect(points.features[0]!.properties.label).toBe("abcdef"); // label stays truncated for the map
expect(points.features[1]!.properties.title).toBe("Repeater North");
});
});