mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-02 17:23:45 +00:00
206 lines
9.1 KiB
TypeScript
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");
|
|
});
|
|
});
|