mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:57:57 +00:00
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:
1 parent
417c8dd00a
commit
bb3c8cc001
7 files changed
+201
-56
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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); });
|
||||
Reference in new issue
Block a user