Files
meshcore-analyzer/cmd/server/rx_coverage_test.go
T
efitenandClaude Opus 5 00ee4f9e23 fix(coverage): attribute node-discover replies to the responder (#2111)
## Problem

A CoreDrive RX companion that runs node-discover gets no coverage on an
upstream instance. Reported today by an operator on v3.13.0: the app
logged 32 discover replies from one repeater in 16 minutes (`heard
751a49f0c5dadc70 (8B, discover)`), all published, and
`client_receptions` stayed at 0 rows for that companion while
`client_rx_observations` held 93.

Two gaps, both on the upstream side:

1. **Ingestor.** `deriveHeardKey` attributes a FLOOD `path[last]` and a
0-hop advert, and drops a 0-hop `CONTROL/DISCOVER_RESP` (firmware
`CTL_TYPE_NODE_DISCOVER_RESP`). The reply carries the responder's own
pubkey at offset 6 of the payload, which `decoder.go` already parses
into `CtrlPubKey` (#1802), but the coverage path never used it.
2. **Server.** CoreDrive RX asks for `DISCOVER_PREFIX_ONLY`, so the
firmware answers with an 8-byte prefix
(`simple_repeater/MyMesh.cpp:799-805`). `coverageHeardKeyCandidates`
only built the 64, 6 and 4 hex candidates, so a 16-hex `heard_key` would
be stored and then matched by no per-node coverage query.

## Change

- `cmd/ingestor/client_reception.go`: a third branch in `deriveHeardKey`
for a discover response with no hops. Accepts exactly 8 or 32 bytes,
nothing truncated, stored with `src='discover'`.
- `cmd/server/rx_coverage.go`: adds the 16-hex prefix to
`coverageHeardKeyCandidates`.
- `docs/client-rx-coverage.md`: documents the `discover` source, the
8-byte keylen and the four-candidate lookup.

The leaderboard and `/api/rx-coverage` read `client_receptions` without
a key filter, so they pick the rows up without a change. Name resolution
goes through `batchResolveHeardKeys`, which is a prefix lookup and
handles 16 hex as is.

## Evidence from a deployment that has had this since 2026-08-19

On analyzer.on8ar.eu, `client_receptions` over the last 7 days by `src`:
discover 4232, rxlog 4909, geo 548, advert 34. Discover replies are 44%
of all coverage rows there (4232 of 9723); on an upstream instance those
rows are not written. They cannot be backfilled afterwards either:
`client_rx_observations` keeps no raw bytes, so the responder pubkey is
gone.

## Tests

- `TestDeriveHeardKey` and `TestBuildClientReception` gain discover
cases: 8-byte and 32-byte keys accepted (32-byte uppercase input
lowercased), a 3-byte and an empty key rejected, a non-discover CONTROL
rejected, a discover response with hops not attributed.
- `TestHandleClientPacketDiscoverRespWritesReception`: end to end, a raw
0-hop DISCOVER_RESP on the client topic writes one `client_receptions`
row with `src='discover'`.
- `TestCoverageHeardKeyCandidatesIncludesDiscoverPrefix`: the 16-hex
prefix is among the per-node candidates.
- Ran locally on Windows: `go test ./...` in `cmd/server` passes; in
`cmd/ingestor` everything passes except
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which needs the symlink
privilege on Windows and fails on clean master too.

Not done: no browser validation, as the change is ingestor and server
only and the frontend reads the same endpoints. Not included: geographic
resolution of 1-byte hops (`src='geo'`) and the RF noise layer, which
are separate changes.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 17:15:50 +02:00

272 lines
11 KiB
Go

package main
import (
"encoding/json"
"fmt"
"math"
"testing"
)
// TestAggregateCoverageCapsNodesPerCell verifies #11: a cell that heard more than
// coverageCellNodeCap distinct nodes ships at most that many, with NodesTruncated set.
func TestAggregateCoverageCapsNodesPerCell(t *testing.T) {
rows := make([]coverageRow, 0, coverageCellNodeCap+5)
for i := 0; i < coverageCellNodeCap+5; i++ {
rows = append(rows, coverageRow{
Lat: 51.05, Lon: 3.72, SNR: covF(float64(-i)),
HeardKey: fmt.Sprintf("aa%06x", i), RxAt: "2026-06-01T10:00:00Z",
})
}
fc := aggregateCoverage(rows, 9, nil)
if len(fc.Features) != 1 {
t.Fatalf("expected 1 cell, got %d", len(fc.Features))
}
p := fc.Features[0].Properties
if len(p.Nodes) != coverageCellNodeCap || !p.NodesTruncated {
t.Fatalf("want %d nodes + truncated, got %d nodes truncated=%v", coverageCellNodeCap, len(p.Nodes), p.NodesTruncated)
}
}
// TestAggregateCoverageCapsFeatures verifies #12: a query spanning more than
// coverageFeatureCap cells is bounded to that many features with Truncated set,
// and a smaller query is not truncated.
func TestAggregateCoverageCapsFeatures(t *testing.T) {
// 0.1° spacing >> a res-9 cell (~4 km), so each point lands in its own cell.
rows := make([]coverageRow, 0, coverageFeatureCap+200)
side := 75 // 75*75 = 5625 > 5000
for i := 0; i < side*side; i++ {
lat := 10.0 + float64(i/side)*0.1
lon := 10.0 + float64(i%side)*0.1
rows = append(rows, coverageRow{Lat: lat, Lon: lon, SNR: covF(-5)})
}
fc := aggregateCoverage(rows, 9, nil)
if len(fc.Features) != coverageFeatureCap || !fc.Truncated {
t.Fatalf("want %d features + truncated, got %d truncated=%v", coverageFeatureCap, len(fc.Features), fc.Truncated)
}
// Still sorted by cell after truncation.
for i := 1; i < len(fc.Features); i++ {
if fc.Features[i-1].Properties.Cell > fc.Features[i].Properties.Cell {
t.Fatalf("truncated features not sorted by cell at %d", i)
}
}
// A small query is not truncated.
small := aggregateCoverage(rows[:10], 9, nil)
if small.Truncated {
t.Fatalf("small query should not be truncated")
}
}
func covF(f float64) *float64 { return &f }
func TestAggregateCoverageBucketsBestSNR(t *testing.T) {
rows := []coverageRow{
{Lat: 51.05000, Lon: 3.72000, SNR: covF(-12)},
{Lat: 51.05001, Lon: 3.72001, SNR: covF(-6)}, // same cell, stronger
}
fc := aggregateCoverage(rows, 9, nil)
if len(fc.Features) != 1 {
t.Fatalf("expected 1 cell, got %d", len(fc.Features))
}
if p := fc.Features[0].Properties; p.BestSNR == nil || *p.BestSNR != -6 || p.Count != 2 || !p.HasSig {
t.Fatalf("bad props: %+v", fc.Features[0].Properties)
}
if g := fc.Features[0].Geometry; g.Type != "Polygon" || len(g.Coordinates) != 1 {
t.Fatalf("bad geometry: %+v", g)
}
if _, err := json.Marshal(fc); err != nil {
t.Fatalf("marshal: %v", err)
}
}
func TestAggregateCoverageGreyWhenNoSignal(t *testing.T) {
fc := aggregateCoverage([]coverageRow{{Lat: 51.05, Lon: 3.72}}, 9, nil)
if len(fc.Features) != 1 || fc.Features[0].Properties.HasSig {
t.Fatalf("expected one grey (no-sig) cell, got %+v", fc.Features)
}
}
// TestAggregateCoverageNodeBreakdown covers the per-cell node list: each heard node
// keeps its latest SNR (by rx_at) and reception count, sorted strongest-first with
// heard-without-signal nodes last.
func TestAggregateCoverageNodeBreakdown(t *testing.T) {
rows := []coverageRow{
// node A: two receptions; the later one (t2) has the weaker SNR -10.
{Lat: 51.05, Lon: 3.72, SNR: covF(-4), HeardKey: "aabb", RxAt: "2026-06-01T10:00:00Z"},
{Lat: 51.05001, Lon: 3.72001, SNR: covF(-10), HeardKey: "aabb", RxAt: "2026-06-02T10:00:00Z"},
// node B: single reception, strongest latest SNR.
{Lat: 51.05, Lon: 3.72, SNR: covF(-6), HeardKey: "ccdd", RxAt: "2026-06-01T10:00:00Z"},
// node C: heard without a signal metric.
{Lat: 51.05, Lon: 3.72, HeardKey: "eeff", RxAt: "2026-06-01T10:00:00Z"},
}
fc := aggregateCoverage(rows, 9, nil)
if len(fc.Features) != 1 {
t.Fatalf("expected 1 cell, got %d", len(fc.Features))
}
nodes := fc.Features[0].Properties.Nodes
if len(nodes) != 3 {
t.Fatalf("expected 3 nodes, got %d (%+v)", len(nodes), nodes)
}
if nodes[0].Prefix != "ccdd" || nodes[0].SNR == nil || *nodes[0].SNR != -6 {
t.Errorf("node[0] want ccdd@-6 (strongest), got %+v", nodes[0])
}
if nodes[1].Prefix != "aabb" || nodes[1].SNR == nil || *nodes[1].SNR != -10 || nodes[1].Count != 2 {
t.Errorf("node[1] want aabb latest -10 count 2, got %+v", nodes[1])
}
if nodes[2].Prefix != "eeff" || nodes[2].SNR != nil {
t.Errorf("node[2] want eeff no-signal (last), got %+v", nodes[2])
}
}
// TestResolveHeardKey covers heard_key → (pubkey, name) resolution: a unique match
// returns the canonical pubkey + name; an ambiguous prefix (>1 node) and an
// unknown/empty key return the key itself with an empty name.
func TestResolveHeardKey(t *testing.T) {
db := seedCoverageDB(t)
mustExecDB(t, db, `INSERT INTO nodes (public_key,name,role) VALUES ('aabbccdd11223344','Alice','repeater')`)
mustExecDB(t, db, `INSERT INTO nodes (public_key,name,role) VALUES ('aabbcc99887766aa','Bob','repeater')`)
srv := &Server{db: db}
if k, n := srv.resolveHeardKey("aabbccdd"); k != "aabbccdd11223344" || n != "Alice" {
t.Errorf("unique prefix → (pubkey,Alice), got (%q,%q)", k, n)
}
if k, n := srv.resolveHeardKey("aabbcc"); k != "aabbcc" || n != "" {
t.Errorf("ambiguous prefix → (key,\"\"), got (%q,%q)", k, n)
}
if k, n := srv.resolveHeardKey("ffff"); k != "ffff" || n != "" {
t.Errorf("unknown prefix → (key,\"\"), got (%q,%q)", k, n)
}
if k, n := srv.resolveHeardKey(""); k != "" || n != "" {
t.Errorf("empty prefix → (\"\",\"\"), got (%q,%q)", k, n)
}
}
// TestAggregateCoverageMergesResolvedNodes verifies that the same node heard under
// two different heard_keys (e.g. a 3-byte prefix and the full pubkey) collapses into a
// single entry — summed count, latest SNR — when the resolver maps both to one node.
func TestAggregateCoverageMergesResolvedNodes(t *testing.T) {
rows := []coverageRow{
{Lat: 51.05, Lon: 3.72, SNR: covF(-4), HeardKey: "aabbcc", RxAt: "2026-06-01T10:00:00Z"},
{Lat: 51.05, Lon: 3.72, SNR: covF(-9), HeardKey: "aabbccdd11223344", RxAt: "2026-06-03T10:00:00Z"},
{Lat: 51.05, Lon: 3.72, SNR: covF(-7), HeardKey: "aabbcc", RxAt: "2026-06-02T10:00:00Z"},
}
resolve := func(hk string) (string, string) { return "aabbccdd11223344", "Alice" }
fc := aggregateCoverage(rows, 9, resolve)
if len(fc.Features) != 1 {
t.Fatalf("expected 1 cell, got %d", len(fc.Features))
}
nodes := fc.Features[0].Properties.Nodes
if len(nodes) != 1 {
t.Fatalf("expected 1 merged node, got %d (%+v)", len(nodes), nodes)
}
n := nodes[0]
if n.Name != "Alice" || n.Count != 3 || n.SNR == nil || *n.SNR != -9 {
t.Errorf("merged node want Alice count 3 latest -9, got %+v (snr=%v)", n, n.SNR)
}
}
// TestAggregateCoverageDeterministicFeatureOrder verifies #8: features come out
// sorted by cell regardless of Go's randomized map iteration, so the GeoJSON is
// stable (cacheable / non-flaky e2e).
func TestAggregateCoverageDeterministicFeatureOrder(t *testing.T) {
rows := []coverageRow{
{Lat: 51.0, Lon: 3.0, SNR: covF(-5)},
{Lat: 48.0, Lon: 2.0, SNR: covF(-5)},
{Lat: 52.0, Lon: 4.0, SNR: covF(-5)},
{Lat: 40.0, Lon: -3.0, SNR: covF(-5)},
}
fc := aggregateCoverage(rows, 9, nil)
if len(fc.Features) < 2 {
t.Fatalf("expected multiple cells, got %d", len(fc.Features))
}
for i := 1; i < len(fc.Features); i++ {
if fc.Features[i-1].Properties.Cell > fc.Features[i].Properties.Cell {
t.Fatalf("features not sorted by cell at %d: %q > %q", i,
fc.Features[i-1].Properties.Cell, fc.Features[i].Properties.Cell)
}
}
}
// TestAggregateCoverageNamePrecedenceOrderIndependent verifies #20: when two
// heard_keys resolve to the same node but the resolver returns different display
// names, the most specific (longest) heard_key wins regardless of row order, so
// the name no longer depends on map/row iteration.
func TestAggregateCoverageNamePrecedenceOrderIndependent(t *testing.T) {
resolve := func(hk string) (string, string) {
if hk == "aabbccdd11223344" {
return "aabbccdd11223344", "Alice"
}
return "aabbccdd11223344", "AliceShortPrefix"
}
full := coverageRow{Lat: 51.05, Lon: 3.72, SNR: covF(-5), HeardKey: "aabbccdd11223344", RxAt: "2026-06-01T10:00:00Z"}
prefix := coverageRow{Lat: 51.05, Lon: 3.72, SNR: covF(-6), HeardKey: "aabbcc", RxAt: "2026-06-02T10:00:00Z"}
for _, order := range [][]coverageRow{{full, prefix}, {prefix, full}} {
fc := aggregateCoverage(order, 9, resolve)
nodes := fc.Features[0].Properties.Nodes
if len(nodes) != 1 {
t.Fatalf("expected 1 merged node, got %d (%+v)", len(nodes), nodes)
}
if nodes[0].Name != "Alice" {
t.Fatalf("name precedence flapped with row order: got %q, want Alice", nodes[0].Name)
}
}
}
func TestZoomToHexRes(t *testing.T) {
// Resolution tracks zoom 1:1 within [3,18], clamped at the edges (z=0 is the
// missing-param case).
cases := map[int]int{0: 3, 3: 3, 8: 8, 16: 16, 18: 18, 25: 18}
for z, want := range cases {
if got := zoomToHexRes(z); got != want {
t.Fatalf("zoomToHexRes(%d)=%d, want %d", z, got, want)
}
}
}
// TestHexSizeRendersConstantPx verifies the core fix: a hex sized for resolution
// res renders at a constant ~hexTargetPx on screen at the corresponding zoom level,
// instead of the old fixed-meter buckets that were ~2px when zoomed out.
func TestHexSizeRendersConstantPx(t *testing.T) {
for res := 4; res <= 16; res++ {
// On-screen point-to-point height = 2*circumradius / mercUnitsPerPixel(zoom),
// where mercUnitsPerPixel = mercUPPZ0 / 2^zoom and zoom == res.
px := 2 * hexSizeForRes(res) * math.Pow(2, float64(res)) / mercUPPZ0
if math.Abs(px-hexTargetPx) > 0.001 {
t.Fatalf("res %d renders %.2fpx, want %.2fpx", res, px, hexTargetPx)
}
// Size must halve each zoom step (finer grid as you zoom in).
if ratio := hexSizeForRes(res) / hexSizeForRes(res+1); math.Abs(ratio-2) > 1e-9 {
t.Fatalf("res %d→%d size ratio %.4f, want 2", res, res+1, ratio)
}
}
}
// TestCoverageHeardKeyCandidatesIncludesDiscoverPrefix pins the 8-byte (16 hex)
// candidate. corescope-rx requests DISCOVER_PREFIX_ONLY, so the firmware answers
// with a 6+8 byte body and essentially every discover-attributed reception is
// stored under an 8-byte heard_key. Without this candidate those rows exist in
// client_receptions and are matched by no per-node coverage query — stored, and
// invisible on the node page.
func TestCoverageHeardKeyCandidatesIncludesDiscoverPrefix(t *testing.T) {
pk := "efef7943505052b47f1809488ea4b4d3942d4ed72d2b1953b90a9f5e62a65fb5"
got := coverageHeardKeyCandidates(pk)
want := map[string]bool{
pk: false,
"efef7943505052b4": false, // 8-byte discover prefix
"efef79": false, // 3-byte relay hop
"efef": false, // 2-byte relay hop
}
for _, c := range got {
if _, ok := want[c]; !ok {
t.Errorf("unexpected candidate %q", c)
continue
}
want[c] = true
}
for c, seen := range want {
if !seen {
t.Errorf("candidate %q missing from %v", c, got)
}
}
}