Files
meshcore-analyzer/cmd/server/rx_coverage_gaps_test.go
T
efitenandClaude Opus 5.5 f3004d977e feat(rx-coverage): show where a companion drove and received nothing (#2172)
The Mobile RX coverage page (`#/rx-coverage`) shows only cells where a
companion received something. Where a companion drove and received
nothing is just as useful for operators, and the data is already there:
the RF sample track (`client_rf_samples`, #1905/#1906) and the
receptions (`client_receptions`) use the same hex grid (`hexCellAt`,
`zoomToHexRes`). This PR adds those cells as a hatched layer under the
reception cells.

## Changes in this PR

**Server**
- `GET /api/rx-coverage?...&gaps=1` adds a `gaps` member to the
FeatureCollection: cells with moving (non-stationary) track samples and
no reception in the same `days` window and `bbox`, each with `samples`.
`gaps_truncated` is set when more than 5000 cells qualify (the
most-sampled are kept, same pattern as `coverageFeatureCap` and
`rfNoiseFeatureCap`).
- Gaps are only returned when `clientRfSamples` is on and there is no
`node` filter: with `?node=` the question would be "this node was not
heard", which these cells do not answer. `?rx=` limits both the track
and the receptions to that companion.
- Without `gaps=1` the payload is unchanged: the `gaps` member is
omitted.
- New code lives in `cmd/server/rx_coverage_gaps.go` (pure
`aggregateGaps` plus `queryTrackPoints`); the handler change in
`rx_dashboard.go` is 14 lines.
- `/api/rx-coverage` is now documented in `openapi.go` and removed from
`openapi_known_gaps.json` (#1670).

**Frontend** (`public/rx-coverage.js`, `public/node-reach-coverage.css`)
- A "Nothing received" toggle on the signal layer, on by default, only
rendered with `MC_CLIENT_RF_SAMPLES`. Hidden on the noise layer.
- Gap cells use a 45-degree hatch (an SVG pattern in a dedicated
renderer, so they sit under the reception cells). The hatch is denser
for cells driven through more often (1 / 2-3 / 4+ samples). Tooltip:
sample count. Legend entry: "driven, nothing received".
- Off state is deep-linkable as `gaps=0`.
- New token `--nq-cov-gap` for light and dark. Plain grey
(`--nq-cov-grey`) already means "received, no SNR", so the hatch keeps
the two apart.

## Threshold: one sample

Measured on a live network (analyzer.on8ar.eu, Belgium, September to
October 2026), with cells of about 250 m: a cell with track samples and
no reception in one half of a month, driven again in the other half. How
often did it receive something the second time?

| Minimum samples | Cells "nothing received" | Driven again | Received
something later |
|---|---|---|---|
| 1 | 3971 | 1263 | 29% |
| 2 | 1565 | 592 | 32% |
| 3 | 619 | 243 | 35% |
| 5 | 142 | 63 | 40% |

A higher threshold hides cells without making the rest more reliable, so
the threshold is 1 and the wording is "nothing received in this period",
never "no coverage". Using every decoded packet as reception evidence
(including 1-byte path hashes) gave 25% to 32%, so the capture rule for
1-byte hashes is not what drives this. The median cell has 2 samples.

## Verification

- Go: 7 new tests in `rx_coverage_gaps_test.go` (gap/no gap, stationary
excluded, single sample, cap keeps the most-sampled, endpoint, `?rx=`
filter, no `gaps` member when not asked / with `?node=` / with RF
samples off). The endpoint tests fail without the handler change. Full
`cmd/server` suite green locally, `go vet` and `gofmt` clean.
- E2E: `tests/e2e/test-rx-coverage-gaps-e2e.js` (4 steps: no toggle with
RF samples off, on by default with hatched cells under the reception
cells, toggle off with `gaps=0` and deep link, noise layer hides it). 2
steps fail without the frontend change. Existing
`test-rx-coverage-noise-e2e.js` and `test-rx-coverage-viewport-e2e.js`
still pass. Listed in `scripts/non-unit-tests.json`.
- Frontend unit suite (`test-all.sh`) green.
- Real data on a staging instance (7 days, bbox 50.8,4.9,51.3,5.8,
z=12): 478 reception cells and 379 gap cells, no cell in both, reception
features identical with and without `gaps=1`, 0.48 s to 0.52 s per
request both ways.
- Browser: checked on staging with real data (light theme), and light,
dark and 390 px wide with mocked data.

## Not in this PR

- A per-node variant on the Reach page ("this repeater transmitted while
a companion was nearby, and was not heard"). That needs the transmit
moments from `observations` and is a heavier query.
- `deploy.yml` does not get a line for the new E2E file, like the other
entries that are only in `non-unit-tests.json` (#2037).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:39:33 +02:00

197 lines
6.9 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
)
// --- aggregateGaps: pure ---
func TestAggregateGapsTrackWithoutReception(t *testing.T) {
res := zoomToHexRes(12)
track := []trackPoint{{Lat: 51.05, Lon: 3.72}, {Lat: 51.05, Lon: 3.72}, {Lat: 51.30, Lon: 3.90}}
rx := []coverageRow{{Lat: 51.30, Lon: 3.90}}
gaps, truncated := aggregateGaps(track, rx, res)
if truncated {
t.Fatal("two cells must not be truncated")
}
if len(gaps) != 1 {
t.Fatalf("want 1 gap (the cell without a reception), got %d: %+v", len(gaps), gaps)
}
g := gaps[0]
if g.Type != "Feature" || g.Geometry.Type != "Polygon" || len(g.Geometry.Coordinates[0]) == 0 {
t.Fatalf("gap must be a GeoJSON polygon feature, got %+v", g)
}
if g.Properties.Cell != hexCellAt(51.05, 3.72, res) || g.Properties.Samples != 2 {
t.Fatalf("want the 51.05/3.72 cell with 2 samples, got %+v", g.Properties)
}
}
// A parked companion is not a drive through the cell: stationary samples never make a gap.
func TestAggregateGapsIgnoresStationary(t *testing.T) {
gaps, _ := aggregateGaps([]trackPoint{{Lat: 51.05, Lon: 3.72, Stationary: true}}, nil, zoomToHexRes(12))
if len(gaps) != 0 {
t.Fatalf("stationary sample must not produce a gap, got %+v", gaps)
}
}
// One moving sample is enough (threshold measured: more samples do not make a gap more reliable).
func TestAggregateGapsSingleSample(t *testing.T) {
gaps, _ := aggregateGaps([]trackPoint{{Lat: 51.05, Lon: 3.72}}, nil, zoomToHexRes(12))
if len(gaps) != 1 || gaps[0].Properties.Samples != 1 {
t.Fatalf("one moving sample without reception must be a gap, got %+v", gaps)
}
}
func TestAggregateGapsCapsKeepsMostSampled(t *testing.T) {
res := zoomToHexRes(14)
var track []trackPoint
seen := map[string]bool{}
for i := 0; len(seen) < coverageGapCap+5; i++ {
la, lo := 51.0+float64(i%200)*0.002, 3.5+float64(i/200)*0.003
c := hexCellAt(la, lo, res)
if seen[c] {
continue
}
seen[c] = true
track = append(track, trackPoint{Lat: la, Lon: lo})
if len(seen) == 1 { // one cell gets many samples: it must survive the cap
for k := 0; k < 9; k++ {
track = append(track, trackPoint{Lat: la, Lon: lo})
}
}
}
gaps, truncated := aggregateGaps(track, nil, res)
if !truncated || len(gaps) != coverageGapCap {
t.Fatalf("want %d gaps and truncated, got %d truncated=%v", coverageGapCap, len(gaps), truncated)
}
found := false
for _, g := range gaps {
if g.Properties.Samples == 10 {
found = true
}
}
if !found {
t.Fatal("the most-sampled cell must be kept when capping")
}
for i := 1; i < len(gaps); i++ {
if gaps[i-1].Properties.Cell > gaps[i].Properties.Cell {
t.Fatal("gaps must be sorted by cell for a deterministic payload")
}
}
}
// --- endpoint ---
func seedGapsDB(t *testing.T) *DB {
db := seedCoverageDB(t)
mustExecDB(t, db, `CREATE TABLE client_rf_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT, rx_pubkey TEXT, sampled_at TEXT, ingested_at TEXT,
lat REAL, lon REAL, pos_acc_m REAL, stationary INTEGER NOT NULL DEFAULT 0,
uptime_secs INTEGER, battery_mv INTEGER, queue_len INTEGER, errors INTEGER,
noise_floor INTEGER, last_rssi INTEGER, last_snr REAL, tx_air_secs INTEGER,
rx_air_secs INTEGER, recv INTEGER, sent INTEGER, flood_rx INTEGER, direct_rx INTEGER,
flood_tx INTEGER, direct_tx INTEGER, recv_errors INTEGER)`)
return db
}
func insTrack(t *testing.T, db *DB, rx, at string, lat, lon float64, stationary int) {
mustExecDB(t, db, fmt.Sprintf(
`INSERT INTO client_rf_samples (rx_pubkey,sampled_at,ingested_at,lat,lon,stationary,uptime_secs) VALUES ('%s','%s','t',%f,%f,%d,1)`,
rx, at, lat, lon, stationary))
}
func serveCoverage(srv *Server, path string) *httptest.ResponseRecorder {
router := mux.NewRouter()
router.HandleFunc("/api/rx-coverage", srv.handleRxCoverage).Methods("GET")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest("GET", path, nil))
return rr
}
func gapsServer(t *testing.T, rfOn bool) *Server {
db := seedGapsDB(t)
// seedCoverageDB already holds receptions; start from a known state.
mustExecDB(t, db, `DELETE FROM client_receptions`)
now := time.Now().UTC().Format(time.RFC3339)
old := time.Now().UTC().AddDate(0, 0, -40).Format(time.RFC3339)
insRx(t, db, "compa", "aabbcc", now, 51.30, 3.90) // heard here
insTrack(t, db, "compa", now, 51.30, 3.90, 0) // driven and heard: no gap
insTrack(t, db, "compa", now, 51.05, 3.72, 0) // driven, nothing: gap
insTrack(t, db, "compb", now, 51.10, 3.60, 0) // other companion, nothing: gap unless ?rx=compa
insTrack(t, db, "compa", now, 51.20, 3.50, 1) // parked: no gap
insTrack(t, db, "compa", old, 51.40, 3.40, 0) // outside the 7-day window: no gap
cfg := &Config{ClientRxCoverage: &ClientRxCoverageConfig{Enabled: true}}
if rfOn {
cfg.ClientRfSamples = &ClientRfSamplesConfig{Enabled: true}
}
return &Server{db: db, cfg: cfg}
}
func decodeCoverage(t *testing.T, rr *httptest.ResponseRecorder) CoverageFeatureCollection {
t.Helper()
if rr.Code != 200 {
t.Fatalf("status %d body %s", rr.Code, rr.Body.String())
}
var fc CoverageFeatureCollection
if err := json.Unmarshal(rr.Body.Bytes(), &fc); err != nil {
t.Fatalf("decode: %v", err)
}
return fc
}
func TestRxCoverageGapsEndpoint(t *testing.T) {
srv := gapsServer(t, true)
z := 12
fc := decodeCoverage(t, serveCoverage(srv, fmt.Sprintf("/api/rx-coverage?bbox=50,3,52,4&z=%d&days=7&gaps=1", z)))
if len(fc.Features) != 1 {
t.Fatalf("the reception layer must be unchanged: want 1 feature, got %d", len(fc.Features))
}
res := zoomToHexRes(z)
want := map[string]bool{hexCellAt(51.05, 3.72, res): true, hexCellAt(51.10, 3.60, res): true}
if len(fc.Gaps) != len(want) {
t.Fatalf("want %d gaps, got %+v", len(want), fc.Gaps)
}
for _, g := range fc.Gaps {
if !want[g.Properties.Cell] {
t.Fatalf("unexpected gap cell %s", g.Properties.Cell)
}
}
}
func TestRxCoverageGapsFollowObserverFilter(t *testing.T) {
srv := gapsServer(t, true)
fc := decodeCoverage(t, serveCoverage(srv, "/api/rx-coverage?bbox=50,3,52,4&z=12&days=7&gaps=1&rx=compa"))
if len(fc.Gaps) != 1 || fc.Gaps[0].Properties.Cell != hexCellAt(51.05, 3.72, zoomToHexRes(12)) {
t.Fatalf("?rx=compa must only show compa's own drive, got %+v", fc.Gaps)
}
}
// Without ?gaps=1, with a node filter, or with the RF sample stream off, the payload carries no gaps member at all.
func TestRxCoverageGapsOnlyWhenAskedAndAvailable(t *testing.T) {
cases := []struct {
name string
rfOn bool
path string
}{
{"not asked", true, "/api/rx-coverage?bbox=50,3,52,4&z=12&days=7"},
{"node filter", true, "/api/rx-coverage?bbox=50,3,52,4&z=12&days=7&gaps=1&node=aabbcc"},
{"rf samples off", false, "/api/rx-coverage?bbox=50,3,52,4&z=12&days=7&gaps=1"},
}
for _, c := range cases {
rr := serveCoverage(gapsServer(t, c.rfOn), c.path)
if rr.Code != 200 {
t.Fatalf("%s: status %d", c.name, rr.Code)
}
if strings.Contains(rr.Body.String(), `"gaps"`) {
t.Fatalf("%s: payload must not carry gaps, got %s", c.name, rr.Body.String())
}
}
}