Files
meshcore-analyzer/tests/e2e/test-rx-coverage-gaps-e2e.js
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

110 lines
6.9 KiB
JavaScript

#!/usr/bin/env node
'use strict';
// "Nothing received" cells on the Mobile RX coverage page: the toggle exists
// only with clientRfSamples, is on by default and asks /api/rx-coverage for
// gaps=1, draws the gap cells hatched underneath the reception cells, and the
// off state is deep-linkable (gaps=0) and stops asking for gaps.
const assert = require('assert');
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:3000';
const poly = (lon, lat) => ({ type: 'Polygon', coordinates: [[[lon, lat], [lon + 0.01, lat], [lon + 0.01, lat + 0.01], [lon, lat + 0.01], [lon, lat]]] });
const recv = (lon, lat) => ({ type: 'Feature', geometry: poly(lon, lat), properties: { cell: 'r' + lon, count: 2, best_snr: -4, has_sig: true, nodes: [] } });
const gap = (lon, lat, samples) => ({ type: 'Feature', geometry: poly(lon, lat), properties: { cell: 'g' + lon, samples } });
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.log(' ✗ ' + name + ': ' + e.message); }
}
async function openPage(browser, { rfSamples, hash }) {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
const covRequests = [];
await page.route('**/api/config/client', async route => {
const response = await route.fetch();
await route.fulfill({ json: { ...await response.json(), clientRxCoverage: true, clientRfSamples: rfSamples } });
});
await page.route('**/api/config/map', route => route.fulfill({ json: { center: [51, 4], zoom: 9 } }));
await page.route('**/api/rx-leaderboard?*', route => route.fulfill({ json: { observers: [] } }));
await page.route('**/api/rx-coverage?*', route => {
const url = route.request().url();
covRequests.push(url);
const withGaps = new URL(url).searchParams.get('gaps') === '1';
return route.fulfill({ json: { type: 'FeatureCollection', features: [recv(4.40, 51.20)], ...(withGaps ? { gaps: [gap(4.50, 51.20, 1), gap(4.60, 51.20, 5)] } : {}) } });
});
await page.goto(BASE + '/#/rx-coverage' + (hash || ''));
await page.waitForSelector('#rxMap.leaflet-container', { timeout: 15000 });
return { page, errors, covRequests };
}
const gapCells = page => page.locator('#rxMap path.rx-gap-cell');
(async () => {
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || undefined });
console.log('\n=== RX coverage "nothing received" E2E against ' + BASE + ' ===');
try {
await step('no toggle and no gaps request when clientRfSamples is off', async () => {
const { page, errors, covRequests } = await openPage(browser, { rfSamples: false });
await page.waitForFunction(() => document.querySelectorAll('#rxMap path.leaflet-interactive').length === 1, null, { timeout: 10000 });
assert.strictEqual(await page.locator('#rxGapsBtn').count(), 0, 'toggle must not render when the feature is off');
assert.ok(covRequests.every(u => !/[?&]gaps=1/.test(u)), 'no gaps=1 request without RF samples');
assert.deepStrictEqual(errors, []);
await page.close();
});
await step('on by default: gaps=1 requested, hatched cells drawn under the reception cells, legend entry', async () => {
const { page, errors, covRequests } = await openPage(browser, { rfSamples: true });
await page.waitForFunction(() => document.querySelectorAll('#rxMap path.rx-gap-cell').length === 2, null, { timeout: 10000 });
assert.ok(covRequests.some(u => /[?&]gaps=1/.test(u)), 'the signal layer asks for gaps');
assert.strictEqual(await page.locator('#rxGapsBtn').getAttribute('aria-pressed'), 'true');
assert.strictEqual(await gapCells(page).first().getAttribute('fill'), 'url(#rxGapHatch)', 'gap cells use the hatch pattern');
assert.strictEqual(await page.locator('#rxMap pattern#rxGapHatch').count(), 1, 'the hatch pattern exists once');
const below = await page.evaluate(() => {
const g = document.querySelector('#rxMap path.rx-gap-cell'), r = document.querySelector('#rxMap path.leaflet-interactive:not(.rx-gap-cell)');
return !!(g && r && (g.compareDocumentPosition(r) & Node.DOCUMENT_POSITION_FOLLOWING));
});
assert.ok(below, 'gap cells are drawn before (under) the reception cells');
assert.ok(/nothing received/i.test(await page.locator('#rxLegend').innerText()), 'legend names the gap cells');
assert.deepStrictEqual(errors, []);
await page.close();
});
await step('toggle off: cells gone, gaps=0 in the hash, no more gaps requests; the link restores it', async () => {
const { page, errors, covRequests } = await openPage(browser, { rfSamples: true });
await page.waitForFunction(() => document.querySelectorAll('#rxMap path.rx-gap-cell').length === 2, null, { timeout: 10000 });
await page.click('#rxGapsBtn');
await page.waitForFunction(() => document.querySelectorAll('#rxMap path.rx-gap-cell').length === 0, null, { timeout: 10000 });
assert.ok(/[?&]gaps=0/.test(await page.evaluate(() => location.hash)), 'off state is in the URL hash');
assert.ok(!/[?&]gaps=1/.test(covRequests[covRequests.length - 1]), 'the request after switching off does not ask for gaps');
assert.ok(!/nothing received/i.test(await page.locator('#rxLegend').innerText()), 'legend entry removed');
await page.close();
const again = await openPage(browser, { rfSamples: true, hash: '?gaps=0' });
await again.page.waitForFunction(() => document.querySelectorAll('#rxMap path.leaflet-interactive').length === 1, null, { timeout: 10000 });
assert.strictEqual(await again.page.locator('#rxGapsBtn').getAttribute('aria-pressed'), 'false', 'deep link opens with the toggle off');
assert.strictEqual(await gapCells(again.page).count(), 0);
assert.deepStrictEqual(errors.concat(again.errors), []);
await again.page.close();
});
await step('the noise layer hides the toggle and draws no gap cells', async () => {
const { page, errors } = await openPage(browser, { rfSamples: true });
await page.route('**/api/rf-noise?*', route => route.fulfill({ json: { type: 'FeatureCollection', features: [] } }));
await page.waitForFunction(() => document.querySelectorAll('#rxMap path.rx-gap-cell').length === 2, null, { timeout: 10000 });
assert.ok(await page.locator('#rxGapsBar').isVisible(), 'toggle visible on the signal layer');
await page.click('#rxLayerBar button[data-layer="noise"]');
assert.ok(!(await page.locator('#rxGapsBar').isVisible()), 'toggle hidden on the noise layer');
await page.waitForTimeout(500);
assert.strictEqual(await gapCells(page).count(), 0);
assert.deepStrictEqual(errors, []);
await page.close();
});
} finally {
await browser.close();
}
console.log(`\ntest-rx-coverage-gaps-e2e: ${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
})();