fix(rx-coverage): My coverage and the observer filter on the noise layer (#2326)

"My coverage" and a selected observer only narrowed the signal layer on
the coverage page. On the noise layer the toggle stayed pressed, but
`/api/rf-noise` was called without `mine` or `rx` and returned every
companion's samples.

## Changes

- **Backend:** `/api/rf-noise` now accepts `rx` and `mine=1`, with the
same session rules as `/api/rx-coverage`: 401 without a session, 403 for
a device token, 404 with user management off. The `rx_pubkey` filter
moved into 1 helper, `appendRxFilter` (`cmd/server/rx_dashboard.go`),
which both queries use. Both parameters are in the OpenAPI entry.
- **Frontend:** `public/rx-coverage.js` fetches both layers through
`fetchLayer`. It builds the URL with the time window, observer and
toggle, and judges the reply with the existing `coverageReply`. The
request sequence (`signalSeq`, now `drawSeq`) covers both layers, so the
noise layer also drops a superseded reply. Before, a slow unfiltered
noise reply could overwrite a newer filtered one.

## Performance

The noise query gets 1 extra `WHERE` clause on `rx_pubkey`. There are no
new requests: the page still makes 1 request per redraw.

## Verification

- New Go tests in `cmd/server/rf_noise_mine_test.go`: `rx` narrows the
result (case-insensitive), `mine=1` returns 401 without a session,
returns empty with nothing linked, returns the linked companion's cells,
returns 403 for a device token, and returns 404 with user management
off. Both failed before the fix.
- `tests/unit/test-rx-coverage-mine.js` is updated for the new
`coverageUrl(path, ...)` signature and checks that both layers fetch
through `fetchLayer`.
- New step in `tests/e2e/test-rx-coverage-noise-e2e.js`: a
`?layer=noise&rx=...` link sends `rx`, and clicking "My coverage"
refetches with `mine=1`. It fails against the current master frontend
and passes with this change.
- Local runs (Windows): `go test` and `go vet` in `cmd/server`,
`test-all.sh`, and the noise, gaps and viewport E2E in Chrome. All
green. I did not run the full Playwright suite locally.

## Not in this PR

- The "nothing received" gap cells still skip `mine=1`, unchanged: the
track query cannot narrow to a set of companions yet.
- The time window (`days`) already worked on the noise layer, so it is
not touched.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efitenandClaude Opus 5.5 authored and GitHub committed 2026-10-29 22:39:02 +01:00
1 parent 417c8dd00a
commit bb3c8cc001
7 files changed
+201 -56

No files matched your search

+2
View File
@@ -195,6 +195,8 @@ func routeDescriptions() map[string]routeMeta {
{Name: "bbox", Description: "Bounding box as minLat,minLon,maxLat,maxLon", Type: "string", Required: true},
{Name: "z", Description: "Leaflet zoom level; sets the hex resolution", Type: "integer"},
{Name: "days", Description: "Look-back window in days (1-30, default 7)", Type: "integer"},
{Name: "rx", Description: "Only this companion's samples", Type: "string"},
{Name: "mine", Description: "1 = only samples of companions linked to the logged-in user (browser session; 401 without one)", Type: "string"},
}},
"GET /api/rx-coverage": {Summary: "Mobile RX coverage hex grid", Description: "GeoJSON hex cells where mobile clients directly heard nodes (client_receptions): count, best SNR and the nodes heard per cell. With gaps=1 the payload also carries a `gaps` member: cells a companion drove through (moving client_rf_samples) with no reception in the same window, each with its sample count, plus `gaps_truncated` when capped. Gaps are only returned without a node filter and with clientRfSamples on; they mean nothing was received in this period, not that there is no coverage. Gated on clientRxCoverage.", Tag: "coverage",
QueryParams: []paramMeta{
+18 -4
View File
@@ -144,10 +144,16 @@ func (s *Server) requireClientRfSamples(w http.ResponseWriter, r *http.Request)
}
// queryRfNoiseRows returns raw RF-environment samples within a bbox, over a
// time window (days; 0 = all time). Read-only (server RO connection).
func (s *Server) queryRfNoiseRows(days int, b bbox) ([]rfNoiseRow, error) {
// time window (days; 0 = all time), optionally narrowed to one companion (rx)
// or a set of companions (rxIn, see queryCoverageFiltered). Read-only (server
// RO connection).
func (s *Server) queryRfNoiseRows(rx string, rxIn []string, days int, b bbox) ([]rfNoiseRow, error) {
if rxIn != nil && len(rxIn) == 0 {
return []rfNoiseRow{}, nil
}
where := []string{"lat BETWEEN ? AND ?", "lon BETWEEN ? AND ?", "noise_floor IS NOT NULL"}
args := []interface{}{b.MinLat, b.MaxLat, b.MinLon, b.MaxLon}
where, args = appendRxFilter(where, args, "rx_pubkey", rx, rxIn)
if days > 0 {
since := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339)
where = append(where, "sampled_at >= ?")
@@ -174,11 +180,19 @@ func (s *Server) queryRfNoiseRows(days int, b bbox) ([]rfNoiseRow, error) {
}
// handleRfNoise serves the RF noise-floor map layer as GeoJSON hex cells,
// over a time window. Mirrors handleRxCoverage's parameter handling.
// over a time window. Mirrors handleRxCoverage's parameter handling,
// including ?rx= and ?mine=1.
func (s *Server) handleRfNoise(w http.ResponseWriter, r *http.Request) {
if !s.requireClientRfSamples(w, r) {
return
}
var mine []string
if r.URL.Query().Get("mine") == "1" {
var ok bool
if mine, ok = s.myCompanionPubkeys(w, r); !ok {
return
}
}
b, ok := parseBBox(r.URL.Query().Get("bbox"))
if !ok {
http.Error(w, "bbox required as minLat,minLon,maxLat,maxLon", http.StatusBadRequest)
@@ -190,7 +204,7 @@ func (s *Server) handleRfNoise(w http.ResponseWriter, r *http.Request) {
}
days := clampDays(atoiDefault(r.URL.Query().Get("days"), 7))
z, _ := strconv.Atoi(r.URL.Query().Get("z"))
rows, err := s.queryRfNoiseRows(days, b)
rows, err := s.queryRfNoiseRows(r.URL.Query().Get("rx"), mine, days, b)
if err != nil {
http.Error(w, "query failed", http.StatusInternalServerError)
return
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// The noise layer narrows to one companion (?rx=) or to the caller's linked
// companions (?mine=1) the same way the signal layer does.
func TestRfNoiseRxAndMine(t *testing.T) {
f := newAuthFixture(t)
f.srv.db = seedRfNoiseDB(t)
f.srv.cfg.ClientRfSamples = &ClientRfSamplesConfig{Enabled: true}
f.router.HandleFunc("/api/rf-noise", f.srv.handleRfNoise).Methods("GET")
mine, other := pubHex(companionKey), pubHex(otherKey)
now := time.Now().UTC().Format(time.RFC3339)
for i, pk := range []string{mine, other, other} {
mustExecDB(t, f.srv.db, fmt.Sprintf(`INSERT INTO client_rf_samples (rx_pubkey,sampled_at,ingested_at,lat,lon,stationary,uptime_secs,noise_floor)
VALUES ('%s','%s','t',%f,3.72,0,100,-110)`, pk, now, 51.05+float64(i)*0.05))
}
const q = "/api/rf-noise?bbox=50,3,52,4&z=10"
get := func(path string, mods ...reqMod) *httptest.ResponseRecorder { return f.do("GET", path, nil, mods...) }
all := get(q).Body.String()
empty := get(q + "&rx=" + strings.Repeat("0", 64)).Body.String()
if strings.Count(all, `"Feature"`) != 3 || strings.Count(empty, `"Feature"`) != 0 {
t.Fatalf("rx= does not narrow: all=%s empty=%s", all, empty)
}
if got := get(q + "&rx=" + strings.ToUpper(other)).Body.String(); strings.Count(got, `"Feature"`) != 2 {
t.Fatalf("rx=<other> (upper case) = %s, want its 2 cells", got)
}
expectStatus(t, get(q+"&mine=1"), http.StatusUnauthorized)
alice := f.registerAndActivate(t, "alice@example.org", "Alice", pw)
w := get(q+"&mine=1", as(alice))
expectStatus(t, w, http.StatusOK)
if w.Body.String() != empty {
t.Fatalf("nothing linked: got %s, want the empty collection", w.Body.String())
}
tok := f.deviceToken(t, "alice@example.org", pw, "Pixel").Token
expectStatus(t, f.linkCompanion(t, tok, companionKey, "Car"), http.StatusOK)
w = get(q+"&mine=1", as(alice))
expectStatus(t, w, http.StatusOK)
if w.Body.String() != get(q+"&rx="+mine).Body.String() || w.Body.String() == all {
t.Fatalf("mine=1 is not the linked companion's noise: %s", w.Body.String())
}
expectStatus(t, get(q+"&mine=1", bearer(tok)), http.StatusForbidden)
}
func TestRfNoiseMineWithoutUserManagement(t *testing.T) {
srv := &Server{db: seedRfNoiseDB(t), cfg: &Config{ClientRfSamples: &ClientRfSamplesConfig{Enabled: true}}}
w := httptest.NewRecorder()
srv.handleRfNoise(w, httptest.NewRequest("GET", "/api/rf-noise?bbox=50,3,52,4&mine=1", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("mine=1 with user management off = %d, want 404", w.Code)
}
}
+18 -10
View File
@@ -126,16 +126,7 @@ func (s *Server) queryCoverageFiltered(node, rx string, rxIn []string, days int,
args = append(args, c)
}
}
if rx != "" {
where = append(where, "rx_pubkey = ?")
args = append(args, strings.ToLower(rx))
}
if len(rxIn) > 0 {
where = append(where, "rx_pubkey IN ("+sqlPlaceholders(len(rxIn))+")")
for _, pk := range rxIn {
args = append(args, pk)
}
}
where, args = appendRxFilter(where, args, "rx_pubkey", rx, rxIn)
if days > 0 {
since := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339)
where = append(where, "rx_at >= ?")
@@ -149,6 +140,23 @@ func (s *Server) queryCoverageFiltered(node, rx string, rxIn []string, days int,
return scanCoverageRows(rows)
}
// appendRxFilter narrows a client_* table query to one companion (rx) and/or
// to a set of companions (rxIn, lowercase; see myCompanionPubkeys). col is
// the table's rx_pubkey column. Callers return early on an empty non-nil rxIn.
func appendRxFilter(where []string, args []interface{}, col, rx string, rxIn []string) ([]string, []interface{}) {
if rx != "" {
where = append(where, col+" = ?")
args = append(args, strings.ToLower(rx))
}
if len(rxIn) > 0 {
where = append(where, col+" IN ("+sqlPlaceholders(len(rxIn))+")")
for _, pk := range rxIn {
args = append(args, pk)
}
}
return where, args
}
// handleRxCoverage serves global (or per-observer via ?rx=) coverage as GeoJSON
// hexbins, over a time window. ?node= also works (same as the per-node endpoint).
// requireClientRxCoverage writes a 404 and returns false when the opt-in
+40 -25
View File
@@ -19,11 +19,11 @@
var gapRenderer = null;
function gapsAvailable() { return !!window.MC_CLIENT_RF_SAMPLES; }
// mine: "My coverage": the signal layer only shows coverage collected by
// companions linked to the logged-in user (/api/rx-coverage?mine=1).
// mine: "My coverage": every layer only shows data collected by companions
// linked to the logged-in user (?mine=1).
var mine = false;
// signalSeq numbers signal-layer requests; only the newest reply is drawn.
var signalSeq = 0;
// drawSeq numbers layer requests; only the newest reply is drawn.
var drawSeq = 0;
function cssColor(varName) {
try { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || '#888'; }
@@ -51,13 +51,14 @@
' title="Only coverage collected by companions linked to your account">My coverage</button>';
}
// coverageUrl builds the signal-layer request.
function coverageUrl(bbox, z, d, rx, mineOn) {
return '/api/rx-coverage?bbox=' + bbox + '&z=' + z + '&days=' + d +
// coverageUrl builds a layer request (path = the layer's endpoint) with the
// shared filters: time window, selected observer and "My coverage".
function coverageUrl(path, bbox, z, d, rx, mineOn) {
return path + '?bbox=' + bbox + '&z=' + z + '&days=' + d +
(rx ? '&rx=' + encodeURIComponent(rx) : '') + (mineOn ? '&mine=1' : '');
}
// coverageReply judges a signal-layer reply. Only the newest request counts
// coverageReply judges a layer reply. Only the newest request counts
// (an older, unfiltered reply must not overwrite "My coverage"); a 401/403
// to mine=1 means the session is gone, which is not "no coverage".
function coverageReply(seq, latest, status, mineSent) {
@@ -66,6 +67,12 @@
return status >= 200 && status < 300 ? 'draw' : 'error';
}
// freshBody resolves to the parsed body, or to null when a newer request
// started while it was still arriving: coverageReply only saw the headers.
function freshBody(seq, latest, body) {
return body.then(function (fc) { return seq === latest() ? fc : null; });
}
// authUser is the logged-in user, or null (accounts off, logged out, or
// auth.js not loaded).
function authUser(auth) {
@@ -238,23 +245,31 @@
'</div>';
}
function drawSignalLayer(bbox) {
// Until auth.js has answered, mine is never sent (it would answer 401).
var mineSent = mine && !!authUser(window.CSAuth);
// Gaps need a single companion's track, so not with mine (the server omits them too).
var wantGaps = gapsAvailable() && showGaps && !mineSent;
var url = coverageUrl(bbox, map.getZoom(), days, selectedRx, mineSent) + (wantGaps ? '&gaps=1' : '');
var seq = ++signalSeq;
fetch(url).then(function (r) {
var verdict = coverageReply(seq, signalSeq, r.status, mineSent);
// mineSent: until auth.js has answered, mine is never sent (it would answer 401).
function mineSent() { return mine && !!authUser(window.CSAuth); }
// fetchLayer requests one layer's cells with the shared filters. It resolves
// to the FeatureCollection, or to null when request seq was superseded or
// mine=1 was refused (the toggle is then cleared and the map redrawn).
function fetchLayer(path, bbox, extra, seq) {
var sentMine = mineSent();
return fetch(coverageUrl(path, bbox, map.getZoom(), days, selectedRx, sentMine) + extra).then(function (r) {
var verdict = coverageReply(seq, drawSeq, r.status, sentMine);
if (verdict === 'ignore') return null;
if (verdict === 'mine-refused') {
if (!destroyed && map) { mine = false; renderMine(); drawCoverage(); syncHash(); }
return null;
}
if (verdict === 'error') throw new Error('HTTP ' + r.status);
return r.json();
}).then(function (fc) {
return freshBody(seq, function () { return drawSeq; }, r.json());
});
}
function drawSignalLayer(bbox) {
// Gaps need a single companion's track, so not with mine (the server omits them too).
var wantGaps = gapsAvailable() && showGaps && !mineSent();
var seq = ++drawSeq;
fetchLayer('/api/rx-coverage', bbox, wantGaps ? '&gaps=1' : '', seq).then(function (fc) {
if (!fc || destroyed || !covLayer || layer !== 'signal') return;
covLayer.clearLayers();
setNoiseEmpty(false);
@@ -276,14 +291,14 @@
}).catch(function (e) {
console.warn('rx-coverage: coverage fetch failed', e);
// #1: never leave stale hexes from a previous layer/view on screen.
if (!destroyed && covLayer && layer === 'signal' && seq === signalSeq) covLayer.clearLayers();
if (!destroyed && covLayer && layer === 'signal' && seq === drawSeq) covLayer.clearLayers();
});
}
function drawNoiseLayer(bbox) {
var url = '/api/rf-noise?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days;
fetch(url).then(function (r) { return r.json(); }).then(function (fc) {
if (destroyed || !covLayer || layer !== 'noise') return;
var seq = ++drawSeq;
fetchLayer('/api/rf-noise', bbox, '', seq).then(function (fc) {
if (!fc || destroyed || !covLayer || layer !== 'noise') return;
covLayer.clearLayers();
var features = fc.features || [];
setNoiseEmpty(features.length === 0);
@@ -298,7 +313,7 @@
// #1/#2: a slow/failing request must not leave the map showing the
// other layer's hexes under the noise legend, nor sit there blank and
// unlabelled indistinguishable from "no samples in this view".
if (destroyed || !covLayer || layer !== 'noise') return;
if (destroyed || !covLayer || layer !== 'noise' || seq !== drawSeq) return;
covLayer.clearLayers();
setNoiseEmpty(true, 'Could not load noise samples. Pan or zoom to retry.');
});
@@ -614,6 +629,6 @@
}
if (window.addEventListener) window.addEventListener('cs-auth-changed', onAuthChanged);
window.CSRxCoverage = { _test: { coverageUrl: coverageUrl, mineBtnHtml: mineBtnHtml, authUser: authUser, coverageReply: coverageReply } };
window.CSRxCoverage = { _test: { coverageUrl: coverageUrl, mineBtnHtml: mineBtnHtml, authUser: authUser, coverageReply: coverageReply, freshBody: freshBody } };
registerPage('rx-coverage', { init: init, destroy: destroy });
})();
+30 -4
View File
@@ -2,8 +2,9 @@
'use strict';
// RF noise-floor layer on the Mobile RX coverage page: the Signal/Noise toggle
// exists only when clientRfSamples is on, switching draws /api/rf-noise cells
// with the noise legend, the layer is deep-linkable, and an empty answer says
// so instead of leaving a blank map.
// with the noise legend, the layer is deep-linkable, an empty answer says
// so instead of leaving a blank map, and the observer filter and "My coverage"
// narrow the noise layer like the signal layer.
const assert = require('assert');
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:3000';
@@ -20,15 +21,18 @@ async function step(name, fn) {
catch (e) { failed++; console.log(' ✗ ' + name + ': ' + e.message); }
}
async function openPage(browser, { rfSamples, noiseFeatures, hash }) {
async function openPage(browser, { rfSamples, noiseFeatures, hash, user }) {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
const noiseRequests = [];
await page.route('**/api/config/client', async route => {
const response = await route.fetch();
await route.fulfill({ json: { ...await response.json(), clientRxCoverage: true, clientRfSamples: rfSamples } });
const cfg = { ...await response.json(), clientRxCoverage: true, clientRfSamples: rfSamples };
if (user) cfg.userManagement = { enabled: true };
await route.fulfill({ json: cfg });
});
if (user) await page.route('**/api/auth/me', route => route.fulfill({ json: user }));
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 => route.fulfill({ json: { type: 'FeatureCollection', features: [] } }));
@@ -83,6 +87,28 @@ async function openPage(browser, { rfSamples, noiseFeatures, hash }) {
assert.deepStrictEqual(errors, []);
await page.close();
});
await step('the noise layer sends the observer filter and My coverage', async () => {
const rx = 'ab'.repeat(32);
const { page, errors, noiseRequests } = await openPage(browser, {
rfSamples: true, noiseFeatures: [], hash: '?layer=noise&rx=' + rx,
user: { id: 1, email: 'alice@example.org', displayName: 'Alice', role: 'user', csrfToken: 't' },
});
await page.waitForSelector('#rxMineBar button[data-mine]', { timeout: 10000 });
for (let i = 0; i < 50 && !noiseRequests.length; i++) await page.waitForTimeout(100);
assert.ok(noiseRequests.length >= 1, 'a ?layer=noise link must fetch the noise layer');
assert.strictEqual(new URL(noiseRequests[noiseRequests.length - 1]).searchParams.get('rx'), rx, 'rx is sent to /api/rf-noise');
const before = noiseRequests.length;
await page.click('#rxMineBar button[data-mine]');
await page.waitForFunction(() => /[?&]mine=1/.test(location.hash), null, { timeout: 10000 });
for (let i = 0; i < 50 && noiseRequests.length === before; i++) await page.waitForTimeout(100);
assert.ok(noiseRequests.length > before, 'toggling My coverage refetches the noise layer');
const last = new URL(noiseRequests[noiseRequests.length - 1]).searchParams;
assert.strictEqual(last.get('mine'), '1', 'mine=1 is sent to /api/rf-noise');
assert.strictEqual(last.get('rx'), rx, 'the observer filter stays on');
assert.deepStrictEqual(errors, []);
await page.close();
});
} finally {
await browser.close();
}
+32 -13
View File
@@ -2,8 +2,9 @@
const REPO_ROOT = require('path').resolve(__dirname, '..', '..');
// "My coverage" toggle (docs/specs/2026-10-08-companion-linking-design.md,
// Coverage attribution): shown to logged-in users only, it adds mine=1 to the
// signal-layer request. The real public/rx-coverage.js is loaded in a vm, with
// a window that has no addEventListener, like test-rx-coverage-config-race.js.
// request of every layer (signal and noise). The real public/rx-coverage.js
// is loaded in a vm, with a window that has no addEventListener, like
// test-rx-coverage-config-race.js.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
@@ -26,9 +27,10 @@ const t = sandbox.window.CSRxCoverage && sandbox.window.CSRxCoverage._test;
assert.ok(t, 'rx-coverage.js should expose window.CSRxCoverage._test');
// The request URL: mine=1 only with the toggle on, after the rx filter.
assert.strictEqual(t.coverageUrl('1,2,3,4', 9, 7, '', false), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=7');
assert.strictEqual(t.coverageUrl('1,2,3,4', 9, 7, '', true), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=7&mine=1');
assert.strictEqual(t.coverageUrl('1,2,3,4', 9, 30, 'ab cd', true), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=30&rx=ab%20cd&mine=1');
assert.strictEqual(t.coverageUrl('/api/rx-coverage', '1,2,3,4', 9, 7, '', false), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=7');
assert.strictEqual(t.coverageUrl('/api/rx-coverage', '1,2,3,4', 9, 7, '', true), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=7&mine=1');
assert.strictEqual(t.coverageUrl('/api/rx-coverage', '1,2,3,4', 9, 30, 'ab cd', true), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=30&rx=ab%20cd&mine=1');
assert.strictEqual(t.coverageUrl('/api/rf-noise', '1,2,3,4', 9, 14, 'ab', true), '/api/rf-noise?bbox=1,2,3,4&z=9&days=14&rx=ab&mine=1');
// The toggle: nothing for a logged-out visitor, a pressed/unpressed button otherwise.
assert.strictEqual(t.mineBtnHtml(null, false), '', 'no toggle for a logged-out visitor');
@@ -44,13 +46,17 @@ assert.strictEqual(t.authUser({ isEnabled: function () { return false; }, user:
assert.strictEqual(t.authUser({ isEnabled: function () { return true; }, user: function () { return null; } }), null);
assert.strictEqual(t.authUser({ isEnabled: function () { return true; }, user: function () { return u; } }), u);
// Wiring: the signal layer builds its URL with the toggle, and the bar starts hidden.
assert.ok(code.indexOf('var mineSent = mine && !!authUser(window.CSAuth);') !== -1 &&
code.indexOf('coverageUrl(bbox, map.getZoom(), days, selectedRx, mineSent)') !== -1,
'drawSignalLayer must build its URL with coverageUrl and the toggle');
// Wiring: every layer fetches through fetchLayer, which builds its URL with
// the observer filter and the toggle; the bar starts hidden.
assert.ok(code.indexOf('function mineSent() { return mine && !!authUser(window.CSAuth); }') !== -1 &&
code.indexOf('coverageUrl(path, bbox, map.getZoom(), days, selectedRx, sentMine)') !== -1,
'fetchLayer must build its URL with coverageUrl, the observer filter and the toggle');
assert.ok(/fetchLayer\('\/api\/rx-coverage', bbox, /.test(code), 'the signal layer must fetch through fetchLayer');
assert.ok(/fetchLayer\('\/api\/rf-noise', bbox, /.test(code), 'the noise layer must fetch through fetchLayer');
assert.strictEqual((code.match(/fetch\('\/api\/rf-noise/g) || []).length, 0, 'no unfiltered noise fetch');
assert.ok(/id="rxMineBar"[^>]*hidden/.test(code), 'the toggle bar must start hidden');
// A reply only counts when it answers the newest signal-layer request: an
// A reply only counts when it answers the newest layer request: an
// older, unfiltered reply must not overwrite "My coverage" (or the reverse).
assert.strictEqual(t.coverageReply(1, 2, 200, false), 'ignore');
assert.strictEqual(t.coverageReply(2, 2, 200, true), 'draw');
@@ -62,7 +68,20 @@ assert.strictEqual(t.coverageReply(1, 2, 401, true), 'ignore');
// Any other failure is an error, never parsed as a FeatureCollection.
assert.strictEqual(t.coverageReply(2, 2, 401, false), 'error');
assert.strictEqual(t.coverageReply(2, 2, 500, true), 'error');
assert.ok(/coverageReply\(seq, signalSeq, r\.status, mineSent\)/.test(code),
'drawSignalLayer must judge each reply with coverageReply');
assert.ok(/coverageReply\(seq, drawSeq, r\.status, sentMine\)/.test(code),
'fetchLayer must judge each reply with coverageReply');
console.log('rx-coverage My coverage OK');
// The headers can be newest while the body is still on its way: a newer
// request started in between must win when the old body lands (#2326).
(async function () {
let latest = 1;
let finish;
const late = t.freshBody(1, function () { return latest; }, new Promise(function (r) { finish = r; }));
latest = 2;
finish({ features: [1] });
assert.strictEqual(await late, null, 'a body that lands after a newer request must be dropped');
assert.deepStrictEqual(await t.freshBody(2, function () { return latest; }, Promise.resolve({ features: [2] })), { features: [2] });
assert.ok(/freshBody\(seq, function \(\) \{ return drawSeq; \}, r\.json\(\)\)/.test(code),
'fetchLayer must judge the body again when it lands');
console.log('rx-coverage My coverage OK');
})().catch(function (e) { console.error(e); process.exit(1); });