From ad4d19392fbbdf6340c69a4bbeece371411828ed Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 14 Oct 2026 14:23:54 +0200 Subject: [PATCH 1/2] feat(compare): reference-to-many observer comparison over the full window (#122) Relates to #122 Server: GET /api/analytics/observer-comparison computes exact packet overlap between one reference observer and up to 40 compared observers, from a single indexed scan over observations.timestamp (not a browser sample, not transmissions.first_seen). Direction is matched case-insensitively to RX only; TX and unknown/NULL never count. Bounded, cached (30s, #430-style ceiling + reject-before-work) and singleflighted. Frontend: compare.js gains a "One vs. many" mode alongside the existing two-observer workflow (unchanged), with a sortable comparison table, drill-down into the pairwise view, deep-linkable state, and a remembered reference observer. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/deploy.yml | 8 + cmd/server/observer_comparison.go | 526 ++++++++++++ cmd/server/observer_comparison_perf_test.go | 218 +++++ cmd/server/observer_comparison_test.go | 798 ++++++++++++++++++ cmd/server/openapi.go | 7 + cmd/server/routes.go | 5 + public/app.js | 2 +- public/compare.js | 446 +++++++++- public/style.css | 27 + scripts/e2e-shards.json | 8 + test-all.sh | 1 + .../seed-122-observer-comparison.sql | 69 ++ test-issue-122-compare-reference-many-e2e.js | 173 ++++ test-issue-122-compare-reference-many.js | 162 ++++ test-issue-1644-redesign.js | 10 +- 15 files changed, 2428 insertions(+), 32 deletions(-) create mode 100644 cmd/server/observer_comparison.go create mode 100644 cmd/server/observer_comparison_perf_test.go create mode 100644 cmd/server/observer_comparison_test.go create mode 100644 test-fixtures/seed-122-observer-comparison.sql create mode 100644 test-issue-122-compare-reference-many-e2e.js create mode 100644 test-issue-122-compare-reference-many.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bb98582f3..c5d5222f8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -407,6 +407,14 @@ jobs: # header for the rows and the expected interval estimates. run: sqlite3 test-fixtures/e2e-fixture.db < test-fixtures/seed-245-advert-intervals.sql + - name: Seed reference-to-many overlap data for #122 E2E + # The fixture's observations.direction is NULL throughout (it + # predates that field), which the #122 endpoint correctly treats + # as "not RX" rather than guessing -- so without this seed every + # comparison would be all-zero. See the file header for the exact + # overlap numbers (matches the issue's own 60%/75% example). + run: sqlite3 test-fixtures/e2e-fixture.db < test-fixtures/seed-122-observer-comparison.sql + - name: Start Go server with fixture DB run: | fuser -k "$E2E_PORT"/tcp 2>/dev/null || true diff --git a/cmd/server/observer_comparison.go b/cmd/server/observer_comparison.go new file mode 100644 index 000000000..8a0773e82 --- /dev/null +++ b/cmd/server/observer_comparison.go @@ -0,0 +1,526 @@ +package main + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "math" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +// GET /api/analytics/observer-comparison?ref=&hours=1|6|24&observers= (#122). +// +// Reference-to-many observer comparison, descriptive only (no SNR, no health +// score -- see docs/api-spec.md). Fixes the browser-sample problem the +// existing pairwise compare.js overlap has (public/compare.js, #129/#1646): +// that mode requests at most 10,000 packets per observer over a hardcoded +// 24h window and intersects them client-side, so a busy observer is compared +// on an incomplete subset. This endpoint instead aggregates every eligible +// RX observation server-side, over the caller's chosen window, via a single +// indexed scan (scanObserverComparisonPairs) -- O(matched observations), not +// O(observers x packets) and not bounded by any packet-list page size. +const ( + // observerComparisonMaxObservers bounds how many "other" observers one + // request may compare against the reference. The UI drives this from + // the eligible-observer dropdown (a few dozen entries in practice); this + // is a hard ceiling against a request asking for an unbounded comparison + // set. Exceeding it is a 400, not a silent partial comparison (#122: + // "never silently truncate the aggregate population"). + observerComparisonMaxObservers = 40 + + // observerComparisonObserversParamMaxLen caps the raw "observers" query + // value's length, rejected before it is split or looked up, so a + // pathological string cannot drive any work at all. + observerComparisonObserversParamMaxLen = 4096 + + observerComparisonCacheTTL = 30 * time.Second + observerComparisonCacheMaxKeys = 64 +) + +// observerComparisonScanRowLimit hard-caps the number of distinct (observer, +// transmission) RX pairs one request's scan may return. The window (<=24h) +// and observer count (<=observerComparisonMaxObservers) already bound the +// scan to recent, requested traffic rather than full table history; this is +// a backstop against one pathologically busy window. Hitting it fails the +// request closed with an explicit error (#122) instead of returning +// truncated counts that would look exact. +// +// var (not const), mirroring reachScanRowLimit (node_reach.go): tests lower +// it to exercise the limit without inserting a million rows. +var observerComparisonScanRowLimit = 1_000_000 + +// observerComparisonAllowedHours is the MVP's fixed window whitelist (#122 +// "Offer 1-hour, 6-hour, and 24-hour windows initially"). +var observerComparisonAllowedHours = map[int]bool{1: true, 6: true, 24: true} + +// ObserverComparisonWindow is the exact UTC [Start, End) interval the +// comparison was computed over, based on observations.timestamp -- not +// transmissions.first_seen -- so a transmission re-observed inside the +// window counts even when it was first seen before it (#122). +type ObserverComparisonWindow struct { + Hours int `json:"hours"` + Start string `json:"start"` // RFC3339 UTC, inclusive + End string `json:"end"` // RFC3339 UTC, exclusive +} + +// ObserverComparisonReference is the chosen reference observer and the size +// of its own RX set in the window (|R|). +type ObserverComparisonReference struct { + ID string `json:"id"` + Name string `json:"name"` + Total int `json:"total"` +} + +// ObserverComparisonRow is one other observer's overlap with the reference. +// *CoveragePct is nil when its denominator is zero -- #122: "return an +// unavailable percentage ... rather than presenting a misleading 0%". +type ObserverComparisonRow struct { + ObserverID string `json:"observerId"` + ObserverName string `json:"observerName"` + Total int `json:"total"` // |O| + Shared int `json:"shared"` // |S| = |R ∩ O| + ReferenceOnly int `json:"referenceOnly"` // |R|-|S| + OtherOnly int `json:"otherOnly"` // |O|-|S| + ReferenceCoveragePct *float64 `json:"referenceCoveragePct"` // 100*|S|/|R| + OtherCoveragePct *float64 `json:"otherCoveragePct"` // 100*|S|/|O| +} + +// ObserverComparisonResponse is the full response body. No map[string]any: +// every field is named (#122). +type ObserverComparisonResponse struct { + Reference ObserverComparisonReference `json:"reference"` + Window ObserverComparisonWindow `json:"window"` + Rows []ObserverComparisonRow `json:"rows"` +} + +// observerIdentity is one resolved, currently-active observers row. +type observerIdentity struct { + RowID int64 + ID string + Name string +} + +// observationPair is one distinct (observer rowid, transmission id) RX +// observation inside a window -- the only shape the comparison needs. +type observationPair struct { + ObserverRowID int64 + TransmissionID int64 +} + +// resolveObserverIdentities looks up rowid/name for the given observer ids, +// restricted to active observers (inactive IS NULL OR inactive = 0 -- the +// same visibility GetObservers already applies). An id that does not exist +// or is inactive is simply absent from the result; callers decide what an +// absence means for the reference vs. the compared set. +func (db *DB) resolveObserverIdentities(ids []string) (map[string]observerIdentity, error) { + if len(ids) == 0 { + return map[string]observerIdentity{}, nil + } + placeholders := strings.Repeat("?,", len(ids)) + placeholders = placeholders[:len(placeholders)-1] + args := make([]interface{}, len(ids)) + for i, id := range ids { + args[i] = id + } + rows, err := db.conn.Query( + `SELECT rowid, id, name FROM observers WHERE id IN (`+placeholders+`) AND (inactive IS NULL OR inactive = 0)`, + args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := make(map[string]observerIdentity, len(ids)) + for rows.Next() { + var rowID int64 + var id string + var name sql.NullString + if err := rows.Scan(&rowID, &id, &name); err != nil { + return nil, err + } + displayName := id + if name.Valid && name.String != "" { + displayName = name.String + } + out[id] = observerIdentity{RowID: rowID, ID: id, Name: displayName} + } + return out, rows.Err() +} + +// scanObserverComparisonPairs returns every distinct (observer_idx, +// transmission_id) RX observation for the given observer rowids inside +// [start, end). It reads the composite (observer_idx, timestamp) index +// (migration obs_observer_ts_idx_v1), so the scan is bounded by the +// requested window and observer set, not by total table size (#122 perf). +// +// "RX" is matched case-insensitively; NULL or any other value (including +// "TX") is excluded -- #122: an unknown direction must never silently count +// as RX. +func (db *DB) scanObserverComparisonPairs(rowIDs []int64, start, end time.Time) ([]observationPair, error) { + placeholders := strings.Repeat("?,", len(rowIDs)) + placeholders = placeholders[:len(placeholders)-1] + args := make([]interface{}, 0, len(rowIDs)+2) + for _, id := range rowIDs { + args = append(args, id) + } + args = append(args, start.Unix(), end.Unix()) + + rows, err := db.conn.Query( + `SELECT DISTINCT observer_idx, transmission_id FROM observations + WHERE observer_idx IN (`+placeholders+`) + AND timestamp >= ? AND timestamp < ? + AND UPPER(direction) = 'RX'`, + args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]observationPair, 0, 1024) + for rows.Next() { + if len(out) >= observerComparisonScanRowLimit { + return nil, fmt.Errorf( + "observer comparison window too large: more than %d matching observations; narrow the window or reduce the compared observers", + observerComparisonScanRowLimit) + } + var p observationPair + if err := rows.Scan(&p.ObserverRowID, &p.TransmissionID); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// computeObserverComparison aggregates scanned RX pairs into per-observer +// overlap rows against the reference. Two linear passes over pairs -- +// O(len(pairs)), independent of how many "other" observers are compared. +// There is no per-observer re-scan, so this is not O(observers x packets). +func computeObserverComparison(refRowID int64, others []observerIdentity, pairs []observationPair) (refTotal int, rows []ObserverComparisonRow) { + refSet := make(map[int64]struct{}, len(pairs)) + for _, p := range pairs { + if p.ObserverRowID == refRowID { + refSet[p.TransmissionID] = struct{}{} + } + } + refTotal = len(refSet) + + total := make(map[int64]int, len(others)) + shared := make(map[int64]int, len(others)) + for _, p := range pairs { + if p.ObserverRowID == refRowID { + continue + } + total[p.ObserverRowID]++ + if _, ok := refSet[p.TransmissionID]; ok { + shared[p.ObserverRowID]++ + } + } + + rows = make([]ObserverComparisonRow, 0, len(others)) + for _, o := range others { + s := shared[o.RowID] + t := total[o.RowID] + row := ObserverComparisonRow{ + ObserverID: o.ID, + ObserverName: o.Name, + Total: t, + Shared: s, + ReferenceOnly: refTotal - s, + OtherOnly: t - s, + } + if refTotal > 0 { + v := roundPct(float64(s) / float64(refTotal)) + row.ReferenceCoveragePct = &v + } + if t > 0 { + v := roundPct(float64(s) / float64(t)) + row.OtherCoveragePct = &v + } + rows = append(rows, row) + } + sort.Slice(rows, func(i, j int) bool { + if ni, nj := strings.ToLower(rows[i].ObserverName), strings.ToLower(rows[j].ObserverName); ni != nj { + return ni < nj + } + return rows[i].ObserverID < rows[j].ObserverID + }) + return refTotal, rows +} + +// roundPct rounds a 0..1 fraction to a percentage with one decimal place, +// matching the existing pairwise compare.js computeOverlapStats rounding. +func roundPct(frac float64) float64 { + return math.Round(frac*1000) / 10 +} + +// observerComparisonCacheEntry is one cached response, with its own expiry. +type observerComparisonCacheEntry struct { + resp *ObserverComparisonResponse + expires time.Time +} + +// observerComparisonCache is usable as a zero value. Keyed by +// observerComparisonCacheKey (ref|hours|sorted other ids); bounded to +// observerComparisonCacheMaxKeys entries following the #430 pattern +// (evict expired first, then the one nearest expiry), with concurrent +// misses for one key sharing a single computation (singleflight). A stored +// response is never mutated after put, so it is safe to hand the same +// pointer to every caller of a cache hit. +type observerComparisonCache struct { + mu sync.Mutex + entries map[string]*observerComparisonCacheEntry + flights singleflight.Group +} + +func (c *observerComparisonCache) get(key string, now time.Time) (*ObserverComparisonResponse, bool) { + c.mu.Lock() + defer c.mu.Unlock() + e := c.entries[key] + if e == nil || !now.Before(e.expires) { + return nil, false + } + return e.resp, true +} + +func (c *observerComparisonCache) put(key string, resp *ObserverComparisonResponse, now time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + if c.entries == nil { + c.entries = make(map[string]*observerComparisonCacheEntry) + } + if _, exists := c.entries[key]; !exists && len(c.entries) >= observerComparisonCacheMaxKeys { + for k, old := range c.entries { + if !now.Before(old.expires) { + delete(c.entries, k) + } + } + if len(c.entries) >= observerComparisonCacheMaxKeys { + victim, first := "", true + var soonest time.Time + for k, old := range c.entries { + switch { + case first, old.expires.Before(soonest): + victim, soonest, first = k, old.expires, false + case old.expires.Equal(soonest) && k < victim: + victim = k + } + } + delete(c.entries, victim) + } + } + c.entries[key] = &observerComparisonCacheEntry{resp: resp, expires: now.Add(observerComparisonCacheTTL)} +} + +// len reports the current entry count (tests). +func (c *observerComparisonCache) len() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.entries) +} + +// reset drops every entry (tests). +func (c *observerComparisonCache) reset() { + c.mu.Lock() + c.entries = nil + c.mu.Unlock() +} + +// load returns the cached response for key or, on a miss, the result of one +// computation shared by every concurrent caller of key (#122 singleflight). +// The cache is re-checked inside the flight so a caller that missed just +// before another flight stored a fresh entry does not recompute. +func (c *observerComparisonCache) load(key string, now time.Time, compute func() (*ObserverComparisonResponse, error)) (*ObserverComparisonResponse, error) { + if resp, ok := c.get(key, now); ok { + return resp, nil + } + v, err, _ := c.flights.Do(key, func() (interface{}, error) { + if resp, ok := c.get(key, time.Now()); ok { + return resp, nil + } + resp, err := compute() + if err != nil { + return nil, err + } + c.put(key, resp, time.Now()) + return resp, nil + }) + if err != nil { + return nil, err + } + return v.(*ObserverComparisonResponse), nil +} + +// observerComparisonCacheKey builds the cache/flight key for one request. +// Bounded to observerComparisonCacheMaxKeyBytes: a longer key is stored (and +// used as the flight key) as its SHA-256 digest, mirroring +// channelListStoreKey (channels_list_cache.go). +const observerComparisonCacheMaxKeyBytes = 512 + +func observerComparisonCacheKey(refID string, hours int, others []observerIdentity) string { + ids := make([]string, len(others)) + for i, o := range others { + ids[i] = o.ID + } + sort.Strings(ids) + key := refID + "|" + strconv.Itoa(hours) + "|" + strings.Join(ids, ",") + if len(key) <= observerComparisonCacheMaxKeyBytes { + return key + } + sum := sha256.Sum256([]byte(key)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// parseObserverComparisonObserverIDs splits and de-duplicates the +// "observers" query value. Returns an error if refID appears in the list +// (the reference must not compare against itself -- the same invariant +// public/compare.js already enforces for the two-observer mode via +// isComparisonReady). +func parseObserverComparisonObserverIDs(raw, refID string) ([]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + seen := make(map[string]bool, 8) + out := make([]string, 0, 8) + for _, part := range strings.Split(raw, ",") { + id := strings.TrimSpace(part) + if id == "" { + continue + } + if id == refID { + return nil, errors.New("observers must not include the reference observer") + } + if seen[id] { + continue + } + seen[id] = true + out = append(out, id) + } + return out, nil +} + +// handleObserverComparison implements GET /api/analytics/observer-comparison +// (#122). All parameter validation happens before any cache lookup, DB +// query, or singleflight -- the #430 "reject before work" pattern. +func (s *Server) handleObserverComparison(w http.ResponseWriter, r *http.Request) { + if s.db == nil || !s.db.isV3() { + writeError(w, 400, "observer comparison requires the v3 observations schema") + return + } + + refID := strings.TrimSpace(r.URL.Query().Get("ref")) + if refID == "" { + writeError(w, 400, "ref is required") + return + } + + hours, err := strconv.Atoi(r.URL.Query().Get("hours")) + if err != nil || !observerComparisonAllowedHours[hours] { + writeError(w, 400, "hours must be one of 1, 6, 24") + return + } + + observersRaw := r.URL.Query().Get("observers") + if len(observersRaw) > observerComparisonObserversParamMaxLen { + writeError(w, 400, "observers filter too long") + return + } + otherIDs, err := parseObserverComparisonObserverIDs(observersRaw, refID) + if err != nil { + writeError(w, 400, err.Error()) + return + } + if len(otherIDs) == 0 { + writeError(w, 400, "observers is required (comma-separated observer ids)") + return + } + if len(otherIDs) > observerComparisonMaxObservers { + writeError(w, 400, fmt.Sprintf("too many observers: %d requested, %d max", len(otherIDs), observerComparisonMaxObservers)) + return + } + + if s.cfg != nil && s.cfg.IsObserverBlacklisted(refID) { + writeError(w, 404, "observer not found") + return + } + + allIDs := make([]string, 0, len(otherIDs)+1) + allIDs = append(allIDs, refID) + allIDs = append(allIDs, otherIDs...) + identities, err := s.db.resolveObserverIdentities(allIDs) + if err != nil { + writeError(w, 500, err.Error()) + return + } + ref, ok := identities[refID] + if !ok { + writeError(w, 404, "observer not found") + return + } + + others := make([]observerIdentity, 0, len(otherIDs)) + for _, id := range otherIDs { + o, ok := identities[id] + if !ok { + continue // unknown or inactive -- silently excluded (visibility policy, not an error) + } + if s.cfg != nil && s.cfg.IsObserverBlacklisted(id) { + continue + } + others = append(others, o) + } + if len(others) == 0 { + writeError(w, 400, "no eligible observers to compare") + return + } + + cacheKey := observerComparisonCacheKey(refID, hours, others) + resp, err := s.obsComparison.load(cacheKey, time.Now(), func() (*ObserverComparisonResponse, error) { + return s.computeObserverComparisonResponse(ref, others, hours) + }) + if err != nil { + writeError(w, 500, err.Error()) + return + } + writeJSON(w, resp) +} + +// computeObserverComparisonResponse runs the DB scan and builds the typed +// response for one (reference, others, hours) request. Not cached itself -- +// handleObserverComparison wraps it in s.obsComparison.load. +func (s *Server) computeObserverComparisonResponse(ref observerIdentity, others []observerIdentity, hours int) (*ObserverComparisonResponse, error) { + end := time.Now().UTC().Truncate(time.Second) + start := end.Add(-time.Duration(hours) * time.Hour) + + rowIDs := make([]int64, 0, len(others)+1) + rowIDs = append(rowIDs, ref.RowID) + for _, o := range others { + rowIDs = append(rowIDs, o.RowID) + } + + pairs, err := s.db.scanObserverComparisonPairs(rowIDs, start, end) + if err != nil { + return nil, err + } + + refTotal, rows := computeObserverComparison(ref.RowID, others, pairs) + + return &ObserverComparisonResponse{ + Reference: ObserverComparisonReference{ID: ref.ID, Name: ref.Name, Total: refTotal}, + Window: ObserverComparisonWindow{ + Hours: hours, + Start: start.Format(time.RFC3339), + End: end.Format(time.RFC3339), + }, + Rows: rows, + }, nil +} diff --git a/cmd/server/observer_comparison_perf_test.go b/cmd/server/observer_comparison_perf_test.go new file mode 100644 index 000000000..cb49e0844 --- /dev/null +++ b/cmd/server/observer_comparison_perf_test.go @@ -0,0 +1,218 @@ +package main + +import ( + "fmt" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" +) + +// TestObserverComparisonPerf_RealisticFixtureEndToEnd is the AGENTS.md rule +// 0 perf proof for #122: the reference-to-many comparison must scan a +// realistic multi-observer dataset via the composite (observer_idx, +// timestamp) index, not via one query per compared observer and not by +// loading every packet into the browser (the problem this issue replaces). +// +// Dataset: 30,000 transmissions, 8 observers (1 reference + 7 compared), +// each transmission RX-observed by about 3 of the 8 observers (~90K +// observation rows) -- same fan-out shape as the staging #wardriving +// channel referenced by db_channel_messages_perf_test.go, sized for #122's +// "30K+ transmissions and many observers" without making `go test -race` +// (the CI configuration, deploy.yml) too slow: measured on this machine, +// the real implementation takes ~4.7s under -race (vs ~0.3s without it); +// a mutant that issues one query per compared observer instead of one bulk +// query -- the per-API-call anti-pattern #122 explicitly forbids -- took +// ~10.2s under -race for the same fixture, more than double. The budget +// below is set well above the real implementation's measured time (not at +// the mutant's) so CI noise cannot make a correct change flaky. +func TestObserverComparisonPerf_RealisticFixtureEndToEnd(t *testing.T) { + if testing.Short() { + t.Skip("perf test") + } + db := setupTestDB(t) + defer db.Close() + + const numObservers = 8 + const numTx = 30000 + + observerRowIDs := make([]int64, numObservers) + observerIDs := make([]string, numObservers) + for i := 0; i < numObservers; i++ { + id := fmt.Sprintf("perf-obs-%02d", i) + res, err := db.conn.Exec(`INSERT INTO observers (id, name) VALUES (?, ?)`, id, id) + if err != nil { + t.Fatal(err) + } + rowID, _ := res.LastInsertId() + observerRowIDs[i] = rowID + observerIDs[i] = id + } + + txTx, err := db.conn.Begin() + if err != nil { + t.Fatal(err) + } + txStmt, err := txTx.Prepare(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type) VALUES (?, ?, ?, 1, 1)`) + if err != nil { + t.Fatal(err) + } + obsStmt, err := txTx.Prepare(`INSERT INTO observations (transmission_id, observer_idx, direction, path_json, timestamp) VALUES (?, ?, 'RX', '[]', ?)`) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + base := now.Add(-23 * time.Hour) // comfortably inside a 24h window, never racing "now" + observationRows := 0 + for i := 0; i < numTx; i++ { + ts := base.Add(time.Duration(i) * time.Second).Format(time.RFC3339) + hash := fmt.Sprintf("perf122hash%08d", i) + res, err := txStmt.Exec(fmt.Sprintf("%08X", i), hash, ts) + if err != nil { + t.Fatal(err) + } + txID, _ := res.LastInsertId() + obsTS := base.Add(time.Duration(i) * time.Second).Unix() + // Deterministic ~3-of-8 fan-out per transmission (no randomness, so + // the test is reproducible): observer o observes tx i when + // (i*31 + o*17) % 8 < 3. + for o := 0; o < numObservers; o++ { + if (i*31+o*17)%8 >= 3 { + continue + } + if _, err := obsStmt.Exec(txID, observerRowIDs[o], obsTS); err != nil { + t.Fatal(err) + } + observationRows++ + } + } + if err := txTx.Commit(); err != nil { + t.Fatal(err) + } + t.Logf("fixture: %d transmissions, %d observers, %d observation rows", numTx, numObservers, observationRows) + + srv := NewServer(db, &Config{}, NewHub()) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + url := "/api/analytics/observer-comparison?ref=" + observerIDs[0] + "&hours=24&observers=" + for i := 1; i < numObservers; i++ { + if i > 1 { + url += "," + } + url += observerIDs[i] + } + + // Warm-up call to amortize first-run query-plan/prepare cost, matching + // the db_channel_messages_perf_test.go convention. + warm := httptest.NewRecorder() + router.ServeHTTP(warm, httptest.NewRequest("GET", url, nil)) + if warm.Code != 200 { + t.Fatalf("warm-up status = %d body=%s", warm.Code, warm.Body.String()) + } + srv.obsComparison.reset() // measure a real cold computation, not the cache hit + + start := time.Now() + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", url, nil)) + elapsed := time.Since(start) + if w.Code != 200 { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + resp := decodeComparisonResponse(t, w) + if len(resp.Rows) != numObservers-1 { + t.Fatalf("rows = %d, want %d", len(resp.Rows), numObservers-1) + } + + // Budget set ~2.5x above the real implementation's measured -race time + // (~4.7s; see the doc comment above) so CI noise cannot make a correct + // change flaky, while still sitting below the measured one-query-per- + // observer mutant's ~10.2s. + const budget = 12 * time.Second + if elapsed > budget { + t.Fatalf("observer comparison too slow: %v (budget %v) on %d tx x %d observers (%d obs rows)", + elapsed, budget, numTx, numObservers, observationRows) + } + t.Logf("comparison against %d observers over %d matching rows took %v", numObservers-1, observationRows, elapsed) +} + +// buildComparisonFixture returns a reference plus numOthers observers and a +// pairs slice where the reference and each other observer each contribute +// exactly pairsPerObserver distinct transmissions, chosen so total pairs +// grows linearly with numOthers (not with numOthers^2). +func buildComparisonFixture(numOthers, pairsPerObserver int) (others []observerIdentity, pairs []observationPair) { + others = make([]observerIdentity, numOthers) + for i := range others { + others[i] = observerIdentity{RowID: int64(i + 2), ID: fmt.Sprintf("o%d", i), Name: fmt.Sprintf("Observer %d", i)} + } + // Reference's own pairs (rowid 1), disjoint tx id space per observer so + // nothing degenerates into an all-shared or all-disjoint special case. + nextTx := int64(0) + for i := 0; i < pairsPerObserver; i++ { + pairs = append(pairs, observationPair{ObserverRowID: 1, TransmissionID: nextTx}) + nextTx++ + } + for _, o := range others { + for i := 0; i < pairsPerObserver; i++ { + pairs = append(pairs, observationPair{ObserverRowID: o.RowID, TransmissionID: nextTx}) + nextTx++ + } + } + return others, pairs +} + +// TestComputeObserverComparison_ScalesLinearlyInObservers is a regression +// guard for #122 "no O(n²) across observers x packets": with a fixed +// per-observer workload, total input size grows linearly with the number +// of compared observers, and so must computeObserverComparison's time. The +// real mutant proof for the O(n²) requirement lives at the level #122 +// actually calls out ("bounded, cached/coalesced bulk aggregation rather +// than one API call per observer"): see +// TestObserverComparisonPerf_RealisticFixtureEndToEnd's doc comment, which +// records a per-observer-rescan mutant that measurably broke that test's +// budget. A pure-Go nested rescan of computeObserverComparison's pairs +// slice was tried here too, but at this function's realistic input sizes a +// branch-predicted integer-compare skip is cheap enough that it did not +// reliably blow this test's budget -- so this test is kept as a linear- +// scaling sanity check on the shipped implementation, not as that mutant's +// proof. +func TestComputeObserverComparison_ScalesLinearlyInObservers(t *testing.T) { + if testing.Short() { + t.Skip("perf test") + } + const pairsPerObserver = 300_000 + const smallN = 4 + const bigN = observerComparisonMaxObservers // 40 + const scale = float64(bigN) / float64(smallN) + + smallOthers, smallPairs := buildComparisonFixture(smallN, pairsPerObserver) + start := time.Now() + computeObserverComparison(1, smallOthers, smallPairs) + smallElapsed := time.Since(start) + + bigOthers, bigPairs := buildComparisonFixture(bigN, pairsPerObserver) + start = time.Now() + computeObserverComparison(1, bigOthers, bigPairs) + bigElapsed := time.Since(start) + + // Linear (O(pairs)): bigElapsed ~= scale * smallElapsed. Quadratic + // (O(observers x pairs)) would instead give ~= scale^2 * smallElapsed + // (10x for smallN=4, bigN=40). A tolerance of 3x the linear prediction + // comfortably passes the real implementation's noise while clearly + // rejecting a quadratic regression. + const tolerance = 3.0 + budget := time.Duration(float64(smallElapsed) * scale * tolerance) + // Measurements at this size can be a handful of milliseconds; floor the + // budget so sub-millisecond timer noise in smallElapsed can't make it + // unrealistically tight. + if budget < 50*time.Millisecond { + budget = 50 * time.Millisecond + } + if bigElapsed > budget { + t.Fatalf("computeObserverComparison does not scale linearly in observers: %d others took %v, %d others took %v (budget %v = %dx baseline x %.0fx tolerance) -- looks O(observers x pairs)", + smallN, smallElapsed, bigN, bigElapsed, budget, int(scale), tolerance) + } + t.Logf("%d others: %v; %d others: %v (linear prediction %v, budget %v)", + smallN, smallElapsed, bigN, bigElapsed, time.Duration(float64(smallElapsed)*scale), budget) +} diff --git a/cmd/server/observer_comparison_test.go b/cmd/server/observer_comparison_test.go new file mode 100644 index 000000000..58162e951 --- /dev/null +++ b/cmd/server/observer_comparison_test.go @@ -0,0 +1,798 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/gorilla/mux" +) + +// ---- test fixtures -------------------------------------------------- + +// newObsComparisonTestServer returns a fresh v3-schema server with an empty +// DB (no seedTestData) so each test controls its own observers/ +// transmissions/observations precisely. +func newObsComparisonTestServer(t *testing.T) (*Server, *mux.Router, *DB) { + t.Helper() + db := setupTestDB(t) + srv := NewServer(db, &Config{}, NewHub()) + router := mux.NewRouter() + srv.RegisterRoutes(router) + return srv, router, db +} + +func insertComparisonObserver(t *testing.T, db *DB, id, name string, inactive int) int64 { + t.Helper() + res, err := db.conn.Exec(`INSERT INTO observers (id, name, inactive) VALUES (?, ?, ?)`, id, name, inactive) + if err != nil { + t.Fatalf("insert observer %s: %v", id, err) + } + rowID, _ := res.LastInsertId() + return rowID +} + +func insertComparisonTx(t *testing.T, db *DB, hash, firstSeen string) int64 { + t.Helper() + res, err := db.conn.Exec( + `INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type) VALUES ('AA', ?, ?, 1, 1)`, + hash, firstSeen) + if err != nil { + t.Fatalf("insert transmission %s: %v", hash, err) + } + id, _ := res.LastInsertId() + return id +} + +func insertComparisonObs(t *testing.T, db *DB, txID, observerRowID int64, direction string, ts int64, pathJSON string) { + t.Helper() + _, err := db.conn.Exec( + `INSERT INTO observations (transmission_id, observer_idx, direction, path_json, timestamp) VALUES (?, ?, ?, ?, ?)`, + txID, observerRowID, direction, pathJSON, ts) + if err != nil { + t.Fatalf("insert observation tx=%d observer=%d: %v", txID, observerRowID, err) + } +} + +// ---- computeObserverComparison (pure) -------------------------------- + +func TestComputeObserverComparison_EmptySets(t *testing.T) { + others := []observerIdentity{{RowID: 2, ID: "other", Name: "Other"}} + refTotal, rows := computeObserverComparison(1, others, nil) + if refTotal != 0 { + t.Fatalf("refTotal = %d, want 0", refTotal) + } + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + r := rows[0] + if r.Total != 0 || r.Shared != 0 || r.ReferenceOnly != 0 || r.OtherOnly != 0 { + t.Fatalf("expected all-zero counts, got %+v", r) + } + if r.ReferenceCoveragePct != nil { + t.Fatalf("ReferenceCoveragePct = %v, want nil (zero denominator)", *r.ReferenceCoveragePct) + } + if r.OtherCoveragePct != nil { + t.Fatalf("OtherCoveragePct = %v, want nil (zero denominator)", *r.OtherCoveragePct) + } +} + +func TestComputeObserverComparison_ZeroOverlap(t *testing.T) { + // Reference saw tx 1,2; other saw disjoint tx 3,4. + pairs := []observationPair{ + {ObserverRowID: 1, TransmissionID: 1}, + {ObserverRowID: 1, TransmissionID: 2}, + {ObserverRowID: 2, TransmissionID: 3}, + {ObserverRowID: 2, TransmissionID: 4}, + } + others := []observerIdentity{{RowID: 2, ID: "other", Name: "Other"}} + refTotal, rows := computeObserverComparison(1, others, pairs) + if refTotal != 2 { + t.Fatalf("refTotal = %d, want 2", refTotal) + } + r := rows[0] + if r.Total != 2 || r.Shared != 0 || r.ReferenceOnly != 2 || r.OtherOnly != 2 { + t.Fatalf("got %+v", r) + } + if r.ReferenceCoveragePct == nil || *r.ReferenceCoveragePct != 0 { + t.Fatalf("ReferenceCoveragePct = %v, want 0", r.ReferenceCoveragePct) + } + if r.OtherCoveragePct == nil || *r.OtherCoveragePct != 0 { + t.Fatalf("OtherCoveragePct = %v, want 0", r.OtherCoveragePct) + } +} + +func TestComputeObserverComparison_PerfectOverlap(t *testing.T) { + pairs := []observationPair{ + {ObserverRowID: 1, TransmissionID: 1}, + {ObserverRowID: 1, TransmissionID: 2}, + {ObserverRowID: 2, TransmissionID: 1}, + {ObserverRowID: 2, TransmissionID: 2}, + } + others := []observerIdentity{{RowID: 2, ID: "other", Name: "Other"}} + refTotal, rows := computeObserverComparison(1, others, pairs) + if refTotal != 2 { + t.Fatalf("refTotal = %d, want 2", refTotal) + } + r := rows[0] + if r.Total != 2 || r.Shared != 2 || r.ReferenceOnly != 0 || r.OtherOnly != 0 { + t.Fatalf("got %+v", r) + } + if r.ReferenceCoveragePct == nil || *r.ReferenceCoveragePct != 100 { + t.Fatalf("ReferenceCoveragePct = %v, want 100", r.ReferenceCoveragePct) + } + if r.OtherCoveragePct == nil || *r.OtherCoveragePct != 100 { + t.Fatalf("OtherCoveragePct = %v, want 100", r.OtherCoveragePct) + } +} + +// TestComputeObserverComparison_PartialAsymmetric mirrors the issue's own +// worked example: reference = 1000, other = 800, shared = 600 -> 60% of +// reference traffic, 75% of other traffic. Scaled down 100x for a tiny +// fixture; the percentages must match exactly. +func TestComputeObserverComparison_PartialAsymmetric(t *testing.T) { + var pairs []observationPair + // Reference sees tx 1..10 (10 total). + for tx := int64(1); tx <= 10; tx++ { + pairs = append(pairs, observationPair{ObserverRowID: 1, TransmissionID: tx}) + } + // Other sees tx 1..6 (shared, 6) plus tx 100,101 (other-only, 2) = 8 total. + for tx := int64(1); tx <= 6; tx++ { + pairs = append(pairs, observationPair{ObserverRowID: 2, TransmissionID: tx}) + } + pairs = append(pairs, + observationPair{ObserverRowID: 2, TransmissionID: 100}, + observationPair{ObserverRowID: 2, TransmissionID: 101}, + ) + + others := []observerIdentity{{RowID: 2, ID: "other", Name: "Other"}} + refTotal, rows := computeObserverComparison(1, others, pairs) + if refTotal != 10 { + t.Fatalf("refTotal = %d, want 10", refTotal) + } + r := rows[0] + if r.Total != 8 { + t.Fatalf("Total = %d, want 8", r.Total) + } + if r.Shared != 6 { + t.Fatalf("Shared = %d, want 6", r.Shared) + } + if r.ReferenceOnly != 4 { + t.Fatalf("ReferenceOnly = %d, want 4 (1000-600 scaled: 10-6)", r.ReferenceOnly) + } + if r.OtherOnly != 2 { + t.Fatalf("OtherOnly = %d, want 2 (800-600 scaled: 8-6)", r.OtherOnly) + } + if r.ReferenceCoveragePct == nil || *r.ReferenceCoveragePct != 60 { + t.Fatalf("ReferenceCoveragePct = %v, want 60 (matches issue's 60%% example)", r.ReferenceCoveragePct) + } + if r.OtherCoveragePct == nil || *r.OtherCoveragePct != 75 { + t.Fatalf("OtherCoveragePct = %v, want 75 (matches issue's 75%% example)", r.OtherCoveragePct) + } +} + +func TestComputeObserverComparison_RefTotalZero_PctNil(t *testing.T) { + // Reference has no RX pairs at all in the window; other has some. + pairs := []observationPair{ + {ObserverRowID: 2, TransmissionID: 1}, + } + others := []observerIdentity{{RowID: 2, ID: "other", Name: "Other"}} + refTotal, rows := computeObserverComparison(1, others, pairs) + if refTotal != 0 { + t.Fatalf("refTotal = %d, want 0", refTotal) + } + r := rows[0] + if r.ReferenceCoveragePct != nil { + t.Fatalf("ReferenceCoveragePct = %v, want nil when |R|=0 (unavailable, not 0%%)", *r.ReferenceCoveragePct) + } + if r.OtherCoveragePct == nil || *r.OtherCoveragePct != 0 { + t.Fatalf("OtherCoveragePct = %v, want 0 (|O|=1, |S|=0)", r.OtherCoveragePct) + } + if r.OtherOnly != 1 { + t.Fatalf("OtherOnly = %d, want 1", r.OtherOnly) + } +} + +func TestComputeObserverComparison_OtherTotalZero_PctNil(t *testing.T) { + pairs := []observationPair{ + {ObserverRowID: 1, TransmissionID: 1}, + } + others := []observerIdentity{{RowID: 2, ID: "other", Name: "Other"}} + refTotal, rows := computeObserverComparison(1, others, pairs) + if refTotal != 1 { + t.Fatalf("refTotal = %d, want 1", refTotal) + } + r := rows[0] + if r.OtherCoveragePct != nil { + t.Fatalf("OtherCoveragePct = %v, want nil when |O|=0", *r.OtherCoveragePct) + } + if r.ReferenceCoveragePct == nil || *r.ReferenceCoveragePct != 0 { + t.Fatalf("ReferenceCoveragePct = %v, want 0", r.ReferenceCoveragePct) + } +} + +func TestComputeObserverComparison_MultipleOthersSortedByName(t *testing.T) { + pairs := []observationPair{ + {ObserverRowID: 1, TransmissionID: 1}, + {ObserverRowID: 1, TransmissionID: 2}, + {ObserverRowID: 2, TransmissionID: 1}, // "Zed" + {ObserverRowID: 3, TransmissionID: 2}, // "Alpha" + } + others := []observerIdentity{ + {RowID: 2, ID: "obsZ", Name: "Zed"}, + {RowID: 3, ID: "obsA", Name: "Alpha"}, + } + _, rows := computeObserverComparison(1, others, pairs) + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + if rows[0].ObserverName != "Alpha" || rows[1].ObserverName != "Zed" { + t.Fatalf("rows not sorted by name: %+v", rows) + } +} + +// ---- parseObserverComparisonObserverIDs ------------------------------ + +func TestParseObserverComparisonObserverIDs(t *testing.T) { + cases := []struct { + name string + raw string + ref string + want []string + wantErr bool + }{ + {"empty", "", "ref", nil, false}, + {"single", "a", "ref", []string{"a"}, false}, + {"dedupe and trim", " a, b ,a,b", "ref", []string{"a", "b"}, false}, + {"ref in list", "a,ref,b", "ref", nil, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := parseObserverComparisonObserverIDs(c.raw, c.ref) + if c.wantErr { + if err == nil { + t.Fatalf("expected error, got %v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(c.want) { + t.Fatalf("got %v, want %v", got, c.want) + } + for i := range got { + if got[i] != c.want[i] { + t.Fatalf("got %v, want %v", got, c.want) + } + } + }) + } +} + +// ---- observerComparisonCacheKey --------------------------------------- + +func TestObserverComparisonCacheKey_OrderIndependent(t *testing.T) { + a := []observerIdentity{{ID: "b"}, {ID: "a"}, {ID: "c"}} + b := []observerIdentity{{ID: "c"}, {ID: "a"}, {ID: "b"}} + k1 := observerComparisonCacheKey("ref", 24, a) + k2 := observerComparisonCacheKey("ref", 24, b) + if k1 != k2 { + t.Fatalf("keys differ by input order: %q vs %q", k1, k2) + } +} + +func TestObserverComparisonCacheKey_DifferentHoursDiffer(t *testing.T) { + others := []observerIdentity{{ID: "a"}} + if observerComparisonCacheKey("ref", 1, others) == observerComparisonCacheKey("ref", 24, others) { + t.Fatal("keys for different windows must differ") + } +} + +func TestObserverComparisonCacheKey_LongKeyHashed(t *testing.T) { + others := make([]observerIdentity, 0, 40) + for i := 0; i < 40; i++ { + others = append(others, observerIdentity{ID: fmt.Sprintf("observer-with-a-fairly-long-id-%03d", i)}) + } + key := observerComparisonCacheKey("reference-observer-id", 24, others) + if len(key) > observerComparisonCacheMaxKeyBytes { + t.Fatalf("key not bounded: %d bytes", len(key)) + } + if !strings.HasPrefix(key, "sha256:") { + t.Fatalf("expected a hashed key, got %q", key) + } +} + +// ---- observerComparisonCache (ceiling + TTL + singleflight) ---------- + +func TestObserverComparisonCache_TTLExpiry(t *testing.T) { + c := &observerComparisonCache{} + now := time.Now() + c.put("k", &ObserverComparisonResponse{}, now) + if _, ok := c.get("k", now.Add(observerComparisonCacheTTL-time.Millisecond)); !ok { + t.Fatal("expected a hit just before TTL expiry") + } + if _, ok := c.get("k", now.Add(observerComparisonCacheTTL+time.Millisecond)); ok { + t.Fatal("expected a miss just after TTL expiry") + } +} + +// TestObserverComparisonCache_CeilingEvictsNearestExpiry is the #430 +// pattern's mutant target: fill the cache to its ceiling with entries at +// staggered expiries, then insert one more NEW key. Exactly the entry +// nearest expiry must be evicted; a refresh of an EXISTING key must never +// evict anything else. +func TestObserverComparisonCache_CeilingEvictsNearestExpiry(t *testing.T) { + c := &observerComparisonCache{} + base := time.Now() + for i := 0; i < observerComparisonCacheMaxKeys; i++ { + key := fmt.Sprintf("k%03d", i) + // Staggered expiry: k000 expires soonest. + c.entries = ensureEntries(c.entries) + c.put(key, &ObserverComparisonResponse{}, base.Add(time.Duration(i)*time.Second-observerComparisonCacheTTL)) + } + if c.len() != observerComparisonCacheMaxKeys { + t.Fatalf("len = %d, want %d", c.len(), observerComparisonCacheMaxKeys) + } + + // Refreshing an existing key must not evict any other entry. + c.put("k005", &ObserverComparisonResponse{}, base) + if c.len() != observerComparisonCacheMaxKeys { + t.Fatalf("refreshing an existing key changed len to %d", c.len()) + } + + // A brand new key past the ceiling evicts exactly the one nearest + // expiry (k000, since none have expired relative to `base`). + c.put("new-key", &ObserverComparisonResponse{}, base) + if c.len() != observerComparisonCacheMaxKeys { + t.Fatalf("len after eviction = %d, want %d (ceiling held)", c.len(), observerComparisonCacheMaxKeys) + } + if _, ok := c.get("k000", base); ok { + t.Fatal("expected k000 (nearest expiry) to be evicted") + } + if _, ok := c.get("k001", base); !ok { + t.Fatal("k001 should still be present -- only the single nearest-expiry victim is evicted") + } + if _, ok := c.get("new-key", base); !ok { + t.Fatal("new-key should have been inserted") + } +} + +func ensureEntries(m map[string]*observerComparisonCacheEntry) map[string]*observerComparisonCacheEntry { + if m == nil { + return make(map[string]*observerComparisonCacheEntry) + } + return m +} + +func TestObserverComparisonCache_SingleflightCollapsesConcurrentMisses(t *testing.T) { + c := &observerComparisonCache{} + var computeCalls atomic.Int32 + results, errs := callConcurrently(16, func(i int) (interface{}, error) { + return c.load("same-key", time.Now(), func() (*ObserverComparisonResponse, error) { + computeCalls.Add(1) + time.Sleep(20 * time.Millisecond) // widen the race window + return &ObserverComparisonResponse{Reference: ObserverComparisonReference{ID: "ref"}}, nil + }) + }) + for i, err := range errs { + if err != nil { + t.Fatalf("call %d: unexpected error: %v", i, err) + } + } + if n := computeCalls.Load(); n != 1 { + t.Fatalf("compute ran %d times, want 1 (singleflight should collapse concurrent misses)", n) + } + for i, r := range results { + resp := r.(*ObserverComparisonResponse) + if resp.Reference.ID != "ref" { + t.Fatalf("call %d got unexpected result: %+v", i, resp) + } + } +} + +// ---- handler: request validation (#430 "reject before work") -------- + +func TestHandleObserverComparison_MissingRef(t *testing.T) { + _, router, _ := newObsComparisonTestServer(t) + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?hours=24&observers=a", nil)) + if w.Code != 400 { + t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String()) + } +} + +func TestHandleObserverComparison_InvalidHours(t *testing.T) { + _, router, _ := newObsComparisonTestServer(t) + for _, hours := range []string{"", "0", "5", "48", "-1", "abc"} { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=r&hours="+hours+"&observers=a", nil)) + if w.Code != 400 { + t.Fatalf("hours=%q: status = %d, want 400", hours, w.Code) + } + } +} + +func TestHandleObserverComparison_MissingObservers(t *testing.T) { + _, router, _ := newObsComparisonTestServer(t) + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=r&hours=24", nil)) + if w.Code != 400 { + t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String()) + } +} + +func TestHandleObserverComparison_RefInObservers(t *testing.T) { + _, router, _ := newObsComparisonTestServer(t) + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=r&hours=24&observers=a,r,b", nil)) + if w.Code != 400 { + t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String()) + } +} + +func TestHandleObserverComparison_TooManyObservers(t *testing.T) { + _, router, _ := newObsComparisonTestServer(t) + ids := make([]string, observerComparisonMaxObservers+1) + for i := range ids { + ids[i] = fmt.Sprintf("o%d", i) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=r&hours=24&observers="+strings.Join(ids, ","), nil)) + if w.Code != 400 { + t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String()) + } +} + +func TestHandleObserverComparison_ObserversParamTooLong(t *testing.T) { + _, router, _ := newObsComparisonTestServer(t) + huge := strings.Repeat("x", observerComparisonObserversParamMaxLen+1) + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=r&hours=24&observers="+huge, nil)) + if w.Code != 400 { + t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String()) + } +} + +func TestHandleObserverComparison_RefUnknown_404(t *testing.T) { + _, router, db := newObsComparisonTestServer(t) + insertComparisonObserver(t, db, "real", "Real", 0) + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=nope&hours=24&observers=real", nil)) + if w.Code != 404 { + t.Fatalf("status = %d, want 404; body=%s", w.Code, w.Body.String()) + } +} + +func TestHandleObserverComparison_V3SchemaGuard(t *testing.T) { + db := setupTestDBv2(t) // observer_id column, not observer_idx -- isV3() is false + srv := NewServer(db, &Config{}, NewHub()) + router := mux.NewRouter() + srv.RegisterRoutes(router) + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=r&hours=24&observers=a", nil)) + if w.Code != 400 { + t.Fatalf("status = %d, want 400 for non-v3 schema; body=%s", w.Code, w.Body.String()) + } +} + +// ---- handler: end-to-end correctness --------------------------------- + +func decodeComparisonResponse(t *testing.T, w *httptest.ResponseRecorder) ObserverComparisonResponse { + t.Helper() + if w.Code != 200 { + t.Fatalf("status = %d, body=%s", w.Code, w.Body.String()) + } + var resp ObserverComparisonResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + return resp +} + +// TestHandleObserverComparison_RXOnly is the core correctness requirement: +// TX and unknown-direction observations must never silently count as RX. +func TestHandleObserverComparison_RXOnly(t *testing.T) { + _, router, db := newObsComparisonTestServer(t) + refIdx := insertComparisonObserver(t, db, "ref", "Ref", 0) + otherIdx := insertComparisonObserver(t, db, "other", "Other", 0) + + now := time.Now().UTC() + firstSeen := now.Format(time.RFC3339) + ts := now.Add(-time.Minute).Unix() // safely inside the window, never racing the handler's own time.Now() + + txRX := insertComparisonTx(t, db, "rx-tx", firstSeen) + txTX := insertComparisonTx(t, db, "tx-tx", firstSeen) + txUnknown := insertComparisonTx(t, db, "unknown-dir-tx", firstSeen) + txEmpty := insertComparisonTx(t, db, "empty-dir-tx", firstSeen) + txLowerRX := insertComparisonTx(t, db, "lower-rx-tx", firstSeen) + + insertComparisonObs(t, db, txRX, refIdx, "RX", ts, "[]") + insertComparisonObs(t, db, txRX, otherIdx, "RX", ts, "[]") + + insertComparisonObs(t, db, txTX, refIdx, "TX", ts, "[]") + insertComparisonObs(t, db, txTX, otherIdx, "TX", ts, "[]") + + insertComparisonObs(t, db, txUnknown, refIdx, "WEIRD", ts, "[]") + insertComparisonObs(t, db, txUnknown, otherIdx, "WEIRD", ts, "[]") + + insertComparisonObs(t, db, txEmpty, refIdx, "", ts, "[]") + insertComparisonObs(t, db, txEmpty, otherIdx, "", ts, "[]") + + insertComparisonObs(t, db, txLowerRX, refIdx, "rx", ts, "[]") + insertComparisonObs(t, db, txLowerRX, otherIdx, "rx", ts, "[]") + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=ref&hours=1&observers=other", nil)) + resp := decodeComparisonResponse(t, w) + + // Only the "RX" and "rx" rows count: refTotal=2, shared=2. + if resp.Reference.Total != 2 { + t.Fatalf("Reference.Total = %d, want 2 (TX/unknown/empty direction must be excluded)", resp.Reference.Total) + } + if len(resp.Rows) != 1 { + t.Fatalf("rows = %d, want 1", len(resp.Rows)) + } + if resp.Rows[0].Shared != 2 || resp.Rows[0].Total != 2 { + t.Fatalf("got %+v, want Total=2 Shared=2", resp.Rows[0]) + } +} + +// TestHandleObserverComparison_DedupPerTransmission: #122 "count each +// transmission_id once per observer, even when multiple paths/observations +// exist." Two observation rows for the same (tx, observer) with different +// paths must count as one. +func TestHandleObserverComparison_DedupPerTransmission(t *testing.T) { + _, router, db := newObsComparisonTestServer(t) + refIdx := insertComparisonObserver(t, db, "ref", "Ref", 0) + otherIdx := insertComparisonObserver(t, db, "other", "Other", 0) + + now := time.Now().UTC() + firstSeen := now.Format(time.RFC3339) + ts := now.Add(-time.Minute).Unix() // safely inside the window, never racing the handler's own time.Now() + + tx := insertComparisonTx(t, db, "multi-path-tx", firstSeen) + // Reference observes the same transmission via two different paths. + insertComparisonObs(t, db, tx, refIdx, "RX", ts, `["aa","bb"]`) + insertComparisonObs(t, db, tx, refIdx, "RX", ts, `["cc"]`) + insertComparisonObs(t, db, tx, otherIdx, "RX", ts, "[]") + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=ref&hours=1&observers=other", nil)) + resp := decodeComparisonResponse(t, w) + + if resp.Reference.Total != 1 { + t.Fatalf("Reference.Total = %d, want 1 (same transmission via 2 paths must count once)", resp.Reference.Total) + } + if resp.Rows[0].Shared != 1 { + t.Fatalf("Shared = %d, want 1", resp.Rows[0].Shared) + } +} + +// TestHandleObserverComparison_WindowUsesObservationTimestamp: #122 "Use +// one explicit UTC interval ... based on observation timestamps, not only +// transmission first_seen." A transmission first seen long before the +// window, but re-observed by the reference inside the window, must count; +// one whose only observation falls outside the window must not. +func TestHandleObserverComparison_WindowUsesObservationTimestamp(t *testing.T) { + _, router, db := newObsComparisonTestServer(t) + refIdx := insertComparisonObserver(t, db, "ref", "Ref", 0) + otherIdx := insertComparisonObserver(t, db, "other", "Other", 0) + + now := time.Now().UTC() + oldFirstSeen := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339) + insideTS := now.Add(-30 * time.Minute).Unix() + outsideTS := now.Add(-2 * time.Hour).Unix() // outside a 1h window + + txReobserved := insertComparisonTx(t, db, "old-tx-reobserved", oldFirstSeen) + insertComparisonObs(t, db, txReobserved, refIdx, "RX", insideTS, "[]") + insertComparisonObs(t, db, txReobserved, otherIdx, "RX", insideTS, "[]") + + txOutside := insertComparisonTx(t, db, "recent-tx-outside-window", now.Format(time.RFC3339)) + insertComparisonObs(t, db, txOutside, refIdx, "RX", outsideTS, "[]") + insertComparisonObs(t, db, txOutside, otherIdx, "RX", outsideTS, "[]") + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=ref&hours=1&observers=other", nil)) + resp := decodeComparisonResponse(t, w) + + if resp.Reference.Total != 1 { + t.Fatalf("Reference.Total = %d, want 1 (only the re-observed tx is inside the window)", resp.Reference.Total) + } + if resp.Rows[0].Shared != 1 { + t.Fatalf("Shared = %d, want 1", resp.Rows[0].Shared) + } +} + +func TestHandleObserverComparison_UnknownObserverSilentlyExcluded(t *testing.T) { + _, router, db := newObsComparisonTestServer(t) + refIdx := insertComparisonObserver(t, db, "ref", "Ref", 0) + otherIdx := insertComparisonObserver(t, db, "other", "Other", 0) + now := time.Now().UTC() + ts := now.Add(-time.Minute).Unix() // safely inside the window, never racing the handler's own time.Now() + tx := insertComparisonTx(t, db, "tx1", now.Format(time.RFC3339)) + insertComparisonObs(t, db, tx, refIdx, "RX", ts, "[]") + insertComparisonObs(t, db, tx, otherIdx, "RX", ts, "[]") + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=ref&hours=1&observers=other,does-not-exist", nil)) + resp := decodeComparisonResponse(t, w) + if len(resp.Rows) != 1 { + t.Fatalf("rows = %d, want 1 (unknown id silently excluded)", len(resp.Rows)) + } + if resp.Rows[0].ObserverID != "other" { + t.Fatalf("got %+v", resp.Rows[0]) + } +} + +func TestHandleObserverComparison_InactiveObserverExcluded(t *testing.T) { + _, router, db := newObsComparisonTestServer(t) + refIdx := insertComparisonObserver(t, db, "ref", "Ref", 0) + insertComparisonObserver(t, db, "inactive-one", "InactiveOne", 1) + otherIdx := insertComparisonObserver(t, db, "other", "Other", 0) + now := time.Now().UTC() + ts := now.Add(-time.Minute).Unix() // safely inside the window, never racing the handler's own time.Now() + tx := insertComparisonTx(t, db, "tx1", now.Format(time.RFC3339)) + insertComparisonObs(t, db, tx, refIdx, "RX", ts, "[]") + insertComparisonObs(t, db, tx, otherIdx, "RX", ts, "[]") + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=ref&hours=1&observers=other,inactive-one", nil)) + resp := decodeComparisonResponse(t, w) + if len(resp.Rows) != 1 || resp.Rows[0].ObserverID != "other" { + t.Fatalf("expected the inactive observer excluded, got %+v", resp.Rows) + } +} + +func TestHandleObserverComparison_BlacklistedObserverExcluded(t *testing.T) { + db := setupTestDB(t) + cfg := &Config{ObserverBlacklist: []string{"blacklisted-one"}} + srv := NewServer(db, cfg, NewHub()) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + refIdx := insertComparisonObserver(t, db, "ref", "Ref", 0) + insertComparisonObserver(t, db, "blacklisted-one", "BlacklistedOne", 0) + otherIdx := insertComparisonObserver(t, db, "other", "Other", 0) + now := time.Now().UTC() + ts := now.Add(-time.Minute).Unix() // safely inside the window, never racing the handler's own time.Now() + tx := insertComparisonTx(t, db, "tx1", now.Format(time.RFC3339)) + insertComparisonObs(t, db, tx, refIdx, "RX", ts, "[]") + insertComparisonObs(t, db, tx, otherIdx, "RX", ts, "[]") + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=ref&hours=1&observers=other,blacklisted-one", nil)) + resp := decodeComparisonResponse(t, w) + if len(resp.Rows) != 1 || resp.Rows[0].ObserverID != "other" { + t.Fatalf("expected the blacklisted observer excluded, got %+v", resp.Rows) + } +} + +func TestHandleObserverComparison_RefBlacklisted_404(t *testing.T) { + db := setupTestDB(t) + cfg := &Config{ObserverBlacklist: []string{"ref"}} + srv := NewServer(db, cfg, NewHub()) + router := mux.NewRouter() + srv.RegisterRoutes(router) + insertComparisonObserver(t, db, "ref", "Ref", 0) + insertComparisonObserver(t, db, "other", "Other", 0) + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=ref&hours=1&observers=other", nil)) + if w.Code != 404 { + t.Fatalf("status = %d, want 404; body=%s", w.Code, w.Body.String()) + } +} + +func TestHandleObserverComparison_AllOthersIneligible_400(t *testing.T) { + _, router, db := newObsComparisonTestServer(t) + insertComparisonObserver(t, db, "ref", "Ref", 0) + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=ref&hours=1&observers=nope1,nope2", nil)) + if w.Code != 400 { + t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String()) + } +} + +// TestHandleObserverComparison_WindowEchoed asserts the response's window +// matches the requested hours and End-Start equals exactly that duration +// (the exact [start,end) boundary itself is covered at the DB-scan level in +// TestScanObserverComparisonPairs_ExactBoundaries, which does not depend on +// wall-clock timing). +func TestHandleObserverComparison_WindowEchoed(t *testing.T) { + _, router, db := newObsComparisonTestServer(t) + refIdx := insertComparisonObserver(t, db, "ref", "Ref", 0) + otherIdx := insertComparisonObserver(t, db, "other", "Other", 0) + now := time.Now().UTC() + tx := insertComparisonTx(t, db, "tx1", now.Format(time.RFC3339)) + insertComparisonObs(t, db, tx, refIdx, "RX", now.Unix(), "[]") + insertComparisonObs(t, db, tx, otherIdx, "RX", now.Unix(), "[]") + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/analytics/observer-comparison?ref=ref&hours=6&observers=other", nil)) + resp := decodeComparisonResponse(t, w) + + if resp.Window.Hours != 6 { + t.Fatalf("Window.Hours = %d, want 6", resp.Window.Hours) + } + start, err := time.Parse(time.RFC3339, resp.Window.Start) + if err != nil { + t.Fatalf("Start not RFC3339: %v", err) + } + end, err := time.Parse(time.RFC3339, resp.Window.End) + if err != nil { + t.Fatalf("End not RFC3339: %v", err) + } + if got := end.Sub(start); got != 6*time.Hour { + t.Fatalf("End-Start = %v, want 6h", got) + } +} + +// ---- scanObserverComparisonPairs (DB-level, exact boundaries) -------- + +// TestScanObserverComparisonPairs_ExactBoundaries verifies the half-open +// [start, end) interval precisely, without depending on wall-clock timing: +// start is itself included, end itself is excluded. +func TestScanObserverComparisonPairs_ExactBoundaries(t *testing.T) { + db := setupTestDB(t) + refIdx := insertComparisonObserver(t, db, "ref", "Ref", 0) + + start := time.Unix(1_700_000_000, 0).UTC() + end := start.Add(time.Hour) + + txAtStart := insertComparisonTx(t, db, "at-start", start.Format(time.RFC3339)) + insertComparisonObs(t, db, txAtStart, refIdx, "RX", start.Unix(), "[]") + + txBeforeStart := insertComparisonTx(t, db, "before-start", start.Format(time.RFC3339)) + insertComparisonObs(t, db, txBeforeStart, refIdx, "RX", start.Unix()-1, "[]") + + txAtEnd := insertComparisonTx(t, db, "at-end", start.Format(time.RFC3339)) + insertComparisonObs(t, db, txAtEnd, refIdx, "RX", end.Unix(), "[]") + + txJustBeforeEnd := insertComparisonTx(t, db, "just-before-end", start.Format(time.RFC3339)) + insertComparisonObs(t, db, txJustBeforeEnd, refIdx, "RX", end.Unix()-1, "[]") + + pairs, err := db.scanObserverComparisonPairs([]int64{refIdx}, start, end) + if err != nil { + t.Fatalf("scan: %v", err) + } + got := map[int64]bool{} + for _, p := range pairs { + got[p.TransmissionID] = true + } + if !got[txAtStart] { + t.Error("expected tx at exactly `start` to be included (inclusive lower bound)") + } + if got[txBeforeStart] { + t.Error("expected tx before `start` to be excluded") + } + if got[txAtEnd] { + t.Error("expected tx at exactly `end` to be excluded (exclusive upper bound)") + } + if !got[txJustBeforeEnd] { + t.Error("expected tx just before `end` to be included") + } +} + +func TestScanObserverComparisonPairs_ScanRowLimit(t *testing.T) { + db := setupTestDB(t) + refIdx := insertComparisonObserver(t, db, "ref", "Ref", 0) + now := time.Now().UTC() + + old := observerComparisonScanRowLimit + observerComparisonScanRowLimit = 3 + t.Cleanup(func() { observerComparisonScanRowLimit = old }) + for i := 0; i < 5; i++ { + tx := insertComparisonTx(t, db, fmt.Sprintf("tx%d", i), now.Format(time.RFC3339)) + insertComparisonObs(t, db, tx, refIdx, "RX", now.Unix(), "[]") + } + _, err := db.scanObserverComparisonPairs([]int64{refIdx}, now.Add(-time.Hour), now.Add(time.Hour)) + if err == nil { + t.Fatal("expected an explicit error when the scan row limit is exceeded, got nil (silent truncation is forbidden)") + } +} diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 0867c2b57..a8b74e919 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -172,6 +172,13 @@ func routeDescriptions() map[string]routeMeta { {Name: "until", Description: "RFC3339 end time — overrides window when paired with since", Type: "string"}, }}, + "GET /api/analytics/observer-comparison": {Summary: "Reference-to-many observer comparison", Description: "Descriptive packet-overlap comparison between one reference observer and one or more other observers (#122). Computed server-side from every eligible RX observation.timestamp in [window.start, window.end) -- not from a client-side packet-list sample, and not from transmissions.first_seen -- so a transmission re-observed inside the window counts even if first seen earlier. Each transmission_id counts once per observer regardless of how many paths/observations reached it; TX and any non-RX/unknown direction are excluded. rows[].referenceCoveragePct/otherCoveragePct are null (not 0) when their denominator is zero. No SNR/RSSI comparison and no health classification -- counts and percentages only. Bounded to observerComparisonMaxObservers (40) compared observers per request and cached 30s per (ref, hours, observer set).", Tag: "analytics", + QueryParams: []paramMeta{ + {Name: "ref", Description: "Reference observer id (required)", Type: "string"}, + {Name: "hours", Description: "Window size: 1, 6, or 24 (required)", Type: "integer"}, + {Name: "observers", Description: "Comma-separated ids of the other observers to compare against the reference (required, 1-40, must not include ref)", Type: "string"}, + }}, + // Channels "GET /api/channels": {Summary: "List channels", Description: "Returns known mesh channels with message counts. approvedChannels ([{name, hash}]) lists the shared hashtag channels an administrator approved, even before they carry traffic; omitted when empty. A channel with stored messages whose shared-channel proposal is not approved (revoked, suggested again and pending, or that re-suggestion rejected) is left out of the list (#251), unless the ingestor also decrypts that name through its built-in/config list (rainbow table, hashChannels, channelKeys); only approving the proposal lists it again, with its history. hiddenChannels ([string], omitted when empty) names the channels left out, so the page does not re-create their rows from live packets; it only names channels that have stored messages (never an unreviewed suggestion), it is global and not filtered by region, and another open tab keeps the set it last loaded, also after a re-approval. The messages stay readable per channel (GET /api/channels/{hash}/messages) and nothing is deleted. Decided per channel with stored messages from a short-lived snapshot of the proposals table (10s, dropped early when an approve/revoke result is read), never a per-request query and without a row cap. When the ingestor's built-in names file is missing or unreadable nothing is hidden. GET /api/analytics/channels is not filtered and still counts these channels.", Tag: "channels"}, "GET /api/channels/{hash}/messages": {Summary: "Get channel messages", Description: "Returns messages for a specific channel. The {hash} path parameter is the existing channel route key: the operator-assigned channel name for a decrypted channel, or \"enc_\" for an undecryptable one — it is not the on-wire channel hash. The separate channelHashHex field on each message carries the actual one-byte hash taken from the packet (see the ChannelMessage schema).", Tag: "channels", Response: schemaRef("ChannelMessagesResponse"), diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 68d7f538a..f616c3a54 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -82,6 +82,10 @@ type Server struct { // (node_advert_routes_cache.go). advertRoutes nodeAdvertRouteCache + // #122 reference-to-many observer comparison, per (ref, hours, other + // observer set) -- see observer_comparison.go. + obsComparison observerComparisonCache + // Cached /api/scope-stats response — per-window, recomputed at most once every 30s scopeStatsMu sync.Mutex scopeStatsCache map[string]*ScopeStatsResponse @@ -324,6 +328,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/stats", s.handleStats).Methods("GET") r.HandleFunc("/api/scope-stats", s.handleScopeStats).Methods("GET") r.HandleFunc("/api/analytics/hop-depth", s.handleHopDepthAnalytics).Methods("GET") + r.HandleFunc("/api/analytics/observer-comparison", s.handleObserverComparison).Methods("GET") r.HandleFunc("/api/analytics/wardriving", s.handleWardrivingStats).Methods("GET") r.HandleFunc("/api/analytics/wardriving/sender-messages", s.handleWardrivingSenderMessages).Methods("GET") r.HandleFunc("/api/perf", s.handlePerf).Methods("GET") diff --git a/public/app.js b/public/app.js index 1cc473bf8..a5639f119 100644 --- a/public/app.js +++ b/public/app.js @@ -220,7 +220,7 @@ const CLIENT_TTL = { channels: 15000, channelMessages: 10000, analyticsRF: 300000, analyticsTopology: 300000, analyticsChannels: 300000, analyticsHashSizes: 300000, analyticsSubpaths: 300000, analyticsSubpathDetail: 300000, - nodeAnalytics: 60000, nodeSearch: 10000 + nodeAnalytics: 60000, nodeSearch: 10000, observerComparison: 20000 }; // Fetch server cache config and use as client TTLs (server values are in seconds) fetch('/api/config/cache').then(r => r.json()).then(cfg => { diff --git a/public/compare.js b/public/compare.js index 2539d9f15..4b2ae2157 100644 --- a/public/compare.js +++ b/public/compare.js @@ -69,16 +69,80 @@ function computeOverlapStats(cmp) { }; } +/** + * Reference-to-many observer comparison helpers (#122). + */ + +/** + * Format a nullable overlap percentage for display. null means "unavailable" + * (zero denominator) per #122 -- never rendered as a misleading 0%. + */ +function formatCoveragePct(pct) { + return (pct === null || pct === undefined) ? '—' : pct.toFixed(1) + '%'; +} + +/** + * The ids of every observer except the reference, in `allObservers`' own + * order (compare.js keeps that list name-sorted, so the resulting + * "observers=" query string is stable across repeated calls with the same + * data -- helping the server's response cache hit rather than miss on key + * order). Does not mutate allObservers. + */ +function buildOtherObserverIds(allObservers, refId) { + return (allObservers || []) + .filter(function (o) { return o && o.id !== refId; }) + .map(function (o) { return o.id; }); +} + +/** + * Sort a copy of an observer-comparison rows array by `column`, direction + * 'asc' | 'desc'. Non-mutating -- callers may hold the original array from + * a cached API response. Numeric columns sort null (unavailable) last + * regardless of direction, so "unavailable" never reads as the smallest + * value. 'name' sorts case-insensitively; ties break by observerId for a + * deterministic order. + */ +function sortComparisonRows(rows, column, direction) { + var copy = (rows || []).slice(); + var dir = direction === 'asc' ? 1 : -1; + copy.sort(function (a, b) { + if (column === 'name') { + var an = (a.observerName || a.observerId || '').toLowerCase(); + var bn = (b.observerName || b.observerId || '').toLowerCase(); + if (an !== bn) return an < bn ? -dir : dir; + } else { + var av = a[column]; + var bv = b[column]; + var aNull = av === null || av === undefined; + var bNull = bv === null || bv === undefined; + if (aNull !== bNull) return aNull ? 1 : -1; + if (!aNull && av !== bv) return av < bv ? -dir : dir; + } + return (a.observerId || '').localeCompare(b.observerId || ''); + }); + return copy; +} + // Expose for testing if (typeof window !== 'undefined') { window.comparePacketSets = comparePacketSets; window.filterPacketsByRoute = filterPacketsByRoute; window.computeOverlapStats = computeOverlapStats; + window.formatCoveragePct = formatCoveragePct; + window.buildOtherObserverIds = buildOtherObserverIds; + window.sortComparisonRows = sortComparisonRows; } (function () { var PAYLOAD_LABELS = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 11: 'Control' }; var MAX_PACKETS = 10000; + var REF_OBSERVER_LS_KEY = 'meshcore-compare-reference'; + // Mirrors the server's observerComparisonMaxObservers (#122): bounding + // the "observers=" list here means a real 400 only happens on an + // install with more than this many eligible observers, and the message + // explains why rather than the request silently getting dropped. + var REF_MAX_OBSERVERS = 40; + var REF_WINDOWS = [1, 6, 24]; var observers = []; var selA = null; var selB = null; @@ -87,38 +151,84 @@ if (typeof window !== 'undefined') { var packetsB = []; var currentView = 'summary'; var routeFilter = 'all'; + // #122 -- pairwise mode's own packet-fetch window, carried from a + // reference-mode drill-down (?hours=) so the detail view below matches + // the window the summary above was computed with. Legacy a=/b= deep + // links carry no hours= and keep the original 24h behavior. + var pairHours = 24; + + // #122 reference-to-many mode state. + var mode = 'pair'; // 'pair' | 'ref' + var refObserverId = null; + var refHours = 24; + var refResult = null; // last successful ObserverComparisonResponse + var refError = null; // last error message, or null + var refLoading = false; + var refSortColumn = 'name'; + var refSortDir = 'asc'; + var refRequestToken = 0; // stale-response guard: only the latest request may render + + function safeLocalStorageGet(key) { + try { return localStorage.getItem(key); } catch (_) { return null; } + } + function safeLocalStorageSet(key, value) { + try { localStorage.setItem(key, value); } catch (_) { /* storage unavailable/full -- not fatal */ } + } function init(app, routeParam) { - // Parse preselected observers from URL: #/compare?a=ID1&b=ID2 + // Parse state from URL: #/compare?a=ID1&b=ID2 (pair, default) or + // #/compare?mode=ref&ref=ID&hours=H (reference-to-many). var hashParams = location.hash.split('?')[1] || ''; var params = new URLSearchParams(hashParams); selA = params.get('a') || null; selB = params.get('b') || null; + var hoursParam = parseInt(params.get('hours'), 10); + pairHours = REF_WINDOWS.indexOf(hoursParam) >= 0 ? hoursParam : 24; comparisonResult = null; packetsA = []; packetsB = []; currentView = 'summary'; routeFilter = 'all'; + mode = params.get('mode') === 'ref' ? 'ref' : 'pair'; + refObserverId = params.get('ref') || safeLocalStorageGet(REF_OBSERVER_LS_KEY) || null; + refHours = REF_WINDOWS.indexOf(hoursParam) >= 0 ? hoursParam : 24; + refResult = null; + refError = null; + refLoading = false; + refSortColumn = 'name'; + refSortDir = 'asc'; + refRequestToken++; + app.innerHTML = '
' + '' + '' + + '
' + + '' + + '' + + '
' + '
Loading observers\u2026
' + '
' + '
'; - // #428 — Bind off the shared #app, not onto it (same pattern #405 used + // #428 -- Bind off the shared #app, not onto it (same pattern #405 used // for observers.js; #402 explicitly left this one for the owner). The // next page's app.innerHTML rebuild discards .compare-page along with - // this listener, so it can't keep firing — and stacking — on later + // this listener, so it can't keep firing -- and stacking -- on later // pages. .compare-page is the root this init() writes into, so it // still catches every row keydown the #app binding did. var pageEl = app.querySelector('.compare-page'); - // #209 — Keyboard accessibility for compare table rows + // #209 -- Keyboard accessibility for compare table rows pageEl.addEventListener('keydown', function (e) { + var sortBtn = e.target.closest('[data-sort]'); + if (sortBtn && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + applyRefSort(sortBtn.dataset.sort); + return; + } var row = e.target.closest('tr[data-action="navigate"]'); if (!row) return; if (e.key !== 'Enter' && e.key !== ' ') return; @@ -126,17 +236,29 @@ if (typeof window !== 'undefined') { location.hash = row.dataset.value; }); - // #393 N9 — delegated click navigation for the same rows. Lets the packet + // #393 N9 -- delegated click navigation for the same rows. Lets the packet // rows drop their per-row inline onclick (consistency with #373), which // also removes the latent `'`-breakout in that double-quoted/JS context. // Bound to #compareContent, not the shared #app: the next page's // app.innerHTML discards it, so it cannot fire on other pages' rows. document.getElementById('compareContent').addEventListener('click', function (e) { + var sortBtn = e.target.closest('[data-sort]'); + if (sortBtn) { applyRefSort(sortBtn.dataset.sort); return; } var row = e.target.closest('tr[data-action="navigate"]'); if (!row) return; location.hash = row.dataset.value; }); + // #122 -- mode toggle is part of the page chrome (outside #compareContent + // and #compareControls, both of which get fully replaced on re-render), + // so it is bound once here on the stable .compare-page root rather than + // re-bound on every render (#419/#437: no stacking, no leak). + pageEl.addEventListener('click', function (e) { + var btn = e.target.closest('[data-mode-btn]'); + if (!btn) return; + switchMode(btn.dataset.modeBtn); + }); + loadObservers(); } @@ -148,6 +270,49 @@ if (typeof window !== 'undefined') { packetsA = []; packetsB = []; routeFilter = 'all'; + refObserverId = null; + refResult = null; + refError = null; + refLoading = false; + refRequestToken++; // any in-flight reference-comparison response is now stale + } + + // #122 -- switch between the two-observer and reference-to-many modes + // in place (no full page navigate/reload): updates the URL via + // replaceState, re-renders the mode toggle + controls, and (re)runs + // whichever comparison is ready. Each mode keeps its own selection in + // memory, so toggling back restores it. + function switchMode(next) { + if (next !== 'pair' && next !== 'ref') return; + if (next === mode) return; + mode = next; + refRequestToken++; // abandon any in-flight reference request from the old mode + var updates = mode === 'ref' ? { mode: 'ref', a: '', b: '' } : { mode: '', ref: '', hours: '' }; + var newHash = window.URLState + ? URLState.updateHashParams(updates, location.hash) + : location.hash; + if (newHash !== location.hash) history.replaceState(null, '', newHash); + renderModeToggle(); + renderActiveControls(); + } + + function renderModeToggle() { + document.querySelectorAll('[data-mode-btn]').forEach(function (btn) { + var on = btn.dataset.modeBtn === mode; + btn.classList.toggle('active', on); + btn.setAttribute('aria-selected', on ? 'true' : 'false'); + }); + } + + function renderActiveControls() { + if (mode === 'ref') { + renderRefControls(); + renderBreadcrumbs(); + if (refObserverId) runReferenceComparison(); else renderRefContent(); + } else { + renderControls(); + if (isComparisonReady()) runComparison(); + } } // #1646 round-2 — single shared "should we run a comparison?" predicate @@ -163,8 +328,8 @@ if (typeof window !== 'undefined') { observers = (data.observers || []).sort(function (a, b) { return (a.name || a.id).localeCompare(b.name || b.id); }); - renderControls(); - if (isComparisonReady()) runComparison(); + renderModeToggle(); + renderActiveControls(); } catch (e) { document.getElementById('compareControls').innerHTML = '
Error loading observers: ' + escapeHtml(e.message) + '
'; @@ -245,26 +410,32 @@ if (typeof window !== 'undefined') { // Hidden when neither observer is picked; otherwise: // "Observers › ⇆ " // The "‎" entities keep punctuation LTR in RTL contexts. + function linkForObserver(id) { + if (!id) return null; + var match = null; + for (var i = 0; i < observers.length; i++) { + if (String(observers[i].id) === String(id)) { match = observers[i]; break; } + } + var label = match ? (match.name || match.id) : id; + return '' + escapeHtml(label) + ''; + } + function renderBreadcrumbs() { var el = document.querySelector('[data-role="compare-breadcrumbs"]'); if (!el) return; - function linkFor(id) { - if (!id) return null; - var match = null; - for (var i = 0; i < observers.length; i++) { - if (String(observers[i].id) === String(id)) { match = observers[i]; break; } - } - var label = match ? (match.name || match.id) : id; - return '' + escapeHtml(label) + ''; - } var parts = ['Observers']; - var aLink = linkFor(selA); - var bLink = linkFor(selB); - if (aLink || bLink) { - var pair = []; - if (aLink) pair.push(aLink); - if (bLink) pair.push(bLink); - parts.push(pair.join(' ')); + if (mode === 'ref') { + var refLink = linkForObserver(refObserverId); + if (refLink) parts.push('Reference: ' + refLink); + } else { + var aLink = linkForObserver(selA); + var bLink = linkForObserver(selB); + if (aLink || bLink) { + var pair = []; + if (aLink) pair.push(aLink); + if (bLink) pair.push(bLink); + parts.push(pair.join(' ')); + } } el.innerHTML = parts.join(' '); } @@ -280,17 +451,20 @@ if (typeof window !== 'undefined') { content.innerHTML = '
Fetching packets\u2026
'; - // Update URL for shareability - var base = '#/compare?a=' + encodeURIComponent(selA) + '&b=' + encodeURIComponent(selB); + // Update URL for shareability. hours= is only written when it differs + // from the 24h default, so the common case keeps the original short + // #/compare?a=X&b=Y link unchanged (#122). + var base = '#/compare?a=' + encodeURIComponent(selA) + '&b=' + encodeURIComponent(selB) + + (pairHours !== 24 ? '&hours=' + pairHours : ''); if (location.hash.split('?')[0] === '#/compare') { history.replaceState(null, '', base); } try { - var since24h = sinceISO(24); + var sinceWindow = sinceISO(pairHours); var results = await Promise.all([ - api('/packets?observer=' + encodeURIComponent(selA) + '&limit=' + MAX_PACKETS + '&since=' + encodeURIComponent(since24h)), - api('/packets?observer=' + encodeURIComponent(selB) + '&limit=' + MAX_PACKETS + '&since=' + encodeURIComponent(since24h)) + api('/packets?observer=' + encodeURIComponent(selA) + '&limit=' + MAX_PACKETS + '&since=' + encodeURIComponent(sinceWindow)), + api('/packets?observer=' + encodeURIComponent(selB) + '&limit=' + MAX_PACKETS + '&since=' + encodeURIComponent(sinceWindow)) ]); packetsA = results[0].packets || []; @@ -503,7 +677,7 @@ if (typeof window !== 'undefined') { var overlap = total > 0 ? (r.both.length / total * 100).toFixed(1) : '0.0'; el.innerHTML = '
' + - '

In the last 24 hours, ' + nameA + ' saw ' + + '

In the last ' + (pairHours === 1 ? 'hour' : pairHours + ' hours') + ', ' + nameA + ' saw ' + (r.onlyA.length + r.both.length).toLocaleString() + ' unique packets ' + 'and ' + nameB + ' saw ' + (r.onlyB.length + r.both.length).toLocaleString() + ' unique packets. ' + @@ -555,5 +729,219 @@ if (typeof window !== 'undefined') { '

'; } + // ===== #122 — reference-to-many mode ===================================== + + var REF_WINDOW_LABELS = { 1: 'Last hour', 6: 'Last 6 hours', 24: 'Last 24 hours' }; + var REF_COLUMNS = [ + { key: 'name', label: 'Observer' }, + { key: 'total', label: 'Unique' }, + { key: 'shared', label: 'Shared' }, + { key: 'referenceOnly', label: 'Ref. only' }, + { key: 'otherOnly', label: 'Other only' }, + { key: 'referenceCoveragePct', label: 'Ref. sees' }, + { key: 'otherCoveragePct', label: 'Other sees' }, + ]; + + function renderRefControls() { + var el = document.getElementById('compareControls'); + if (!el) return; + + var optionsHtml = '' + + observers.map(function (o) { + var label = escapeHtml(o.name || o.id); + var region = o.iata ? ' (' + escapeHtml(o.iata) + ')' : ''; + return ''; + }).join(''); + + var hoursOptionsHtml = REF_WINDOWS.map(function (h) { + return ''; + }).join(''); + + el.innerHTML = + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + + var ddRef = document.getElementById('compareRefObs'); + var ddHours = document.getElementById('compareRefHours'); + if (refObserverId) ddRef.value = refObserverId; + ddHours.value = String(refHours); + + ddRef.addEventListener('change', function () { + refObserverId = ddRef.value || null; + if (refObserverId) safeLocalStorageSet(REF_OBSERVER_LS_KEY, refObserverId); + renderBreadcrumbs(); + if (refObserverId) runReferenceComparison(); else renderRefContent(); + }); + ddHours.addEventListener('change', function () { + var h = parseInt(ddHours.value, 10); + refHours = REF_WINDOWS.indexOf(h) >= 0 ? h : 24; + if (refObserverId) runReferenceComparison(); + }); + } + + function updateRefUrl() { + var updates = { mode: 'ref', ref: refObserverId || '', hours: refHours, a: '', b: '' }; + var newHash = window.URLState ? URLState.updateHashParams(updates, location.hash) : null; + if (newHash && newHash !== location.hash && location.hash.split('?')[0] === '#/compare') { + history.replaceState(null, '', newHash); + } + } + + // #122 — the "observers=" list the server compares the reference against: + // every OTHER eligible observer /api/observers already returned (so the + // existing visibility/blacklist policy applies identically to this mode), + // capped to REF_MAX_OBSERVERS. A cap that actually truncates is surfaced + // explicitly in renderRefContent, never silently. + function refOtherObserverIds() { + var ids = buildOtherObserverIds(observers, refObserverId); + return { ids: ids.slice(0, REF_MAX_OBSERVERS), truncated: ids.length > REF_MAX_OBSERVERS, total: ids.length }; + } + + async function runReferenceComparison() { + if (!refObserverId) { refResult = null; refError = null; renderRefContent(); return; } + updateRefUrl(); + + var others = refOtherObserverIds(); + if (others.ids.length === 0) { + refResult = null; + refError = 'No other observers are available to compare against this reference.'; + renderRefContent(); + return; + } + + refLoading = true; + refError = null; + renderRefContent(); + + // #122 — stale-response guard: a slower, older request (e.g. the + // previous window) must never overwrite a newer one's result. Every + // call that can change what should be displayed (mode switch, window + // change, reference change, unmount) bumps refRequestToken; only the + // response matching the token captured here is applied. + var myToken = refRequestToken; + try { + var resp = await api( + '/analytics/observer-comparison?ref=' + encodeURIComponent(refObserverId) + + '&hours=' + encodeURIComponent(refHours) + + '&observers=' + others.ids.map(encodeURIComponent).join(','), + { ttl: CLIENT_TTL.observerComparison || 20000 } + ); + if (myToken !== refRequestToken) return; // superseded by a newer request + refResult = resp; + refResult._truncated = others.truncated; + refResult._eligibleTotal = others.total; + refError = null; + } catch (e) { + if (myToken !== refRequestToken) return; + refResult = null; + refError = (e.body && e.body.error) || e.message; + } finally { + if (myToken === refRequestToken) refLoading = false; + } + renderRefContent(); + } + + // #122 — toggle/change the active sort column; same column reverses + // direction, a new column starts ascending. Pure client-side re-sort of + // the already-fetched result, no refetch. + function applyRefSort(column) { + if (!refResult) return; + if (refSortColumn === column) { + refSortDir = refSortDir === 'asc' ? 'desc' : 'asc'; + } else { + refSortColumn = column; + refSortDir = 'asc'; + } + renderRefContent(); + } + + function refDrillDownLink(otherId) { + return '#/compare?a=' + encodeURIComponent(refObserverId) + '&b=' + encodeURIComponent(otherId) + + (refHours !== 24 ? '&hours=' + refHours : ''); + } + + function renderRefContent() { + var content = document.getElementById('compareContent'); + if (!content) return; + + if (!refObserverId) { + content.innerHTML = '
Select a reference observer to compare it against every other eligible observer.
'; + return; + } + if (refLoading && !refResult) { + content.innerHTML = '
Comparing observers\u2026
'; + return; + } + if (refError) { + content.innerHTML = '
Error: ' + escapeHtml(refError) + '
'; + return; + } + if (!refResult) { + content.innerHTML = '
No data.
'; + return; + } + + var r = refResult; + var refName = escapeHtml(r.reference.name || r.reference.id); + var windowLabel = escapeHtml(REF_WINDOW_LABELS[r.window.hours] || (r.window.hours + 'h')); + var rows = sortComparisonRows(r.rows, refSortColumn, refSortDir); + + var headerHtml = + '
' + + '
' + refName + '
' + + '
' + r.reference.total.toLocaleString() + ' unique RX transmission' + (r.reference.total === 1 ? '' : 's') + ' — ' + windowLabel + '
' + + '
'; + + // #122 "Honest interpretation": no data vs. zero overlap are distinct, + // and low overlap is never presented as a health/quality verdict. + var noteHtml = '

Counts cover the window above only and come from every retained RX observation in it, not a packet-list sample. Low overlap can reflect different locations, frequencies, routes, downtime, or reporting gaps — it is not a health score.

'; + if (r._truncated) { + noteHtml += '

\u26A0\uFE0F Showing ' + r.rows.length + ' of ' + r._eligibleTotal + ' eligible observers (request limit). Results below are exact for the observers shown; the comparison is not over the full observer population.

'; + } + + if (r.reference.total === 0) { + noteHtml = '

\u26A0\uFE0F ' + refName + ' has no retained RX data in this window — percentages below are unavailable, not zero.

' + noteHtml; + } + + if (rows.length === 0) { + content.innerHTML = headerHtml + noteHtml + '
No other eligible observers to compare.
'; + return; + } + + var tableHtml = + '
' + + '' + + REF_COLUMNS.map(function (c) { + var on = refSortColumn === c.key; + var arrow = on ? (refSortDir === 'asc' ? ' ▲' : ' ▼') : ''; + return ''; + }).join('') + + '' + + '' + rows.map(function (row) { + var name = escapeHtml(row.observerName || row.observerId); + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }).join('') + + '' + + '
' + name + '' + row.total.toLocaleString() + '' + row.shared.toLocaleString() + '' + row.referenceOnly.toLocaleString() + '' + row.otherOnly.toLocaleString() + '' + escapeHtml(formatCoveragePct(row.referenceCoveragePct)) + '' + escapeHtml(formatCoveragePct(row.otherCoveragePct)) + '
'; + + content.innerHTML = headerHtml + noteHtml + tableHtml; + } + registerPage('compare', { init: init, destroy: destroy }); })(); diff --git a/public/style.css b/public/style.css index 024250e8d..e1f48795b 100644 --- a/public/style.css +++ b/public/style.css @@ -4238,6 +4238,33 @@ button.region-pill-active:hover { opacity: 0.85; color: var(--text-on-accent); } .compare-table { font-size: var(--fs-sm); } +/* ── #122 reference-to-many mode ─────────────────────────────────── */ +.compare-mode-toggle { + display: flex; gap: var(--space-xs); margin-bottom: var(--space-sm); flex-wrap: wrap; +} +.compare-ref-headline { + margin-bottom: var(--space-sm); +} +.compare-ref-headline-name { + font-size: var(--fs-lg); font-weight: var(--fw-semibold); color: var(--text); +} +.compare-ref-headline-sub { + font-size: var(--fs-sm); color: var(--text-muted); font-variant-numeric: tabular-nums; +} +.compare-ref-note { + font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.5; + margin: 0 0 var(--space-sm); +} +.compare-ref-table { font-size: var(--fs-sm); } +.compare-ref-table .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } +.compare-ref-sort-btn { + background: transparent; border: none; color: var(--text-muted); + font-size: var(--fs-sm); font-weight: var(--fw-semibold); cursor: pointer; + padding: 2px 0; white-space: nowrap; +} +.compare-ref-sort-btn:hover { color: var(--text); } +.compare-ref-sort-btn.active { color: var(--accent); } + @media (max-width: 640px) { .compare-selector { flex-direction: column; align-items: stretch; } .compare-select-group { min-width: 0; } diff --git a/scripts/e2e-shards.json b/scripts/e2e-shards.json index 82fcac402..350d90a37 100644 --- a/scripts/e2e-shards.json +++ b/scripts/e2e-shards.json @@ -2314,6 +2314,14 @@ "secondsByExecution": [ 5 ] + }, + { + "file": "test-issue-122-compare-reference-many-e2e.js", + "shard": 3, + "env": { + "BASE_URL": "$BASE_URL" + }, + "output": "e2e" } ], "exclusions": [ diff --git a/test-all.sh b/test-all.sh index 185a7f458..d379d46c0 100755 --- a/test-all.sh +++ b/test-all.sh @@ -232,6 +232,7 @@ run test-channel-live-decrypt.js run test-color-picker-ux.js run test-compare-flood-filter.js run test-compare-overlap.js +run test-issue-122-compare-reference-many.js run test-embed-mode-1369.js run test-geofilter-draft.js run test-hash-color.js diff --git a/test-fixtures/seed-122-observer-comparison.sql b/test-fixtures/seed-122-observer-comparison.sql new file mode 100644 index 000000000..6fdf037db --- /dev/null +++ b/test-fixtures/seed-122-observer-comparison.sql @@ -0,0 +1,69 @@ +-- #122 fixture: deterministic reference-to-many overlap data. +-- +-- The committed e2e-fixture.db has observations.direction = NULL for every +-- row (it predates the firmware/MQTT "direction" field) -- correctly, the +-- #122 endpoint treats an unknown direction as "not RX" rather than +-- guessing, so without this seed every comparison in the fixture would be +-- all-zero/"no data" and the reference-to-many E2E couldn't exercise real +-- overlap, sorting-by-value, or the headline counts. +-- +-- Uses the fixture's first three real, active observers (rowid 1 "Kennedy +-- Repeater", rowid 2 "GY889 Repeater", rowid 3 "DUBLIN Observer") so the +-- E2E can pick the reference by its known name rather than a brittle index. +-- Synthetic transmission ids are a deliberately out-of-band sentinel range +-- (-1220001..-1220012, mirroring the -1000000 #1791 convention in +-- deploy.yml) so they cannot collide with real ingested rows. Run AFTER +-- the schema migration (./corescope-migrate), like the other seed-*.sql +-- files, and AFTER tools/freshen-fixture.sh (timestamps use strftime('now') +-- directly, so they land inside every #122 window -- 1h/6h/24h -- no +-- matter when CI runs). +-- +-- Overlap design (rowid 1 = reference "Kennedy Repeater"): +-- Kennedy sees tx 1..10 (|R| = 10) +-- GY889 sees tx 1..6 + tx 11,12 (|O| = 8, shared = 6 -> 60.0% / 75.0%, the issue's own worked example) +-- DUBLIN sees tx 1..3 (|O| = 3, shared = 3 -> 30.0% / 100.0%) + +INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type) +VALUES + (-1220001, '0100', '122cmp0000000001', strftime('%Y-%m-%dT%H:%M:%SZ','now'), 1, 1), + (-1220002, '0100', '122cmp0000000002', strftime('%Y-%m-%dT%H:%M:%SZ','now'), 1, 1), + (-1220003, '0100', '122cmp0000000003', strftime('%Y-%m-%dT%H:%M:%SZ','now'), 1, 1), + (-1220004, '0100', '122cmp0000000004', strftime('%Y-%m-%dT%H:%M:%SZ','now'), 1, 1), + (-1220005, '0100', '122cmp0000000005', strftime('%Y-%m-%dT%H:%M:%SZ','now'), 1, 1), + (-1220006, '0100', '122cmp0000000006', strftime('%Y-%m-%dT%H:%M:%SZ','now'), 1, 1), + (-1220007, '0100', '122cmp0000000007', strftime('%Y-%m-%dT%H:%M:%SZ','now'), 1, 1), + (-1220008, '0100', '122cmp0000000008', strftime('%Y-%m-%dT%H:%M:%SZ','now'), 1, 1), + (-1220009, '0100', '122cmp0000000009', strftime('%Y-%m-%dT%H:%M:%SZ','now'), 1, 1), + (-1220010, '0100', '122cmp0000000010', strftime('%Y-%m-%dT%H:%M:%SZ','now'), 1, 1), + (-1220011, '0100', '122cmp0000000011', strftime('%Y-%m-%dT%H:%M:%SZ','now'), 1, 1), + (-1220012, '0100', '122cmp0000000012', strftime('%Y-%m-%dT%H:%M:%SZ','now'), 1, 1); + +INSERT INTO observations (transmission_id, observer_idx, direction, path_json, timestamp) +VALUES + -- Kennedy Repeater (rowid 1, reference): tx 1..10 + (-1220001, 1, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220002, 1, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220003, 1, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220004, 1, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220005, 1, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220006, 1, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220007, 1, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220008, 1, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220009, 1, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220010, 1, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + -- GY889 Repeater (rowid 2): tx 1..6 (shared) + 11,12 (own) = 8 total, 6 shared + (-1220001, 2, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220002, 2, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220003, 2, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220004, 2, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220005, 2, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220006, 2, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220011, 2, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220012, 2, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + -- DUBLIN Observer (rowid 3): tx 1..3 (all shared, 100% of its own traffic) + (-1220001, 3, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220002, 3, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + (-1220003, 3, 'RX', '[]', CAST(strftime('%s','now') AS INTEGER)), + -- A TX observation on a packet Kennedy also has RX -- must never count + -- toward GY889's total/shared (#122 "exclude TX"). + (-1220007, 2, 'TX', '[]', CAST(strftime('%s','now') AS INTEGER)); diff --git a/test-issue-122-compare-reference-many-e2e.js b/test-issue-122-compare-reference-many-e2e.js new file mode 100644 index 000000000..bf6fb5ed6 --- /dev/null +++ b/test-issue-122-compare-reference-many-e2e.js @@ -0,0 +1,173 @@ +/** + * E2E test (#122): reference-to-many observer comparison on the Compare page. + * + * Covers the issue's acceptance criteria at the browser level: mode + * selection, window changes, a stale-response race, column sorting, + * drill-down into the existing pairwise view, reload/deep-link restore, + * empty states, and a narrow viewport. Server-side correctness (exact + * overlap math, RX/TX/unknown direction, window boundaries, visibility + * filtering, caching/singleflight) is covered by cmd/server Go tests; + * this file only exercises the UI built on top of it. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-122-compare-reference-many-e2e.js + */ +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:3000'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.error(' ❌ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +async function run() { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] + }); + const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + page.on('pageerror', e => console.error(' pageerror:', e.message)); + + console.log('\nRunning #122 compare reference-to-many E2E tests against ' + BASE + '\n'); + + await step('Compare page shows a "One vs. many" mode toggle alongside "Two observers"', async () => { + await page.goto(BASE + '/#/compare', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('[data-mode-btn="pair"]', { timeout: 15000 }); + const pairBtn = await page.$('[data-mode-btn="pair"]'); + const refBtn = await page.$('[data-mode-btn="ref"]'); + assert(pairBtn, 'expected [data-mode-btn="pair"]'); + assert(refBtn, 'expected [data-mode-btn="ref"]'); + assert((await pairBtn.getAttribute('aria-selected')) === 'true', 'pair mode should start active'); + }); + + await step('Empty state before a reference is chosen', async () => { + await page.goto(BASE + '/#/compare', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('[data-mode-btn="ref"]', { timeout: 15000 }); + await page.click('[data-mode-btn="ref"]'); + await page.waitForSelector('#compareRefObs', { timeout: 10000 }); + const text = await page.$eval('#compareContent', el => el.textContent); + assert(/select a reference/i.test(text), 'expected a prompt to select a reference, got: ' + text); + }); + + let refId, otherIds; + await step('Picking a reference observer renders the comparison table', async () => { + await page.goto(BASE + '/#/compare', { waitUntil: 'domcontentloaded' }); + await page.click('[data-mode-btn="ref"]'); + await page.waitForSelector('#compareRefObs', { timeout: 10000 }); + const options = await page.$$eval('#compareRefObs option', opts => + opts.map(o => o.value).filter(v => v)); + assert(options.length >= 2, 'need at least 2 observers in fixture, got ' + options.length); + refId = options[0]; + await page.selectOption('#compareRefObs', refId); + await page.waitForSelector('.compare-ref-table tbody tr', { timeout: 15000 }); + otherIds = await page.$$eval('.compare-ref-table tbody tr[data-action="navigate"]', rows => + rows.map(r => r.getAttribute('data-value'))); + assert(otherIds.length >= 1, 'expected at least one comparison row'); + const headline = await page.$eval('.compare-ref-headline-name', el => el.textContent); + assert(headline.trim().length > 0, 'expected the reference name in the headline'); + }); + + await step('URL reflects mode/ref/hours for sharing', async () => { + const h = await page.evaluate(() => location.hash); + assert(h.indexOf('mode=ref') >= 0, 'expected mode=ref in hash: ' + h); + assert(h.indexOf('ref=' + encodeURIComponent(refId)) >= 0, 'expected ref= in hash: ' + h); + }); + + await step('Reloading the deep link restores the same reference and table', async () => { + const h = await page.evaluate(() => location.hash); + await page.goto(BASE + '/' + h, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#compareRefObs', { timeout: 15000 }); + const val = await page.$eval('#compareRefObs', el => el.value); + assert(val === refId, 'expected #compareRefObs to restore to ' + refId + ', got ' + val); + await page.waitForSelector('.compare-ref-table tbody tr', { timeout: 15000 }); + }); + + await step('Changing the window re-fetches and keeps exactly one in-flight result (stale-response guard)', async () => { + await page.waitForSelector('#compareRefHours', { timeout: 10000 }); + // Fire two window changes back-to-back -- the UI must settle on the + // LAST one, not an earlier in-flight response that resolves later. + await page.selectOption('#compareRefHours', '1'); + await page.selectOption('#compareRefHours', '6'); + await page.waitForFunction(() => { + var sel = document.getElementById('compareRefHours'); + return sel && sel.value === '6'; + }, null, { timeout: 5000 }); + await page.waitForSelector('.compare-ref-table, .compare-ref-note', { timeout: 15000 }); + const h = await page.evaluate(() => location.hash); + assert(h.indexOf('hours=6') >= 0, 'expected hours=6 in hash after settling, got: ' + h); + const val = await page.$eval('#compareRefHours', el => el.value); + assert(val === '6', 'select must reflect the last window chosen, got ' + val); + }); + + await step('Sort headers reorder the table and toggle aria-sort', async () => { + await page.waitForSelector('.compare-ref-sort-btn[data-sort="total"]', { timeout: 10000 }); + const before = await page.$$eval('.compare-ref-table tbody tr td:first-child', tds => tds.map(td => td.textContent)); + await page.click('.compare-ref-sort-btn[data-sort="total"]'); + await page.waitForFunction(() => { + var btn = document.querySelector('.compare-ref-sort-btn[data-sort="total"]'); + return btn && btn.getAttribute('aria-sort') !== 'none'; + }, null, { timeout: 5000 }); + const sort1 = await page.$eval('.compare-ref-sort-btn[data-sort="total"]', el => el.getAttribute('aria-sort')); + assert(sort1 === 'ascending' || sort1 === 'descending', 'expected aria-sort to be set, got ' + sort1); + // Clicking the same header again must flip direction. + await page.click('.compare-ref-sort-btn[data-sort="total"]'); + await page.waitForFunction((prev) => { + var btn = document.querySelector('.compare-ref-sort-btn[data-sort="total"]'); + return btn && btn.getAttribute('aria-sort') !== prev && btn.getAttribute('aria-sort') !== 'none'; + }, sort1, { timeout: 5000 }); + const sort2 = await page.$eval('.compare-ref-sort-btn[data-sort="total"]', el => el.getAttribute('aria-sort')); + assert(sort2 !== sort1, 'expected the second click to reverse sort direction'); + void before; // order itself is data-dependent; aria-sort + no-crash is the contract under test + }); + + await step('Clicking a row drills into the existing two-observer view with the same window', async () => { + const row = await page.waitForSelector('.compare-ref-table tbody tr[data-action="navigate"]'); + const target = await row.getAttribute('data-value'); + assert(/^#\/compare\?a=/.test(target), 'expected a drill-down link into pair mode, got ' + target); + await row.click(); + await page.waitForFunction((t) => location.hash === t, target, { timeout: 5000 }); + await page.waitForSelector('#compareObsA', { timeout: 15000 }); + const valA = await page.$eval('#compareObsA', el => el.value); + assert(valA === refId, 'expected pairwise Observer A to be the reference, got ' + valA); + const pairBtn = await page.$('[data-mode-btn="pair"]'); + assert((await pairBtn.getAttribute('aria-selected')) === 'true', 'drilling down should switch the toggle back to pair mode'); + }); + + await step('Switching back to "One vs. many" restores the previously chosen reference', async () => { + await page.click('[data-mode-btn="ref"]'); + await page.waitForSelector('#compareRefObs', { timeout: 10000 }); + const val = await page.$eval('#compareRefObs', el => el.value); + assert(val === refId, 'expected the reference selection to persist across a mode toggle, got ' + val); + }); + + // ── Narrow viewport ── + const narrow = await browser.newContext({ viewport: { width: 375, height: 700 } }); + const np = await narrow.newPage(); + np.setDefaultTimeout(15000); + np.on('pageerror', e => console.error(' pageerror:', e.message)); + + await step('Narrow screen: mode toggle and reference table remain usable', async () => { + await np.goto(BASE + '/#/compare?mode=ref&ref=' + encodeURIComponent(refId), { waitUntil: 'domcontentloaded' }); + await np.waitForSelector('[data-mode-btn="ref"]', { timeout: 15000 }); + await np.waitForSelector('.compare-ref-table tbody tr', { timeout: 15000 }); + const overflow = await np.evaluate(() => { + var el = document.querySelector('.compare-page'); + return el ? el.scrollWidth - document.documentElement.clientWidth : 0; + }); + assert(overflow <= 4, 'compare-page should not force horizontal body scroll at 375px, overflow=' + overflow); + const btn = await np.$('[data-mode-btn="ref"]'); + assert(await btn.isVisible(), 'mode toggle button should stay visible/usable on a narrow screen'); + }); + + await narrow.close(); + await browser.close(); + console.log('\n' + passed + ' passed, ' + failed + ' failed'); + if (failed) process.exit(1); +} + +run().catch(e => { console.error(e); process.exit(1); }); diff --git a/test-issue-122-compare-reference-many.js b/test-issue-122-compare-reference-many.js new file mode 100644 index 000000000..3be5abcfa --- /dev/null +++ b/test-issue-122-compare-reference-many.js @@ -0,0 +1,162 @@ +/* Unit tests for compare.js reference-to-many helpers — #122 */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(` ✅ ${name}`); } + catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); } +} + +function makeSandbox() { + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '', addEventListener: () => {} }), + head: { appendChild: () => {} }, + getElementById: () => null, + querySelectorAll: () => [], + addEventListener: () => {}, + }, + console, + setTimeout, clearTimeout, + location: { hash: '#/compare', href: '' }, + history: { replaceState: () => {} }, + URLSearchParams, + Map, Set, Date, Promise, + escapeHtml: (s) => s, + api: () => Promise.resolve({ observers: [] }), + CLIENT_TTL: { observers: 0, observerComparison: 0 }, + registerPage: () => {}, + timeAgo: () => '', + payloadTypeColor: () => '', + }; + ctx.self = ctx.window; + return ctx; +} + +const ctx = makeSandbox(); +const sandbox = vm.createContext(ctx); +const compareSrc = fs.readFileSync(__dirname + '/public/compare.js', 'utf8'); +vm.runInContext(compareSrc, sandbox); + +console.log('\ncompare.js reference-to-many helpers (#122):'); + +test('formatCoveragePct, buildOtherObserverIds, sortComparisonRows are exposed on window', () => { + assert.strictEqual(typeof sandbox.window.formatCoveragePct, 'function'); + assert.strictEqual(typeof sandbox.window.buildOtherObserverIds, 'function'); + assert.strictEqual(typeof sandbox.window.sortComparisonRows, 'function'); +}); + +// ---- formatCoveragePct ------------------------------------------------ + +test('formatCoveragePct renders null as unavailable, never 0%', () => { + const out = sandbox.window.formatCoveragePct(null); + assert.strictEqual(out, '—'); + assert.notStrictEqual(out, '0.0%', 'a zero-denominator percentage must not read as 0%'); +}); + +test('formatCoveragePct renders undefined the same as null', () => { + assert.strictEqual(sandbox.window.formatCoveragePct(undefined), '—'); +}); + +test('formatCoveragePct renders a real zero as "0.0%", distinct from unavailable', () => { + assert.strictEqual(sandbox.window.formatCoveragePct(0), '0.0%'); +}); + +test('formatCoveragePct formats to one decimal place', () => { + assert.strictEqual(sandbox.window.formatCoveragePct(60), '60.0%'); + assert.strictEqual(sandbox.window.formatCoveragePct(66.666), '66.7%'); +}); + +// ---- buildOtherObserverIds -------------------------------------------- + +test('buildOtherObserverIds excludes only the reference id', () => { + const observers = [{ id: 'a' }, { id: 'ref' }, { id: 'b' }]; + const ids = sandbox.window.buildOtherObserverIds(observers, 'ref'); + assert.deepStrictEqual(ids, ['a', 'b']); +}); + +test('buildOtherObserverIds preserves input order (caller controls sort)', () => { + const observers = [{ id: 'zed' }, { id: 'alpha' }, { id: 'ref' }]; + const ids = sandbox.window.buildOtherObserverIds(observers, 'ref'); + assert.deepStrictEqual(ids, ['zed', 'alpha']); +}); + +test('buildOtherObserverIds does not mutate its input', () => { + const observers = [{ id: 'a' }, { id: 'ref' }]; + const copy = observers.slice(); + sandbox.window.buildOtherObserverIds(observers, 'ref'); + assert.deepStrictEqual(observers, copy); +}); + +test('buildOtherObserverIds returns everything when the reference is absent', () => { + const observers = [{ id: 'a' }, { id: 'b' }]; + assert.deepStrictEqual(sandbox.window.buildOtherObserverIds(observers, 'nope'), ['a', 'b']); +}); + +// ---- sortComparisonRows ------------------------------------------------ + +function row(id, name, total, shared, referenceOnly, otherOnly, refPct, otherPct) { + return { + observerId: id, observerName: name, total: total, shared: shared, + referenceOnly: referenceOnly, otherOnly: otherOnly, + referenceCoveragePct: refPct, otherCoveragePct: otherPct, + }; +} + +test('sortComparisonRows sorts by name, case-insensitive, ascending', () => { + const rows = [row('z', 'Zed', 1, 1, 0, 0, 100, 100), row('a', 'alpha', 1, 1, 0, 0, 100, 100), row('b', 'Beta', 1, 1, 0, 0, 100, 100)]; + const sorted = sandbox.window.sortComparisonRows(rows, 'name', 'asc'); + assert.deepStrictEqual(sorted.map(r => r.observerId), ['a', 'b', 'z']); +}); + +test('sortComparisonRows reverses with direction=desc', () => { + const rows = [row('a', 'Alpha', 1, 1, 0, 0, 100, 100), row('z', 'Zed', 1, 1, 0, 0, 100, 100)]; + const sorted = sandbox.window.sortComparisonRows(rows, 'name', 'desc'); + assert.deepStrictEqual(sorted.map(r => r.observerId), ['z', 'a']); +}); + +test('sortComparisonRows sorts numerically on a count column', () => { + const rows = [row('a', 'A', 50, 10, 40, 5, 20, 20), row('b', 'B', 5, 1, 4, 0, 20, 20), row('c', 'C', 20, 3, 17, 1, 15, 15)]; + const sorted = sandbox.window.sortComparisonRows(rows, 'total', 'asc'); + assert.deepStrictEqual(sorted.map(r => r.observerId), ['b', 'c', 'a']); +}); + +test('sortComparisonRows does not mutate the input array', () => { + const rows = [row('z', 'Zed', 1, 1, 0, 0, 100, 100), row('a', 'Alpha', 1, 1, 0, 0, 100, 100)]; + const original = rows.slice(); + sandbox.window.sortComparisonRows(rows, 'name', 'asc'); + assert.deepStrictEqual(rows, original, 'sortComparisonRows must return a copy, not sort in place'); +}); + +// This is the mutant-catching case for the #122 "unavailable, not 0%" +// requirement as it propagates into sorting: a null percentage (zero +// denominator) must sort as the WORST/last value in both directions, never +// silently compare as if it were numeric 0 (which would place it ahead of +// real small-but-nonzero percentages on an ascending sort). +test('sortComparisonRows sorts a null (unavailable) percentage last in both directions', () => { + const rows = [ + row('has-data', 'HasData', 10, 1, 9, 0, 10, 10), + row('unavailable', 'Unavailable', 0, 0, 0, 0, null, null), + ]; + const asc = sandbox.window.sortComparisonRows(rows, 'otherCoveragePct', 'asc'); + assert.strictEqual(asc[0].observerId, 'has-data', 'null must not sort before a real 10% on ascending'); + assert.strictEqual(asc[1].observerId, 'unavailable'); + + const desc = sandbox.window.sortComparisonRows(rows, 'otherCoveragePct', 'desc'); + assert.strictEqual(desc[0].observerId, 'has-data', 'null must still sort last on descending'); + assert.strictEqual(desc[1].observerId, 'unavailable'); +}); + +test('sortComparisonRows breaks ties by observerId for a deterministic order', () => { + const rows = [row('b', 'Same', 1, 1, 0, 0, 100, 100), row('a', 'Same', 1, 1, 0, 0, 100, 100)]; + const sorted = sandbox.window.sortComparisonRows(rows, 'name', 'asc'); + assert.deepStrictEqual(sorted.map(r => r.observerId), ['a', 'b']); +}); + +console.log(`\n ${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0); diff --git a/test-issue-1644-redesign.js b/test-issue-1644-redesign.js index 8f2680bbc..40f4d4870 100644 --- a/test-issue-1644-redesign.js +++ b/test-issue-1644-redesign.js @@ -156,13 +156,19 @@ test('observers render() calls preserveCompareSelection after innerHTML rewrite' }); // ── 4) a11y — tablist + clickable strip semantics (#1644 round-1) ──── +// +// #122 added a SECOND, unrelated tablist to this file (the pair/reference +// mode toggle, `data-mode-btn`) that also uses class="tab-btn" + role="tab" +// but controls which CONTROL PANEL renders, not #compareDetail. Both regexes +// below require the `data-cview` marker so they match only the original +// Summary/Both/OnlyA/OnlyB detail tabs, not the mode toggle. test('compare tab buttons declare aria-selected (synced with .active)', () => { // Tab markup is built as a string in compare.js. Each `