From 45127c91233d7d2a0c7cc11ac90fb0ee531871ed Mon Sep 17 00:00:00 2001 From: efiten Date: Fri, 16 Oct 2026 12:11:07 +0200 Subject: [PATCH 1/2] feat(server): /api/nodes/reported lists name-only repeaters with an estimated position A repeater that told a mobile companion its name (client_node_names) but never sent an advert has no nodes row and no position, so the UI could not show it anywhere. This endpoint lists every such repeater and adds an estimate when the receptions allow one (cmd/server/reported_nodes.go). - Receptions that count: discover/advert rows keyed by the full pubkey or its 8-byte prefix, and rxlog rows whose 2-3 byte path hash resolves uniquely to the repeater (lookupPrefixes). geo rows are left out. - Receptions within 100 m form one spot with the best SNR. - Estimate only from 3 spots at least 200 m apart: SNR-weighted centroid, weight 10^(snr/20), radius to the farthest spot, at least 500 m. - A repeater with an advert position in nodes or inactive_nodes is left out; blacklist and hidden name prefixes apply. - Cached 5 minutes, compute time logged. List capped at 500 repeaters. - Documented in openapi.go. Tests: spot merging, threshold, weighted centroid, radius and floor, the endpoint with discover/rxlog/geo rows, an ambiguous path hash, blacklist, hidden name, advert position, missing table. Full server suite passes. Co-Authored-By: Claude Opus 5.5 --- cmd/server/openapi.go | 1 + cmd/server/reported_nodes.go | 364 ++++++++++++++++++++++++++++++ cmd/server/reported_nodes_test.go | 221 ++++++++++++++++++ cmd/server/routes.go | 4 + 4 files changed, 590 insertions(+) create mode 100644 cmd/server/reported_nodes.go create mode 100644 cmd/server/reported_nodes_test.go diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 38c7286e8..3ae41e277 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -141,6 +141,7 @@ func routeDescriptions() map[string]routeMeta { {Name: "status", Description: "Filter by status (active/stale/offline)", Type: "string"}, }}, "GET /api/nodes/search": {Summary: "Search nodes", Description: "Search nodes by name or public key prefix.", Tag: "nodes", QueryParams: []paramMeta{{Name: "q", Description: "Search query", Type: "string", Required: true}}}, + "GET /api/nodes/reported": {Summary: "Repeaters known only from their reported name", Description: "Repeaters that told a mobile companion their own name (client_node_names, ANON_REQ_TYPE_OWNER) and have no advert position in nodes. Each carries its reception counts and, when companions heard it directly from at least `method.min_spots` spots at least `method.min_spread_m` apart, an `estimate`: a SNR-weighted centroid of those spots with `radius_m` (never below `method.min_radius_m`). The estimate is where companions stood, weighted, not a measured position. Cached for 5 minutes.", Tag: "nodes"}, "GET /api/nodes/bulk-health": {Summary: "Bulk node health", Description: "Returns health status for all nodes in one call.", Tag: "nodes"}, "GET /api/nodes/network-status": {Summary: "Network status summary", Description: "Returns counts of active, stale, and offline nodes.", Tag: "nodes"}, "GET /api/nodes/{pubkey}": {Summary: "Get node detail", Description: "Returns full detail for a single node by public key. For repeater/room nodes this includes the issue #672 usefulness axes + composite score/grade (see the Node schema).", Tag: "nodes", Response: schemaRef("NodeDetailResponse")}, diff --git a/cmd/server/reported_nodes.go b/cmd/server/reported_nodes.go new file mode 100644 index 000000000..a555f0375 --- /dev/null +++ b/cmd/server/reported_nodes.go @@ -0,0 +1,364 @@ +package main + +import ( + "database/sql" + "log" + "math" + "net/http" + "strings" + "sync" + "time" +) + +// Repeaters known only from client_node_names: a mobile companion asked them +// for their own name (ANON_REQ_TYPE_OWNER) because no advert had ever named +// them, so nodes has no row and no position for them. /api/nodes/reported +// lists every such repeater so the UI can find it, and adds an estimated +// position when the receptions allow one. +// +// The estimate is a SNR-weighted centroid of where companions stood when they +// heard the repeater directly. It is not trilateration: a repeater only ever +// heard from one side lands on that side, which is why the radius is never +// smaller than estimateMinRadiusM and why the UI must present it as an +// estimate, never as a node position. + +const ( + // estimateSpotM merges receptions closer than this into one spot, so a + // companion parked in one place counts once, however long it stays. + estimateSpotM = 100.0 + // estimateMinSpots and estimateMinSpreadM: below this there is no + // estimate, because the centroid of one place is that place, which says + // where the companion was, not where the repeater is. + estimateMinSpots = 3 + estimateMinSpreadM = 200.0 + // estimateMinRadiusM is the smallest uncertainty radius reported. + estimateMinRadiusM = 500.0 + // estimateNoSNRdB stands in for a reception without an SNR value. + estimateNoSNRdB = -20.0 + // reportedNodesTTL bounds how often the list is recomputed. + reportedNodesTTL = 5 * time.Minute + // reportedNodesMax caps the list, so the IN query stays bounded. + reportedNodesMax = 500 +) + +// PositionEstimate is an estimated position with its uncertainty. +type PositionEstimate struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + RadiusM float64 `json:"radius_m"` +} + +// ReportedNode is one repeater known only from its own reported name. +type ReportedNode struct { + Pubkey string `json:"pubkey"` + Name string `json:"name"` + NameReportedAt string `json:"name_reported_at"` + Reporters int `json:"reporters"` + Receptions int `json:"receptions"` + Spots int `json:"spots"` + FirstHeard string `json:"first_heard,omitempty"` + LastHeard string `json:"last_heard,omitempty"` + Estimate *PositionEstimate `json:"estimate,omitempty"` +} + +// ReportedNodesResponse is the /api/nodes/reported reply. Method carries the +// thresholds the estimate used, so the UI can explain a missing estimate. +type ReportedNodesResponse struct { + Nodes []ReportedNode `json:"nodes"` + Method struct { + SpotM float64 `json:"spot_m"` + MinSpots int `json:"min_spots"` + MinSpreadM float64 `json:"min_spread_m"` + MinRadiusM float64 `json:"min_radius_m"` + } `json:"method"` +} + +// rxPoint is one direct reception: where the companion stood, and how well. +type rxPoint struct { + Lat, Lon float64 + SNR *float64 +} + +// distanceM is the great-circle distance in metres. +func distanceM(lat1, lon1, lat2, lon2 float64) float64 { + return haversineKm(lat1, lon1, lat2, lon2) * 1000 +} + +// spotsOf merges receptions within estimateSpotM of a spot's first reception +// into that spot, keeping the best SNR. Greedy in input order, which is fine +// at 100 m: the merge only exists to stop one parked companion counting many +// times. +func spotsOf(pts []rxPoint) []rxPoint { + var spots []rxPoint + for _, p := range pts { + merged := false + for i := range spots { + if distanceM(spots[i].Lat, spots[i].Lon, p.Lat, p.Lon) < estimateSpotM { + if p.SNR != nil && (spots[i].SNR == nil || *p.SNR > *spots[i].SNR) { + spots[i].SNR = p.SNR + } + merged = true + break + } + } + if !merged { + spots = append(spots, p) + } + } + return spots +} + +// estimatePosition returns the estimate and the spot count. The estimate is +// nil below estimateMinSpots spots or when no two spots are +// estimateMinSpreadM apart. +func estimatePosition(pts []rxPoint) (*PositionEstimate, int) { + spots := spotsOf(pts) + if len(spots) < estimateMinSpots { + return nil, len(spots) + } + spread := 0.0 + for i := range spots { + for j := i + 1; j < len(spots); j++ { + spread = math.Max(spread, distanceM(spots[i].Lat, spots[i].Lon, spots[j].Lat, spots[j].Lon)) + } + } + if spread < estimateMinSpreadM { + return nil, len(spots) + } + // Linear amplitude weight: 6 dB better counts twice as much. + var sw, slat, slon float64 + for _, s := range spots { + snr := estimateNoSNRdB + if s.SNR != nil { + snr = *s.SNR + } + w := math.Pow(10, snr/20) + sw += w + slat += w * s.Lat + slon += w * s.Lon + } + est := &PositionEstimate{Lat: slat / sw, Lon: slon / sw, RadiusM: estimateMinRadiusM} + for _, s := range spots { + est.RadiusM = math.Max(est.RadiusM, distanceM(est.Lat, est.Lon, s.Lat, s.Lon)) + } + est.Lat = math.Round(est.Lat*1e5) / 1e5 + est.Lon = math.Round(est.Lon*1e5) / 1e5 + est.RadiusM = math.Round(est.RadiusM) + return est, len(spots) +} + +type reportedNodesCache struct { + mu sync.Mutex + at time.Time + resp *ReportedNodesResponse +} + +func (s *Server) handleReportedNodes(w http.ResponseWriter, r *http.Request) { + if s.db == nil || s.db.conn == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable") + return + } + c := &s.reportedNodes + c.mu.Lock() + defer c.mu.Unlock() + if c.resp == nil || time.Since(c.at) > reportedNodesTTL { + start := time.Now() + resp, err := s.computeReportedNodes() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + log.Printf("[reported-nodes] %d repeaters, %d with an estimate, computed in %s", len(resp.Nodes), countEstimates(resp.Nodes), time.Since(start).Round(time.Millisecond)) + c.resp, c.at = resp, time.Now() + } + writeJSON(w, c.resp) +} + +func countEstimates(nodes []ReportedNode) int { + n := 0 + for _, nd := range nodes { + if nd.Estimate != nil { + n++ + } + } + return n +} + +// tablePresent reports whether a table exists. Used for inactive_nodes and +// client_receptions, which an older or partial database may lack. +func (db *DB) tablePresent(name string) bool { + var n int + err := db.conn.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&n) + return err == nil && n > 0 +} + +// computeReportedNodes builds the list: every client_node_names target with no +// advert position in nodes (or inactive_nodes), with its direct receptions. +func (s *Server) computeReportedNodes() (*ReportedNodesResponse, error) { + resp := &ReportedNodesResponse{Nodes: []ReportedNode{}} + resp.Method.SpotM = estimateSpotM + resp.Method.MinSpots = estimateMinSpots + resp.Method.MinSpreadM = estimateMinSpreadM + resp.Method.MinRadiusM = estimateMinRadiusM + if !s.db.clientNodeNamesPresent() { + return resp, nil + } + + positioned := `EXISTS (SELECT 1 FROM nodes n WHERE n.public_key = c.target AND n.lat IS NOT NULL AND n.lon IS NOT NULL AND NOT (n.lat = 0 AND n.lon = 0))` + if s.db.tablePresent("inactive_nodes") { + positioned += ` OR EXISTS (SELECT 1 FROM inactive_nodes n WHERE n.public_key = c.target AND n.lat IS NOT NULL AND n.lon IS NOT NULL AND NOT (n.lat = 0 AND n.lon = 0))` + } + // The bare name beside MAX(observed_at) is SQLite's bare-column rule: the + // name of the newest observation (same as lookupPrefixes). + rows, err := s.db.conn.Query(` + SELECT target, name, MAX(observed_at), COUNT(DISTINCT rx_pubkey) + FROM client_node_names c + WHERE NOT (`+positioned+`) + GROUP BY target + ORDER BY MAX(observed_at) DESC + LIMIT ?`, reportedNodesMax) + if err != nil { + return nil, err + } + byPK := map[string]*ReportedNode{} + var order []string + for rows.Next() { + var n ReportedNode + if err := rows.Scan(&n.Pubkey, &n.Name, &n.NameReportedAt, &n.Reporters); err != nil { + rows.Close() + return nil, err + } + if s.cfg.IsBlacklisted(n.Pubkey) || s.cfg.IsNameHidden(n.Name) { + continue + } + nn := n + byPK[n.Pubkey] = &nn + order = append(order, n.Pubkey) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + if len(order) > 0 && s.db.tablePresent("client_receptions") { + pts, err := s.directReceptions(order) + if err != nil { + return nil, err + } + for _, pk := range order { + n := byPK[pk] + rx := pts[pk] + n.Receptions = len(rx.points) + n.FirstHeard, n.LastHeard = rx.first, rx.last + n.Estimate, n.Spots = estimatePosition(rx.points) + } + } + for _, pk := range order { + resp.Nodes = append(resp.Nodes, *byPK[pk]) + } + return resp, nil +} + +type targetReceptions struct { + points []rxPoint + first, last string +} + +// directReceptions collects, per target, the receptions that certainly heard +// that repeater directly: +// - discover/advert rows keyed by the full pubkey or its 8-byte prefix, which +// carry the responder's own identity +// - rxlog rows keyed by a 2-3 byte path hash, only when that prefix resolves +// uniquely to this target (lookupPrefixes); otherwise another node with +// the same hash could be the one heard +// +// src='geo' rows are left out: their 1-byte hop was placed geographically, +// not heard as this node. +func (s *Server) directReceptions(targets []string) (map[string]*targetReceptions, error) { + keyTarget := map[string]string{} + var short []string + for _, pk := range targets { + keyTarget[pk] = pk + if len(pk) >= 16 { + keyTarget[pk[:16]] = pk + } + for _, n := range []int{4, 6} { + if len(pk) >= n { + short = append(short, pk[:n]) + } + } + } + wanted := make(map[string]bool, len(targets)) + for _, pk := range targets { + wanted[pk] = true + } + for k, h := range s.lookupPrefixes(short) { + if s.visible(h) && wanted[h.pubkey] { + keyTarget[k] = h.pubkey + } + } + + out := make(map[string]*targetReceptions, len(targets)) + for _, pk := range targets { + out[pk] = &targetReceptions{} + } + keys := make([]string, 0, len(keyTarget)) + for k := range keyTarget { + keys = append(keys, k) + } + const chunk = 400 + for i := 0; i < len(keys); i += chunk { + end := i + chunk + if end > len(keys) { + end = len(keys) + } + batch := keys[i:end] + args := make([]interface{}, len(batch)) + for j, k := range batch { + args[j] = k + } + rows, err := s.db.conn.Query(` + SELECT heard_key, src, lat, lon, snr, rx_at FROM client_receptions + WHERE heard_key IN (`+sqlPlaceholders(len(batch))+`) AND src IN ('discover','advert','rxlog')`, args...) + if err != nil { + return nil, err + } + for rows.Next() { + var hk, src, at string + var lat, lon float64 + var snr sql.NullFloat64 + if err := rows.Scan(&hk, &src, &lat, &lon, &snr, &at); err != nil { + rows.Close() + return nil, err + } + hk = strings.ToLower(hk) + // A short key only counts as an rxlog path hash; a discover or + // advert row always carries 8 or 32 bytes. + if len(hk) < 16 && src != "rxlog" { + continue + } + pk, ok := keyTarget[hk] + if !ok { + continue + } + t := out[pk] + p := rxPoint{Lat: lat, Lon: lon} + if snr.Valid { + v := snr.Float64 + p.SNR = &v + } + t.points = append(t.points, p) + if t.first == "" || at < t.first { + t.first = at + } + if at > t.last { + t.last = at + } + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + } + return out, nil +} diff --git a/cmd/server/reported_nodes_test.go b/cmd/server/reported_nodes_test.go new file mode 100644 index 000000000..5377da5ee --- /dev/null +++ b/cmd/server/reported_nodes_test.go @@ -0,0 +1,221 @@ +package main + +import ( + "encoding/json" + "fmt" + "math" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" +) + +func snrp(v float64) *float64 { return &v } + +// Offsets in metres around a reference point, small enough for a flat-earth +// conversion to be exact to well under a metre. +func offset(dNorthM, dEastM float64) (float64, float64) { + const lat0, lon0 = 51.0, 5.38 + return lat0 + dNorthM/110540, lon0 + dEastM/(111320*math.Cos(lat0*math.Pi/180)) +} + +func pt(dN, dE float64, snr *float64) rxPoint { + lat, lon := offset(dN, dE) + return rxPoint{Lat: lat, Lon: lon, SNR: snr} +} + +func TestEstimatePositionNeedsThreeSpreadSpots(t *testing.T) { + // Fifteen receptions from one driveway are one spot: no estimate. + var parked []rxPoint + for i := 0; i < 15; i++ { + parked = append(parked, pt(float64(i), 0, snrp(-5))) + } + if est, spots := estimatePosition(parked); est != nil || spots != 1 { + t.Fatalf("parked: est=%v spots=%d, want nil and 1", est, spots) + } + // Two spots: still nothing. + if est, spots := estimatePosition([]rxPoint{pt(0, 0, snrp(-5)), pt(0, 1000, snrp(-5))}); est != nil || spots != 2 { + t.Fatalf("two spots: est=%v spots=%d", est, spots) + } + // Three spots, but all within 200 m of each other: nothing. + close3 := []rxPoint{pt(0, 0, snrp(-5)), pt(0, 120, snrp(-5)), pt(120, 0, snrp(-5))} + if est, spots := estimatePosition(close3); est != nil || spots != 3 { + t.Fatalf("tight cluster: est=%v spots=%d", est, spots) + } +} + +func TestEstimatePositionWeightedCentroid(t *testing.T) { + // Equal SNR: the plain centroid of the three spots. + eq := []rxPoint{pt(0, 0, snrp(-10)), pt(0, 900, snrp(-10)), pt(900, 0, snrp(-10))} + est, spots := estimatePosition(eq) + if est == nil || spots != 3 { + t.Fatalf("est=%v spots=%d", est, spots) + } + wantLat, wantLon := offset(300, 300) + if d := distanceM(est.Lat, est.Lon, wantLat, wantLon); d > 2 { + t.Errorf("centroid off by %.1f m", d) + } + // One spot 20 dB stronger pulls the estimate towards it. + strong := []rxPoint{pt(0, 0, snrp(10)), pt(0, 900, snrp(-10)), pt(900, 0, snrp(-10))} + est2, _ := estimatePosition(strong) + lat0, lon0 := offset(0, 0) + if distanceM(est2.Lat, est2.Lon, lat0, lon0) >= distanceM(est.Lat, est.Lon, lat0, lon0) { + t.Errorf("the strong spot did not pull the estimate towards it: %+v vs %+v", est2, est) + } +} + +func TestEstimatePositionRadius(t *testing.T) { + // Spots far apart: the radius reaches the farthest spot. + wide := []rxPoint{pt(0, 0, snrp(-5)), pt(0, 3000, snrp(-5)), pt(3000, 0, snrp(-5))} + est, _ := estimatePosition(wide) + if est == nil { + t.Fatal("no estimate") + } + far := 0.0 + for _, p := range wide { + far = math.Max(far, distanceM(est.Lat, est.Lon, p.Lat, p.Lon)) + } + if math.Abs(est.RadiusM-far) > 1 { + t.Errorf("radius %.0f, want the farthest spot at %.0f", est.RadiusM, far) + } + // Spots close together: the radius never drops below the floor. + near := []rxPoint{pt(0, 0, snrp(-5)), pt(0, 250, snrp(-5)), pt(250, 0, snrp(-5))} + if est, _ := estimatePosition(near); est == nil || est.RadiusM != estimateMinRadiusM { + t.Errorf("near: %+v, want radius %v", est, estimateMinRadiusM) + } +} + +// A spot keeps its best SNR, and a reception without SNR still counts as a spot. +func TestSpotsOfKeepsBestSNR(t *testing.T) { + spots := spotsOf([]rxPoint{pt(0, 0, snrp(-12)), pt(30, 0, snrp(-3)), pt(60, 0, nil), pt(0, 500, nil)}) + if len(spots) != 2 { + t.Fatalf("spots = %d, want 2", len(spots)) + } + if spots[0].SNR == nil || *spots[0].SNR != -3 { + t.Errorf("first spot SNR = %v, want -3", spots[0].SNR) + } + if spots[1].SNR != nil { + t.Errorf("second spot SNR = %v, want nil", spots[1].SNR) + } +} + +// --- endpoint --- + +const ( + repA = "c0cdee59e0db981b00000000000000000000000000000000000000000000000a" // reported, spread receptions + repB = "b1b1220000000000000000000000000000000000000000000000000000000000" // reported, one spot + repC = "c2c2330000000000000000000000000000000000000000000000000000000000" // reported, but has an advert position + repH = "c3c3440000000000000000000000000000000000000000000000000000000000" // hidden name +) + +func insRec(t *testing.T, db *DB, hk, src string, dN, dE, snr float64, at string) { + t.Helper() + lat, lon := offset(dN, dE) + mustExecDB(t, db, fmt.Sprintf(`INSERT INTO client_receptions (rx_pubkey,heard_key,heard_keylen,snr,lat,lon,rx_at,ingested_at,src) + VALUES ('comp','%s',%d,%f,%f,%f,'%s','x','%s')`, hk, len(hk)/2, snr, lat, lon, at, src)) +} + +func seedReported(t *testing.T) *Server { + t.Helper() + db := seedCoverageDB(t) + createClientNodeNames(t, db) + insClientName(t, db, repA, "Self Named", "2026-10-16T10:00:00.000Z") + insClientName(t, db, repB, "Lonely", "2026-10-16T09:00:00.000Z") + insClientName(t, db, repC, "Has Advert", "2026-10-16T08:00:00.000Z") + insClientName(t, db, repH, "🚫Secret", "2026-10-16T07:00:00.000Z") + mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('`+repC+`','Has Advert','repeater',51.0,5.38)`) + + // repA: an 8-byte discover key at three spread spots, plus a 2-byte rxlog + // hash that resolves uniquely to it. + insRec(t, db, repA[:16], "discover", 0, 0, -5, "2026-10-01T10:00:00Z") + insRec(t, db, repA[:16], "discover", 0, 900, -10, "2026-10-02T10:00:00Z") + insRec(t, db, repA, "discover", 900, 0, -10, "2026-10-03T10:00:00Z") + insRec(t, db, repA[:4], "rxlog", 10, 10, -6, "2026-10-04T10:00:00Z") + // A geo row is not a direct reception of this node and must be ignored. + insRec(t, db, repA[:4], "geo", 5000, 5000, -1, "2026-10-05T10:00:00Z") + // repB: heard from one place only. + insRec(t, db, repB[:16], "discover", 0, 0, -4, "2026-10-06T10:00:00Z") + insRec(t, db, repB[:16], "discover", 20, 0, -6, "2026-10-06T10:01:00Z") + return &Server{db: db, cfg: &Config{HiddenNamePrefixes: []string{"🚫"}}} +} + +func getReported(t *testing.T, srv *Server) ReportedNodesResponse { + t.Helper() + router := mux.NewRouter() + router.HandleFunc("/api/nodes/reported", srv.handleReportedNodes).Methods("GET") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/nodes/reported", nil)) + if rr.Code != 200 { + t.Fatalf("status %d: %s", rr.Code, rr.Body.String()) + } + var resp ReportedNodesResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + return resp +} + +func TestReportedNodesEndpoint(t *testing.T) { + resp := getReported(t, seedReported(t)) + if resp.Method.MinSpots != estimateMinSpots || resp.Method.MinRadiusM != estimateMinRadiusM { + t.Errorf("method = %+v", resp.Method) + } + got := map[string]ReportedNode{} + for _, n := range resp.Nodes { + got[n.Pubkey] = n + } + if len(got) != 2 { + t.Fatalf("nodes = %v, want repA and repB only (repC has an advert position, repH is hidden)", resp.Nodes) + } + a := got[repA] + if a.Name != "Self Named" || a.Receptions != 4 || a.Spots != 3 || a.Estimate == nil { + t.Fatalf("repA = %+v", a) + } + if a.FirstHeard != "2026-10-01T10:00:00Z" || a.LastHeard != "2026-10-04T10:00:00Z" { + t.Errorf("repA heard %s..%s", a.FirstHeard, a.LastHeard) + } + far, _ := offset(5000, 5000) + if a.Estimate.Lat > far-0.01 { + t.Errorf("the geo row moved the estimate: %+v", a.Estimate) + } + b := got[repB] + if b.Receptions != 2 || b.Spots != 1 || b.Estimate != nil { + t.Fatalf("repB = %+v, want 2 receptions in 1 spot and no estimate", b) + } + // Newest name report first. + if resp.Nodes[0].Pubkey != repA { + t.Errorf("order: first = %s, want repA", resp.Nodes[0].Pubkey) + } +} + +// A short rxlog hash shared with a node in nodes is ambiguous and must not be +// attributed to the reported repeater. +func TestReportedNodesSkipsAmbiguousPathHash(t *testing.T) { + srv := seedReported(t) + mustExecDB(t, srv.db, `INSERT INTO nodes (public_key, name, role) VALUES ('c0cd990000000000000000000000000000000000000000000000000000000000','Other','repeater')`) + resp := getReported(t, srv) + for _, n := range resp.Nodes { + if n.Pubkey == repA && n.Receptions != 3 { + t.Fatalf("repA receptions = %d, want 3 (the ambiguous c0cd rxlog row dropped)", n.Receptions) + } + } +} + +func TestReportedNodesBlacklisted(t *testing.T) { + srv := seedReported(t) + srv.cfg.NodeBlacklist = []string{repA} + for _, n := range getReported(t, srv).Nodes { + if n.Pubkey == repA { + t.Fatal("blacklisted repeater listed") + } + } +} + +// Without client_node_names (an older ingestor) the endpoint answers an empty +// list, not an error. +func TestReportedNodesWithoutTable(t *testing.T) { + srv := &Server{db: setupTestDBv2(t), cfg: &Config{}} + if resp := getReported(t, srv); len(resp.Nodes) != 0 { + t.Fatalf("nodes = %v, want none", resp.Nodes) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index f6e91e0e4..6805c41d0 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -42,6 +42,9 @@ type Server struct { commit string buildTime string + // reportedNodes caches /api/nodes/reported (reported_nodes.go). + reportedNodes reportedNodesCache + // Cached runtime.MemStats to avoid stop-the-world pauses on every health check memStatsMu sync.Mutex memStatsCache runtime.MemStats @@ -346,6 +349,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { // gated by requireClientRfSamples instead. r.HandleFunc("/api/rf-noise", s.handleRfNoise).Methods("GET") r.HandleFunc("/api/nodes/resolve", s.handleResolvePrefix).Methods("GET") + r.HandleFunc("/api/nodes/reported", s.handleReportedNodes).Methods("GET") r.HandleFunc("/api/rx-coverage", s.handleRxCoverage).Methods("GET") r.HandleFunc("/api/rx-leaderboard", s.handleRxLeaderboard).Methods("GET") r.HandleFunc("/api/nodes/{pubkey}", s.handleNodeDetail).Methods("GET") From 587f46343f8ab6195d2a8a11f9a7bf65977fd81e Mon Sep 17 00:00:00 2001 From: efiten Date: Fri, 16 Oct 2026 12:11:07 +0200 Subject: [PATCH 2/2] feat(map): draw estimated repeater positions, marked as estimates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repeaters from /api/nodes/reported with an estimate get their own layer (public/map-estimated.js), never clustered with advert markers: - a hollow dashed marker with "β‰ˆ", in the repeater colour - a dashed, non-interactive circle with the uncertainty radius - a popup with an "Estimated position" badge, "No advert position", the radius, receptions and spots, first/last heard, who reported the name, the one-sided-driving caveat, and a link to the coverage map there - an "Estimated positions" toggle under Display, on by default and stored in localStorage; hidden too when repeaters are filtered out or a route is shown The map keeps working against a server without the route (fetch failure leaves the list empty). Method and limits in docs/client-rx-coverage.md. Tests: tests/unit/test-map-estimated.js (vm sandbox, Leaflet stub) and tests/e2e/test-map-estimated-e2e.js (mocked API: marker, dashed circle, popup text, toggle and its stored state, no page errors), registered in test-all.sh, scripts/non-unit-tests.json and the E2E workflow. All frontend unit suites pass; the new E2E and test-map-nodes-pagination-e2e pass against a static server. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 1 + docs/client-rx-coverage.md | 21 ++++++ public/index.html | 1 + public/map-estimated.js | 81 ++++++++++++++++++++++ public/map.js | 34 +++++++++- public/style.css | 17 +++++ scripts/non-unit-tests.json | 1 + test-all.sh | 1 + tests/e2e/test-map-estimated-e2e.js | 101 ++++++++++++++++++++++++++++ tests/unit/test-map-estimated.js | 65 ++++++++++++++++++ 10 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 public/map-estimated.js create mode 100644 tests/e2e/test-map-estimated-e2e.js create mode 100644 tests/unit/test-map-estimated.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 301abfd7d..1672066da 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -584,6 +584,7 @@ jobs: suite 1 test-channel-issue-1111-e2e.js BASE_URL=$B suite 2 test-map-modal-fluid-e2e.js BASE_URL=$B suite 2 test-map-nodes-pagination-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B + suite 2 test-map-estimated-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B suite 3 test-observer-iata-1188-e2e.js BASE_URL=$B suite 2 test-issue-1639-observers-sort-e2e.js BASE_URL=$B suite 2 test-issue-661-airtime-e2e.js BASE_URL=$B diff --git a/docs/client-rx-coverage.md b/docs/client-rx-coverage.md index 5ee2b727a..2d3aad8c9 100644 --- a/docs/client-rx-coverage.md +++ b/docs/client-rx-coverage.md @@ -179,6 +179,27 @@ set; it lands in `client_node_names`. - The broker ACL must allow `/names`. Explicit topic lists need `meshcore/client/+/names` (or `meshcore/client/#`). +#### Estimated positions + +A repeater known only from `/names` has no advert, so no position of its own. `GET +/api/nodes/reported` lists every such repeater (no advert position in `nodes` or `inactive_nodes`), +and the map draws an estimated position for the ones it can place. The **Estimated positions** +toggle under Display shows or hides them (on by default). + +- **Which receptions count:** rows in `client_receptions` that certainly heard this repeater + directly. Those are `discover` and `advert` rows keyed by its full pubkey or 8-byte prefix, and + `rxlog` rows whose 2-3 byte path hash resolves uniquely to it. `geo` rows are left out. +- **Spots:** receptions within 100 m of each other count as one spot, with the best SNR of that + spot, so a parked companion counts once. +- **Estimate:** only from 3 spots at least 200 m apart. Below that the list still shows the + repeater, without a position. The position is the SNR-weighted centroid of the spots (weight + `10^(snr/20)`). The radius is the distance to the farthest spot, never below 500 m. +- **On the map:** a hollow dashed marker with "β‰ˆ", a dashed circle with that radius, and a popup + that says "Estimated position" and how it was made. It is never clustered with advert markers. +- **Limitation:** this is a centroid of where companions drove, not trilateration. A repeater heard + from one side only is placed towards that side. The ingestor stores the receptions; the server + computes the estimate on request and caches it for 5 minutes. + ## Capture HARD RULE β€” only what was heard directly The app and ingestor record **only the node the companion physically received**, never upstream diff --git a/public/index.html b/public/index.html index bc9d5bd4f..6b96a94a6 100644 --- a/public/index.html +++ b/public/index.html @@ -218,6 +218,7 @@ + diff --git a/public/map-estimated.js b/public/map-estimated.js new file mode 100644 index 000000000..10f46013a --- /dev/null +++ b/public/map-estimated.js @@ -0,0 +1,81 @@ +/* Estimated positions for repeaters known only from their reported name + (/api/nodes/reported, cmd/server/reported_nodes.go). + + These repeaters never sent an advert, so they have no position of their own. + The server estimates one from where mobile companions heard them directly. + Everything here exists to keep that estimate from ever being read as a real + node position: a hollow dashed marker with "β‰ˆ", a dashed uncertainty circle, + and a popup that says what the position is and is not. */ +(function () { + 'use strict'; + + var esc = function (s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + }; + + // withEstimate keeps only the nodes the server could place. + function withEstimate(nodes) { + return (nodes || []).filter(function (n) { + return n && n.estimate && isFinite(n.estimate.lat) && isFinite(n.estimate.lon); + }); + } + + function formatRadius(m) { + if (!isFinite(m)) return ''; + return m >= 1000 ? (Math.round(m / 100) / 10) + ' km' : Math.round(m) + ' m'; + } + + function coverageHref(n) { + return '#/rx-coverage?days=30&lat=' + n.estimate.lat.toFixed(5) + '&lon=' + n.estimate.lon.toFixed(5) + '&zoom=14'; + } + + function iconHtml() { + return ''; + } + + function popupHtml(n, method) { + var e = n.estimate; + var name = n.name ? esc(n.name) : esc(n.pubkey.slice(0, 8)); + var minSpots = method && method.min_spots ? method.min_spots : 3; + return '
' + + '
Estimated position
' + + '
' + name + '
' + + '
' + esc(n.pubkey.slice(0, 16)) + '…
' + + '

