import { describe, it, expect } from "vitest"; import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, neighborFocusIds, buildFocusedNeighborEdges } from "../../../src/features/map/node-geojson"; import type { NodeSummary, NodeNeighbor } from "../../../src/features/nodes/types"; function node(overrides: Partial): NodeSummary { return { id: "n1", publicKey: "pk", nodeType: 1, nodeTypeName: "repeater", name: "Node 1", lat: 45, lng: -75, iatas: [], ...overrides, }; } describe("nodesToFeatureCollection", () => { it("returns an empty FeatureCollection for no nodes", () => { const fc = nodesToFeatureCollection([]); expect(fc.type).toBe("FeatureCollection"); expect(fc.features).toEqual([]); }); it("maps a located node to a Point feature in [lng, lat] order", () => { const fc = nodesToFeatureCollection([node({ lat: 45.3, lng: -75.6 })]); expect(fc.features).toHaveLength(1); expect(fc.features[0]!.geometry).toEqual({ type: "Point", coordinates: [-75.6, 45.3] }); }); it("keeps whole-degree coordinates intact (the API sends decimal degrees, never microdegrees)", () => { const fc = nodesToFeatureCollection([node({ lat: 51, lng: -114 })]); expect(fc.features[0]!.geometry.coordinates).toEqual([-114, 51]); }); it("carries id/name/nodeTypeName/isObserver as feature properties", () => { const fc = nodesToFeatureCollection([ node({ id: "abc", name: "Relay A", nodeType: 2, nodeTypeName: "sensor" }), ]); expect(fc.features[0]!.properties).toEqual({ id: "abc", name: "Relay A", nodeTypeName: "sensor", isObserver: false, }); }); it("defaults isObserver to false and reflects it when set", () => { const fc = nodesToFeatureCollection([ node({ id: "plain" }), node({ id: "obs", isObserver: true }), ]); expect(fc.features[0]!.properties.isObserver).toBe(false); expect(fc.features[1]!.properties.isObserver).toBe(true); }); it("drops nodes missing lat or lng", () => { const fc = nodesToFeatureCollection([ node({ id: "a", lat: null, lng: -75 }), node({ id: "b", lat: 45, lng: null }), node({ id: "c", lat: 45, lng: -75 }), ]); expect(fc.features.map((f) => f.properties.id)).toEqual(["c"]); }); it("keeps nodes at the 0/0 coordinate (0 is a valid coordinate)", () => { const fc = nodesToFeatureCollection([node({ id: "z", lat: 0, lng: 0 })]); expect(fc.features).toHaveLength(1); expect(fc.features[0]!.geometry.coordinates).toEqual([0, 0]); }); it("passes decimal coordinates through untouched (api/nodes.go sends *float64 degrees)", () => { const fc = nodesToFeatureCollection([node({ lat: 49.28, lng: -123.12 })]); expect(fc.features[0]!.geometry.coordinates).toEqual([-123.12, 49.28]); }); }); describe("buildNeighborEdges", () => { const a = node({ id: "a", lat: 45, lng: -75, neighborIds: ["b", "c"] }); const b = node({ id: "b", lat: 46, lng: -76, neighborIds: ["a"] }); const c = node({ id: "c", lat: 47, lng: -77, neighborIds: ["a"] }); it("emits one undirected edge per neighbor pair (a<->b counted once) in [lng, lat] order", () => { const fc = buildNeighborEdges([a, b], "on", null); expect(fc.type).toBe("FeatureCollection"); expect(fc.features).toHaveLength(1); expect(fc.features[0]!.geometry).toEqual({ type: "LineString", coordinates: [[-75, 45], [-76, 46]], }); }); it("skips neighbor ids absent from the set or without coordinates", () => { const lonely = node({ id: "a", lat: 45, lng: -75, neighborIds: ["ghost"] }); const noCoord = node({ id: "b", lat: null, lng: null, neighborIds: ["a"] }); expect(buildNeighborEdges([lonely, noCoord], "on", null).features).toEqual([]); }); it("in 'selected' mode keeps only edges incident to the selected node", () => { const fc = buildNeighborEdges([a, b, c], "selected", "b"); expect(fc.features).toHaveLength(1); // only a<->b expect(fc.features[0]!.properties.selected).toBe(true); }); it("in 'on' mode emits all edges and flags those incident to the selected node", () => { const fc = buildNeighborEdges([a, b, c], "on", "b"); expect(fc.features).toHaveLength(2); // a<->b and a<->c expect(fc.features.filter((f) => f.properties.selected)).toHaveLength(1); // a<->b }); it("ignores a node that lists itself as a neighbor (no zero-length edge)", () => { const selfRef = node({ id: "a", lat: 45, lng: -75, neighborIds: ["a", "b"] }); const fc = buildNeighborEdges([selfRef, b], "on", null); expect(fc.features).toHaveLength(1); // a<->b only, not a<->a }); }); describe("neighborFocusIds", () => { const a = node({ id: "a", lat: 45, lng: -75, neighborIds: ["b", "c"] }); const b = node({ id: "b", lat: 46, lng: -76, neighborIds: ["a"] }); const c = node({ id: "c", lat: 47, lng: -77, neighborIds: ["a"] }); const sorted = (ids: string[] | null) => (ids ? [...ids].sort() : ids); it("returns null when nothing is selected", () => { expect(neighborFocusIds([a, b, c], null)).toBeNull(); }); it("includes the selected node and its located neighbors", () => { expect(sorted(neighborFocusIds([a, b, c], "a"))).toEqual(["a", "b", "c"]); }); it("treats neighbor links as undirected (a node listing the selection counts)", () => { // b lists a; c does not list b, so only a<->b makes c irrelevant to b's focus set expect(sorted(neighborFocusIds([a, b, c], "b"))).toEqual(["a", "b"]); }); it("skips neighbor ids that are absent or unlocated", () => { const noCoord = node({ id: "c", lat: null, lng: null, neighborIds: ["a"] }); expect(sorted(neighborFocusIds([a, b, noCoord], "a"))).toEqual(["a", "b"]); }); it("returns null when the selected node is unlocated (no marker to keep lit)", () => { const unlocated = node({ id: "a", lat: null, lng: null, neighborIds: ["b"] }); expect(neighborFocusIds([unlocated, b], "a")).toBeNull(); }); it("returns null when the selected node has no located neighbors", () => { const lonely = node({ id: "x", lat: 45, lng: -75, neighborIds: ["ghost"] }); expect(neighborFocusIds([lonely, b], "x")).toBeNull(); }); }); describe("buildFocusedNeighborEdges", () => { const DAY = 86400000; const NOW = 1000 * DAY; const sel = node({ id: "a", lat: 45, lng: -75 }); function nb(o: Partial): NodeNeighbor { return { id: "b", publicKey: "pk", nodeType: 2, nodeTypeName: "repeater", iata: "YOW", observationCount: 10, firstSeen: 0, lastSeen: NOW, lat: 46, lng: -76, ...o }; } it("returns empty when nothing is selected", () => { expect(buildFocusedNeighborEdges(null, [nb({})], NOW).features).toEqual([]); }); it("returns empty when the selected node has no coordinates", () => { expect(buildFocusedNeighborEdges(node({ id: "a", lat: null, lng: null }), [nb({})], NOW).features).toEqual([]); }); it("draws one edge selected->neighbor with obs, in [lng, lat] order", () => { const fc = buildFocusedNeighborEdges(sel, [nb({ id: "b", lat: 46, lng: -76, observationCount: 42, lastSeen: NOW })], NOW); expect(fc.features).toHaveLength(1); expect(fc.features[0]!.geometry.coordinates).toEqual([[-75, 45], [-76, 46]]); expect(fc.features[0]!.properties.obs).toBe(42); expect(fc.features[0]!.properties.selected).toBe(true); expect(fc.features[0]!.properties.ageDays).toBe(0); }); it("computes ageDays from lastSeen relative to now", () => { const fc = buildFocusedNeighborEdges(sel, [nb({ id: "b", lastSeen: NOW - 3 * DAY })], NOW); expect(fc.features[0]!.properties.ageDays).toBeCloseTo(3); }); it("aggregates a neighbor's per-iata rows: sums obs, keeps the freshest lastSeen", () => { const fc = buildFocusedNeighborEdges(sel, [ nb({ id: "b", iata: "YOW", observationCount: 30, lastSeen: NOW - 5 * DAY }), nb({ id: "b", iata: "YYZ", observationCount: 12, lastSeen: NOW - 1 * DAY }), ], NOW); expect(fc.features).toHaveLength(1); expect(fc.features[0]!.properties.obs).toBe(42); expect(fc.features[0]!.properties.ageDays).toBeCloseTo(1); }); it("skips neighbors without coordinates and any self-referential row", () => { const fc = buildFocusedNeighborEdges(sel, [ nb({ id: "b", lat: undefined, lng: undefined }), nb({ id: "a", lat: 45, lng: -75 }), // self — no zero-length edge nb({ id: "c", lat: 47, lng: -77, observationCount: 5 }), ], NOW); expect(fc.features.map((f) => f.properties.obs)).toEqual([5]); }); }); describe("filterByNodeType", () => { const fc = nodesToFeatureCollection([ node({ id: "r1", nodeTypeName: "repeater" }), node({ id: "c1", nodeTypeName: "companion" }), node({ id: "r2", nodeTypeName: "repeater" }), ]); it("returns all features (same reference) for an empty type = All", () => { expect(filterByNodeType(fc, "")).toBe(fc); }); it("keeps only features matching the given type", () => { expect(filterByNodeType(fc, "repeater").features.map((f) => f.properties.id)).toEqual(["r1", "r2"]); }); it("returns an empty collection when nothing matches", () => { const out = filterByNodeType(fc, "sensor"); expect(out.type).toBe("FeatureCollection"); expect(out.features).toEqual([]); }); });