No advert position. This repeater only told a mobile companion its name, ' + + 'so its location is estimated from where companions heard it directly: within about ' + + esc(formatRadius(e.radius_m)) + ' of this point.

' + + '
' + + '
Heard
' + esc(n.receptions) + ' times at ' + esc(n.spots) + ' spots (estimate needs ' + esc(minSpots) + ')
' + + (n.first_heard ? '
First / last
' + esc(String(n.first_heard).slice(0, 10)) + ' / ' + esc(String(n.last_heard).slice(0, 10)) + '
' : '') + + '
Name reported
' + esc(String(n.name_reported_at).slice(0, 10)) + ' by ' + esc(n.reporters) + (n.reporters === 1 ? ' companion' : ' companions') + '
' + + '
' + + '

A centroid of where companions drove, not a measured position: a repeater heard from one side only is placed towards that side.

' + + 'Show receptions on the coverage map' + + '
'; + } + + // addTo draws every estimate onto layer: the uncertainty circle first, so the + // marker stays on top and clickable. L is Leaflet. + function addTo(layer, L, nodes, method) { + var shown = withEstimate(nodes); + shown.forEach(function (n) { + var ll = [n.estimate.lat, n.estimate.lon]; + L.circle(ll, { radius: n.estimate.radius_m, className: 'mc-est-area', interactive: false }).addTo(layer); + var marker = L.marker(ll, { + icon: L.divIcon({ html: iconHtml(), className: 'mc-est-marker', iconSize: [32, 32], iconAnchor: [16, 16], popupAnchor: [0, -15] }), + title: (n.name || n.pubkey.slice(0, 8)) + ' (estimated position)', + alt: 'Estimated position of ' + (n.name || n.pubkey.slice(0, 8)), + keyboard: true, + }); + marker.bindPopup(popupHtml(n, method), { maxWidth: 300 }); + marker.addTo(layer); + }); + return shown.length; + } + + window.MapEstimated = { withEstimate: withEstimate, formatRadius: formatRadius, popupHtml: popupHtml, iconHtml: iconHtml, coverageHref: coverageHref, addTo: addTo }; +})(); diff --git a/public/map.js b/public/map.js index 7ba6a62c2..b1a9aecef 100644 --- a/public/map.js +++ b/public/map.js @@ -5,11 +5,15 @@ let map = null; let routeLayer = null; let markerLayer = null; + // Estimated positions of repeaters known only from their reported name + // (map-estimated.js). A layer of its own: never clustered with real markers. + let estimatedLayer = null; + let reported = { nodes: [], method: null }; let clusterGroup = null; let nodes = []; let targetNodeKey = null; let observers = []; - let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clustering: localStorage.getItem('meshcore-map-clustering') !== 'false', hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all', byteSize: localStorage.getItem('meshcore-map-byte-filter') || 'all', multiByteOverlay: localStorage.getItem('meshcore-map-multibyte-overlay') === 'true', scopeState: localStorage.getItem('meshcore-map-scope-filter') || 'all', scopeOverlay: localStorage.getItem('meshcore-map-scope-overlay') === 'true', regionScope: localStorage.getItem('meshcore-map-region-filter') || '' }; + let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clustering: localStorage.getItem('meshcore-map-clustering') !== 'false', hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all', byteSize: localStorage.getItem('meshcore-map-byte-filter') || 'all', multiByteOverlay: localStorage.getItem('meshcore-map-multibyte-overlay') === 'true', scopeState: localStorage.getItem('meshcore-map-scope-filter') || 'all', scopeOverlay: localStorage.getItem('meshcore-map-scope-overlay') === 'true', estimated: localStorage.getItem('meshcore-map-estimated') !== 'false', regionScope: localStorage.getItem('meshcore-map-region-filter') || '' }; let selectedReferenceNode = null; // pubkey of the reference node for neighbor filtering let neighborPubkeys = null; // Set of pubkeys that are direct neighbors of selected node let wsHandler = null; @@ -517,6 +521,7 @@ +
@@ -724,6 +729,7 @@ }); markerLayer = L.layerGroup().addTo(map); + estimatedLayer = L.layerGroup().addTo(map); clusterGroup = createClusterGroup(); if (filters.clustering && clusterGroup) clusterGroup.addTo(map); routeLayer = L.layerGroup().addTo(map); @@ -897,6 +903,11 @@ scopeOverlayEl.checked = filters.scopeOverlay; scopeOverlayEl.addEventListener('change', e => { filters.scopeOverlay = e.target.checked; localStorage.setItem('meshcore-map-scope-overlay', e.target.checked); renderMarkers(); }); } + const estimatedEl = document.getElementById('mcEstimated'); + if (estimatedEl) { + estimatedEl.checked = filters.estimated; + estimatedEl.addEventListener('change', e => { filters.estimated = e.target.checked; localStorage.setItem('meshcore-map-estimated', e.target.checked); renderEstimated(); }); + } document.getElementById('mcLastHeard').addEventListener('change', e => { filters.lastHeard = e.target.value; loadNodes(); }); AreaFilter.init(document.getElementById('mapAreaFilter')); @@ -1087,6 +1098,7 @@ } // Hide default markers so only the route is visible if (markerLayer) map.removeLayer(markerLayer); + if (estimatedLayer) map.removeLayer(estimatedLayer); if (clusterGroup) map.removeLayer(clusterGroup); if (heatLayer) map.removeLayer(heatLayer); @@ -1101,6 +1113,7 @@ L.DomEvent.preventDefault(e); routeLayer.clearLayers(); if (markerLayer) map.addLayer(markerLayer); + if (estimatedLayer) map.addLayer(estimatedLayer); if (clusterGroup) map.addLayer(clusterGroup); map.removeControl(closeBtn); var container = map.getContainer(); @@ -1734,12 +1747,20 @@ if (map !== loadingMap) return; nodes = data.nodes || []; observers = obsData.observers || []; + // Optional: an instance without client_node_names answers an empty list, + // and an older server without the route must not break the map. + try { + const rep = await api('/nodes/reported', { ttl: CLIENT_TTL.nodeList }); + if (map !== loadingMap) return; + reported = { nodes: (rep && rep.nodes) || [], method: rep && rep.method }; + } catch (e) { reported = { nodes: [], method: null }; } buildRoleChecks(data.counts || {}); buildRegionFilter(); buildJumpButtons(); renderMarkers(); + renderEstimated(); // Keep the Important Links overlay in sync on a full node reload: re-fetch // the neighbor-graph edges (not just re-render the stale cache) so the @@ -1847,6 +1868,7 @@ lbl.querySelector('input').addEventListener('change', e => { filters[e.target.dataset.role] = e.target.checked; renderMarkers(); + renderEstimated(); }); el.appendChild(lbl); } @@ -2048,6 +2070,14 @@ } } + // renderEstimated draws the estimated positions, hidden with the toggle or + // when repeaters are filtered out (they are all repeaters). + function renderEstimated() { + if (!estimatedLayer || !window.MapEstimated) return; + estimatedLayer.clearLayers(); + if (filters.estimated && filters.repeater) window.MapEstimated.addTo(estimatedLayer, L, reported.nodes, reported.method); + } + function renderMarkers() { if (_renderingMarkers) return; _renderingMarkers = true; @@ -2444,6 +2474,8 @@ map = null; } markerLayer = null; + estimatedLayer = null; + reported = { nodes: [], method: null }; clusterGroup = null; _currentMarkerData = []; routeLayer = null; diff --git a/public/style.css b/public/style.css index 712d0a1b6..8ed72c0b4 100644 --- a/public/style.css +++ b/public/style.css @@ -5875,3 +5875,20 @@ body.embed #app.app-fixed { .role-swatch--room { color: var(--role-room); } .role-swatch--sensor { color: var(--role-sensor); } .role-swatch--observer { color: var(--role-observer); } + +/* Estimated positions (map-estimated.js): hollow, dashed, marked with "β‰ˆ" so + they never read as a node's own advert position. */ +.mc-est-marker { background: none; border: none; } +.mc-est-ring { fill: var(--surface-1); fill-opacity: .85; stroke: var(--mc-role-repeater); stroke-width: 2; stroke-dasharray: 3 3; } +.mc-est-glyph { fill: var(--mc-role-repeater); font: 700 15px/1 var(--mono, ui-monospace, monospace); } +.leaflet-interactive.mc-est-area, path.mc-est-area { stroke: var(--mc-role-repeater); stroke-width: 1.5; stroke-dasharray: 6 6; fill: var(--mc-role-repeater); fill-opacity: .07; } +.mc-est-legend { color: var(--mc-role-repeater); font-weight: 700; } +.mc-est-popup { display: grid; gap: 4px; max-width: 280px; } +.mc-est-badge { justify-self: start; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; padding: 1px 6px; border-radius: 3px; border: 1px dashed var(--mc-role-repeater); color: var(--text); } +.mc-est-name { font-weight: 700; font-size: 14px; } +.mc-est-pk { color: var(--text-muted); font-size: 11px; } +.mc-est-what, .mc-est-caveat { margin: 2px 0; font-size: 12px; } +.mc-est-caveat { color: var(--text-muted); } +.mc-est-facts { display: grid; grid-template-columns: auto 1fr; gap: 1px 8px; margin: 2px 0; font-size: 12px; } +.mc-est-facts dt { color: var(--text-muted); } +.mc-est-facts dd { margin: 0; } diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json index 2388c1a55..4988d907c 100644 --- a/scripts/non-unit-tests.json +++ b/scripts/non-unit-tests.json @@ -91,6 +91,7 @@ "tests/e2e/test-logo-pulse-1173-e2e.js", "tests/e2e/test-logo-rebrand-e2e.js", "tests/e2e/test-logo-theme-e2e.js", + "tests/e2e/test-map-estimated-e2e.js", "tests/e2e/test-map-modal-fluid-e2e.js", "tests/e2e/test-map-nodes-pagination-e2e.js", "tests/e2e/test-nav-drawer-1064-e2e.js", diff --git a/test-all.sh b/test-all.sh index 4f041e670..552cf060b 100755 --- a/test-all.sh +++ b/test-all.sh @@ -160,6 +160,7 @@ node tests/unit/test-locode-column.js node tests/unit/test-scrape-locode.js node tests/unit/test-discord-announce.js node tests/unit/test-map-clustering.js +node tests/unit/test-map-estimated.js node tests/unit/test-mqtt-status-panel.js node tests/unit/test-my-repeaters-dashboard.js node tests/unit/test-naive-banner-tone.js diff --git a/tests/e2e/test-map-estimated-e2e.js b/tests/e2e/test-map-estimated-e2e.js new file mode 100644 index 000000000..04f3f9658 --- /dev/null +++ b/tests/e2e/test-map-estimated-e2e.js @@ -0,0 +1,101 @@ +/** + * E2E: estimated positions on the map (map-estimated.js, /api/nodes/reported). + * + * A repeater known only from its reported name has no advert position. When + * the server can estimate one, the map draws it as a hollow dashed "β‰ˆ" marker + * with a dashed uncertainty circle and a popup that says it is an estimate. + * The "Estimated positions" toggle hides both, and the choice is remembered. + * A reported repeater without an estimate draws nothing. + * + * Backend-independent: every /api/* call is mocked via page.route. + * + * Run: BASE_URL=http://localhost:13581 node test-map-estimated-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const PLACED = 'c0cdee59e0db981b00000000000000000000000000000000000000000000000a'; +const UNPLACED = 'b1b1220000000000000000000000000000000000000000000000000000000000'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' βœ“ ' + name); } + catch (e) { failed++; console.error(' βœ— ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +const reported = { + nodes: [ + { pubkey: PLACED, name: 'Self Named', name_reported_at: '2026-10-16T10:00:00.000Z', reporters: 1, receptions: 15, spots: 4, + first_heard: '2026-09-18T06:36:06Z', last_heard: '2026-10-16T06:50:48Z', estimate: { lat: 50.99712, lon: 5.37811, radius_m: 2480 } }, + { pubkey: UNPLACED, name: 'Lonely', name_reported_at: '2026-10-16T09:00:00.000Z', reporters: 1, receptions: 2, spots: 1 }, + ], + method: { spot_m: 100, min_spots: 3, min_spread_m: 200, min_radius_m: 500 }, +}; + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + + console.log(`\n=== Map estimated positions E2E against ${BASE} ===`); + + await page.route('**/api/**', (route) => { + const path = new URL(route.request().url()).pathname; + const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + if (path === '/api/nodes/reported') return json(reported); + if (path === '/api/nodes') { + return json({ nodes: [{ public_key: 'aa' + '0'.repeat(62), name: 'Real Repeater', role: 'repeater', lat: 51.0, lon: 5.4, last_seen: new Date().toISOString(), hash_size: 1 }], + total: 1, has_more: false, counts: { repeaters: 1, rooms: 0, companions: 0, sensors: 0 } }); + } + if (path === '/api/observers') return json({ observers: [] }); + if (path === '/api/config/map') return json({ center: [51.0, 5.38], zoom: 12 }); + return json({}); + }); + + await page.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 }); + await page.waitForSelector('#leaflet-map', { timeout: 15000 }); + + await step('the estimate is drawn as a dashed "β‰ˆ" marker with an uncertainty circle', async () => { + await page.waitForSelector('.mc-est-marker', { timeout: 15000 }); + const n = await page.$$eval('.mc-est-marker', (els) => els.length); + assert(n === 1, 'expected 1 estimated marker (the unplaced repeater draws nothing), got ' + n); + const glyph = await page.$eval('.mc-est-marker', (el) => el.textContent.trim()); + assert(glyph === 'β‰ˆ', 'marker glyph ' + JSON.stringify(glyph)); + const dash = await page.$eval('path.mc-est-area', (el) => getComputedStyle(el).strokeDasharray); + assert(dash && dash !== 'none', 'uncertainty circle is not dashed: ' + dash); + }); + + await step('the popup says it is an estimate', async () => { + await page.click('.mc-est-marker'); + await page.waitForSelector('.mc-est-popup'); + const text = await page.$eval('.mc-est-popup', (el) => el.textContent); + assert(text.includes('Estimated position') && text.includes('No advert position'), 'popup text: ' + text); + assert(text.includes('2.5 km'), 'popup radius missing: ' + text); + }); + + await step('the toggle hides marker and circle and is remembered', async () => { + await page.evaluate(() => { const el = document.getElementById('mcEstimated'); el.click(); }); + await page.waitForFunction(() => !document.querySelector('.mc-est-marker') && !document.querySelector('path.mc-est-area')); + const stored = await page.evaluate(() => localStorage.getItem('meshcore-map-estimated')); + assert(stored === 'false', 'localStorage meshcore-map-estimated = ' + stored); + await page.evaluate(() => document.getElementById('mcEstimated').click()); + await page.waitForSelector('.mc-est-marker'); + }); + + await step('no page errors', async () => { + assert(errors.length === 0, errors.join(' | ')); + }); + + await browser.close(); + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/tests/unit/test-map-estimated.js b/tests/unit/test-map-estimated.js new file mode 100644 index 000000000..0473492d8 --- /dev/null +++ b/tests/unit/test-map-estimated.js @@ -0,0 +1,65 @@ +'use strict'; +const REPO_ROOT = require('path').resolve(__dirname, '..', '..'); +// Unit test for map-estimated.js: estimated positions must never read as a +// node's own position. Loads the browser IIFE in a vm sandbox and drives +// addTo with a recording Leaflet stub. +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const code = fs.readFileSync(path.join(REPO_ROOT, 'public', 'map-estimated.js'), 'utf8'); +const sandbox = { window: {} }; +vm.createContext(sandbox); +vm.runInContext(code, sandbox); +const M = sandbox.window.MapEstimated; + +const placed = { + pubkey: 'c0cdee59e0db981b00000000000000000000000000000000000000000000000a', + name: 'Self ', name_reported_at: '2026-10-16T10:00:00.000Z', reporters: 1, + receptions: 15, spots: 4, first_heard: '2026-09-18T06:36:06Z', last_heard: '2026-10-16T06:50:48Z', + estimate: { lat: 50.99712, lon: 5.37811, radius_m: 2480 }, +}; +const unplaced = Object.assign({}, placed, { pubkey: 'b1b1' + '0'.repeat(60), estimate: undefined, spots: 1 }); +const method = { spot_m: 100, min_spots: 3, min_spread_m: 200, min_radius_m: 500 }; + +// Only nodes with an estimate are drawn. +assert.deepStrictEqual(M.withEstimate([placed, unplaced, null]).map(n => n.pubkey), [placed.pubkey]); +assert.strictEqual(M.withEstimate([{ estimate: { lat: NaN, lon: 5 } }]).length, 0, 'a NaN estimate is not drawn'); + +assert.strictEqual(M.formatRadius(500), '500 m'); +assert.strictEqual(M.formatRadius(2480), '2.5 km'); + +// The popup says what this is, escapes the name, and links to the coverage map there. +const html = M.popupHtml(placed, method); +assert.ok(html.includes('Estimated position'), 'badge'); +assert.ok(html.includes('No advert position'), 'states there is no advert position'); +assert.ok(html.includes('2.5 km'), 'uncertainty radius'); +assert.ok(html.includes('15 times at 4 spots (estimate needs 3)'), 'reception facts'); +assert.ok(html.includes('Self <Named>') && !html.includes(''), 'name escaped'); +assert.ok(html.includes('#/rx-coverage?days=30&lat=50.99712&lon=5.37811&zoom=14'), 'coverage link'); +assert.ok(html.includes('1 companion<'), 'singular companion'); + +// The icon is the hollow dashed ring with the β‰ˆ glyph, not a role marker. +assert.ok(M.iconHtml().includes('mc-est-ring') && M.iconHtml().includes('β‰ˆ')); + +// addTo: one uncertainty circle (non-interactive, dashed class) and one marker per estimate. +const added = []; +const layer = {}; +const L = { + circle(ll, opts) { const o = { kind: 'circle', ll, opts, addTo() { added.push(o); return o; } }; return o; }, + divIcon(opts) { return opts; }, + marker(ll, opts) { const o = { kind: 'marker', ll, opts, popup: null, bindPopup(h) { o.popup = h; return o; }, addTo() { added.push(o); return o; } }; return o; }, +}; +assert.strictEqual(M.addTo(layer, L, [placed, unplaced], method), 1); +assert.strictEqual(added.length, 2); +const [circle, marker] = added; +assert.strictEqual(circle.kind, 'circle', 'circle drawn first so the marker stays on top'); +assert.strictEqual(circle.opts.radius, 2480); +assert.strictEqual(circle.opts.className, 'mc-est-area'); +assert.strictEqual(circle.opts.interactive, false); +assert.strictEqual(marker.opts.icon.className, 'mc-est-marker'); +assert.ok(marker.opts.title.includes('(estimated position)'), 'hover title says estimated'); +assert.ok(marker.popup.includes('Estimated position')); + +console.log('map-estimated OK');