diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 108d2b970..7de7c14a5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,6 +170,7 @@ jobs: node test-a11y-1716-rf-range-btn-active.js node test-issue-1705-subpath-contrast.js node test-analytics-table-ids-unique.js + node test-analytics-relay-airtime-dumbbell.js node test-live-vcr-mode-contrast.js node test-issue-1770-mobile-row-clamp.js node test-a11y-axe-routes-coverage.js diff --git a/cmd/ingestor/advert_route_first_ingested_test.go b/cmd/ingestor/advert_route_first_ingested_test.go new file mode 100644 index 000000000..de9532572 --- /dev/null +++ b/cmd/ingestor/advert_route_first_ingested_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "strings" + "testing" +) + +// The same ADVERT can reach the ingestor on both route classes: MeshCore's +// BaseChatMesh::shareContactZeroHop re-sends a stored advert unchanged as a +// TRANSPORT_DIRECT zero-hop packet. ComputeContentHash ignores the route bits, +// transport codes and path, so both variants share one transmissions row, and +// InsertTransmission never rewrites route_type or raw_hex for an existing hash. +// The stored route is therefore that of the first observation *inserted*, not +// the earliest received. Relay Airtime Share's ADVERT route classes are built +// on this contract; these tests pin it so any change to it is deliberate. + +var firstIngestedAdvertPayload = "" + + strings.Repeat("86", 32) + // public key + "00e1f565" + // timestamp + strings.Repeat("00", 64) + // signature (not validated here) + "81" + "50523836" // app data: flags + name "PR86" + +var ( + // ROUTE_TYPE_FLOOD (header 0x11 = ADVERT<<2 | 1), two 1-byte relay hops. + firstIngestedFloodRaw = "11" + "02" + "a1b2" + firstIngestedAdvertPayload + // ROUTE_TYPE_TRANSPORT_DIRECT (0x13), transport codes {0,0}, path_len 0: + // exactly what shareContactZeroHop sends. + firstIngestedZeroHopRaw = "13" + "00000000" + "00" + firstIngestedAdvertPayload +) + +type advertObservation struct { + raw, observer, rxTime string +} + +func advertPacketData(t *testing.T, o advertObservation) *PacketData { + t.Helper() + decoded, err := DecodePacket(o.raw, nil, false) + if err != nil { + t.Fatalf("decode %s: %v", o.raw[:10], err) + } + pd := BuildPacketData(&MQTTPacketMessage{Raw: o.raw}, decoded, o.observer, "", nil) + pd.Timestamp = o.rxTime + return pd +} + +type storedAdvert struct { + rows int + routeType int + rawHex string + firstSeen string + observations int +} + +func ingestAdverts(t *testing.T, obs ...advertObservation) (storedAdvert, string) { + t.Helper() + s := newTestStore(t) + var hash string + for i, o := range obs { + pd := advertPacketData(t, o) + if pd.PayloadType != PayloadADVERT { + t.Fatalf("observation %d decoded as payload %d, want ADVERT", i, pd.PayloadType) + } + if i == 0 { + hash = pd.Hash + } else if pd.Hash != hash { + t.Fatalf("observation %d hash %s differs from %s; route variants must share a content hash", i, pd.Hash, hash) + } + if _, err := s.InsertTransmission(pd); err != nil { + t.Fatalf("insert observation %d: %v", i, err) + } + } + var got storedAdvert + if err := s.db.QueryRow(`SELECT COUNT(*), MAX(route_type), MAX(raw_hex), MAX(first_seen) FROM transmissions WHERE hash = ?`, hash). + Scan(&got.rows, &got.routeType, &got.rawHex, &got.firstSeen); err != nil { + t.Fatalf("query transmissions: %v", err) + } + if err := s.db.QueryRow(`SELECT COUNT(*) FROM observations o JOIN transmissions t ON t.id = o.transmission_id WHERE t.hash = ?`, hash). + Scan(&got.observations); err != nil { + t.Fatalf("query observations: %v", err) + } + return got, hash +} + +func TestAdvertRouteVariantsShareContentHash(t *testing.T) { + flood := advertPacketData(t, advertObservation{firstIngestedFloodRaw, "obs-a", "2026-09-23T10:00:00Z"}) + zeroHop := advertPacketData(t, advertObservation{firstIngestedZeroHopRaw, "obs-b", "2026-09-23T10:05:00Z"}) + if flood.RouteType != RouteFlood || zeroHop.RouteType != RouteTransportDirect { + t.Fatalf("route types = %d/%d, want %d/%d", flood.RouteType, zeroHop.RouteType, RouteFlood, RouteTransportDirect) + } + if flood.Hash != zeroHop.Hash { + t.Fatalf("hashes differ: flood %s, zero-hop %s", flood.Hash, zeroHop.Hash) + } +} + +func TestInsertTransmission_AdvertRouteIsFirstIngested(t *testing.T) { + cases := []struct { + name string + obs []advertObservation + wantRoute int + wantRaw string + wantFirstSeen string + }{ + { + name: "flood then zero-hop", + obs: []advertObservation{ + {firstIngestedFloodRaw, "obs-a", "2026-09-23T10:00:00Z"}, + {firstIngestedZeroHopRaw, "obs-b", "2026-09-23T10:05:00Z"}, + }, + wantRoute: RouteFlood, wantRaw: firstIngestedFloodRaw, wantFirstSeen: "2026-09-23T10:00:00Z", + }, + { + name: "zero-hop then flood", + obs: []advertObservation{ + {firstIngestedZeroHopRaw, "obs-b", "2026-09-23T10:00:00Z"}, + {firstIngestedFloodRaw, "obs-a", "2026-09-23T10:05:00Z"}, + }, + wantRoute: RouteTransportDirect, wantRaw: firstIngestedZeroHopRaw, wantFirstSeen: "2026-09-23T10:00:00Z", + }, + { + // A delayed zero-hop observation inserted first keeps its route even + // though the flood observation was received earlier: first_seen moves + // back, route_type and raw_hex do not. + name: "zero-hop inserted first but received later", + obs: []advertObservation{ + {firstIngestedZeroHopRaw, "obs-b", "2026-09-23T10:05:00Z"}, + {firstIngestedFloodRaw, "obs-a", "2026-09-23T10:00:00Z"}, + }, + wantRoute: RouteTransportDirect, wantRaw: firstIngestedZeroHopRaw, wantFirstSeen: "2026-09-23T10:00:00Z", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, _ := ingestAdverts(t, tc.obs...) + if got.rows != 1 || got.observations != 2 { + t.Fatalf("rows=%d observations=%d, want one transmission with two observations", got.rows, got.observations) + } + if got.routeType != tc.wantRoute { + t.Errorf("route_type = %d, want %d (first inserted observation)", got.routeType, tc.wantRoute) + } + if got.rawHex != tc.wantRaw { + t.Errorf("raw_hex header %s, want %s: raw_hex must stay consistent with route_type", got.rawHex[:2], tc.wantRaw[:2]) + } + if got.firstSeen != tc.wantFirstSeen { + t.Errorf("first_seen = %s, want %s (earliest rx time)", got.firstSeen, tc.wantFirstSeen) + } + }) + } +} diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index b454f3724..b1af86407 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -122,6 +122,12 @@ func routeDescriptions() map[string]routeMeta { "GET /api/analytics/subpaths-bulk": {Summary: "Bulk subpath analysis", Tag: "analytics"}, "GET /api/analytics/subpath-detail": {Summary: "Subpath detail", Tag: "analytics"}, "GET /api/analytics/neighbor-graph": {Summary: "Neighbor graph", Description: "Full neighbor affinity graph for visualization.", Tag: "analytics"}, + "GET /api/analytics/relay-airtime-share": {Summary: "Relay airtime share per payload type", Description: "Overview tab's Relay Airtime Share. Each distinct packet (deduplicated by content hash) scores LoRa Time-on-Air of its raw bytes × distinct repeaters that relayed it, under the assumed preset returned in preset (config analytics.loraPreset); originator TX is excluded. rows[] = {payload_type, type, route_class, count, count_pct, score, airtime_pct}, sorted by airtime_pct desc. type is the numeric payload type; payload_type is a display label and may change. ADVERT is split into up to three rows that all have type 4: route_class \"flood\" (route_type 0/1, label \"ADVERT (flood)\"), \"zero_hop\" (route_type 2/3, label \"ADVERT (zero-hop)\") and \"legacy\" (NULL or out-of-range route_type, label \"ADVERT\"). route_class is null on every other row, so key rows by (type, route_class), never by type alone. The route is the one stored on the transmission: the first observation the ingestor inserted for that content hash, even if later observations of the same hash arrive on the other route class (e.g. a contact re-shared as a zero-hop advert). count_pct and airtime_pct are shares of total_count and total_score (nanoseconds). Payload Type Mix (/api/analytics/rf payloadTypes) is not split. Cached in the RF analytics cache (60s default).", Tag: "analytics", + QueryParams: []paramMeta{ + {Name: "window", Description: "Relative window: 1h, 24h/1d, 3d, 7d/1w or 30d (default: all loaded packets)", Type: "string"}, + {Name: "from", Description: "RFC3339 start; if from or to is given, window is ignored (an unparseable value leaves that bound open)", Type: "string"}, + {Name: "to", Description: "RFC3339 end; if from or to is given, window is ignored (an unparseable value leaves that bound open)", Type: "string"}, + }}, "GET /api/analytics/wardriving": {Summary: "Wardriving channel analytics", Description: "Activity/entry-point/coverage/signal/session analytics for the #wardriving channel (or another channel via ?channel=): message volume over time, top senders, path[0] entry-point hash-prefix tallies (resolve names via /api/resolve-hops), per-observer coverage (observer's known IATA-derived coordinates, not the sender's — MeshMapper's wardriving messages normally carry an anonymous session token, not live GPS), average SNR/RSSI over the same time buckets as the activity series, each sender's messages grouped into distinct sessions/runs (split on a 15-minute gap, each with an AirtimeMs field — LoRa Time-on-Air × distinct relaying repeaters, same formula as the Overview tab's Relay Airtime Share, omitted in DB-only mode), and any senders who explicitly shared their own position (some clients append plaintext \",\" after the token — a deliberate choice by that sender, confirmed empirically, not something CoreScope infers). Cached 30s per window+channel.", Tag: "analytics", QueryParams: []paramMeta{ {Name: "window", Description: "Time window: 1h, 24h (default), or 7d", Type: "string"}, diff --git a/cmd/server/openapi_known_gaps.json b/cmd/server/openapi_known_gaps.json index aa60ec929..128fec18c 100644 --- a/cmd/server/openapi_known_gaps.json +++ b/cmd/server/openapi_known_gaps.json @@ -4,7 +4,6 @@ "routes": [ "/api/admin/prune-geo-filter", "/api/admin/prune-geo-filter/status", - "/api/analytics/relay-airtime-share", "/api/analytics/roles", "/api/config/areas", "/api/config/areas/polygons", diff --git a/cmd/server/relay_airtime_share.go b/cmd/server/relay_airtime_share.go index 4127e4559..65000f40d 100644 --- a/cmd/server/relay_airtime_share.go +++ b/cmd/server/relay_airtime_share.go @@ -25,7 +25,8 @@ import ( // preset 869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5, with the // SF-dependent preamble pulled from internal/lora.PreambleForSF. // -// Aggregated by payload_type. Originator TX is deliberately excluded — a +// Aggregated by payload_type, except ADVERT packets which are split into flood +// and zero-hop route classes. Originator TX is deliberately excluded — a // never-relayed direct message scores 0, which is the correct framing for a // "relay amplification" metric. In-memory only; no SQL, no new index. @@ -197,12 +198,100 @@ func (s *PacketStore) AirtimeAndRelayCountForTransmission(txID int64) (total tim return total, relays, true } -// computeRelayAirtimeShare aggregates relay-airtime-share per payload_type. +type relayAirtimeAdvertRoute uint8 + +const ( + relayAirtimeAdvertUnknown relayAirtimeAdvertRoute = iota + relayAirtimeAdvertFlood + relayAirtimeAdvertZeroHop +) + +type relayAirtimeBucketKey struct { + payloadType int + advertRoute relayAirtimeAdvertRoute +} + +// relayAirtimeKey keeps non-ADVERT aggregation unchanged while separating the +// two ADVERT behaviours defined by MeshCore's route bits. Unknown/legacy route +// values deliberately keep the historical unsuffixed ADVERT bucket rather than +// being silently assigned to either behaviour. tx.PayloadType must be non-nil. +func relayAirtimeKey(tx *StoreTx) relayAirtimeBucketKey { + key := relayAirtimeBucketKey{payloadType: *tx.PayloadType} + if key.payloadType != PayloadADVERT || tx.RouteType == nil { + return key + } + switch *tx.RouteType { + case RouteTransportFlood, RouteFlood: + key.advertRoute = relayAirtimeAdvertFlood + case RouteDirect, RouteTransportDirect: + key.advertRoute = relayAirtimeAdvertZeroHop + } + return key +} + +func relayAirtimeBucketName(key relayAirtimeBucketKey) string { + name := payloadTypeNames[key.payloadType] + if name == "" { + name = "UNK" + } + if key.payloadType != PayloadADVERT { + return name + } + switch key.advertRoute { + case relayAirtimeAdvertFlood: + return name + " (flood)" + case relayAirtimeAdvertZeroHop: + return name + " (zero-hop)" + default: + return name + } +} + +// Machine-readable ADVERT route classes, returned as route_class. They are +// part of the API contract and deliberately independent of the display +// labels built by relayAirtimeBucketName. +const ( + relayAirtimeRouteClassFlood = "flood" + relayAirtimeRouteClassZeroHop = "zero_hop" + relayAirtimeRouteClassLegacy = "legacy" +) + +// relayAirtimeRouteClass returns the route_class value for a row: one of the +// constants above for ADVERT rows and nil (JSON null) for every other payload +// type, so (type, route_class) identifies each row. +func relayAirtimeRouteClass(key relayAirtimeBucketKey) *string { + if key.payloadType != PayloadADVERT { + return nil + } + var class string + switch key.advertRoute { + case relayAirtimeAdvertFlood: + class = relayAirtimeRouteClassFlood + case relayAirtimeAdvertZeroHop: + class = relayAirtimeRouteClassZeroHop + default: + class = relayAirtimeRouteClassLegacy + } + return &class +} + +// computeRelayAirtimeShare aggregates relay-airtime-share per payload_type, +// separating ADVERT rows by their flood and zero-hop route classes. +// +// The route class is the route_type stored on the transmission, which is the +// route of the first observation the ingestor inserted for that content hash. +// The content hash ignores route bits, so a contact re-shared as a zero-hop +// advert has the same hash as the original flood advert; later observations +// never change the stored route (see cmd/ingestor InsertTransmission). // // Returns: // // { -// "rows": [{payload_type, type, count, count_pct, score, airtime_pct}, ...] sorted by airtime_pct desc, +// "rows": [{payload_type, type, route_class, count, count_pct, score, airtime_pct}, ...] +// sorted by airtime_pct desc, where type is the numeric payload type, +// payload_type the display label and route_class "flood" / "zero_hop" / +// "legacy" on ADVERT rows and null otherwise; up to three ADVERT rows +// share type 4, and (type, route_class) identifies each row, // "total_count": int, // "total_score": int64 (nanoseconds of LoRa Time-on-Air × repeater-count, summed across packets), // "window": window label, @@ -212,14 +301,13 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int s.mu.RLock() defer s.mu.RUnlock() - ptNames := payloadTypeNames preset := s.resolveLoRaPreset() type bucket struct { count int score int64 // sum of ToA(payload) × relays, in nanoseconds } - buckets := make(map[int]*bucket) + buckets := make(map[relayAirtimeBucketKey]*bucket) seenHash := make(map[string]bool, len(s.packets)) totalCount := 0 var totalScore int64 @@ -240,11 +328,11 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int } seenHash[tx.Hash] = true } - pt := *tx.PayloadType - b := buckets[pt] + key := relayAirtimeKey(tx) + b := buckets[key] if b == nil { b = &bucket{} - buckets[pt] = b + buckets[key] = b } b.count++ totalCount++ @@ -261,11 +349,8 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int } rows := make([]map[string]interface{}, 0, len(buckets)) - for pt, b := range buckets { - name := ptNames[pt] - if name == "" { - name = "UNK" - } + for key, b := range buckets { + name := relayAirtimeBucketName(key) var countPct, airtimePct float64 if totalCount > 0 { countPct = float64(b.count) / float64(totalCount) * 100.0 @@ -275,7 +360,8 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int } rows = append(rows, map[string]interface{}{ "payload_type": name, - "type": pt, + "type": key.payloadType, + "route_class": relayAirtimeRouteClass(key), "count": b.count, "count_pct": countPct, "score": b.score, @@ -283,8 +369,8 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int }) } - // Sort descending by airtime_pct; tiebreak count desc, then name asc - // for deterministic ordering. + // Sort descending by airtime_pct; tiebreak count desc, then name asc, + // then numeric type asc for deterministic ordering. sort.SliceStable(rows, func(i, j int) bool { ai, _ := rows[i]["airtime_pct"].(float64) aj, _ := rows[j]["airtime_pct"].(float64) @@ -298,7 +384,13 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int } ni, _ := rows[i]["payload_type"].(string) nj, _ := rows[j]["payload_type"].(string) - return ni < nj + if ni != nj { + return ni < nj + } + // Unnamed payload types all share the "UNK" label. + ti, _ := rows[i]["type"].(int) + tj, _ := rows[j]["type"].(int) + return ti < tj }) label := "" diff --git a/cmd/server/relay_airtime_share_advert_split_test.go b/cmd/server/relay_airtime_share_advert_split_test.go new file mode 100644 index 000000000..8c27a1651 --- /dev/null +++ b/cmd/server/relay_airtime_share_advert_split_test.go @@ -0,0 +1,490 @@ +package main + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "net/http/httptest" + "reflect" + "sort" + "testing" + + "github.com/gorilla/mux" + "github.com/meshcore-analyzer/lora" +) + +// Route-class contract for the ADVERT split in Relay Airtime Share +// (Kpa-clawbot/CoreScope#2041). MeshCore firmware (src/Packet.h) defines the +// two low header bits as the route type: 0 transport-flood, 1 flood, 2 direct, +// 3 transport-direct. Every firmware advert sent over a direct route goes +// through Mesh::sendZeroHop (path_len 0), so direct adverts are zero-hop. +// NULL and any value outside 0..3 must fall back to the historical +// unsuffixed ADVERT bucket, and no other payload type may be split. + +func TestRelayAirtimeKey_RouteClassification(t *testing.T) { + cases := []struct { + name string + payloadType int + routeType *int + wantRoute relayAirtimeAdvertRoute + wantLabel string + wantClass string // route_class; "" means null (non-ADVERT rows) + }{ + {"advert transport flood", PayloadADVERT, intPtr(RouteTransportFlood), relayAirtimeAdvertFlood, "ADVERT (flood)", "flood"}, + {"advert flood", PayloadADVERT, intPtr(RouteFlood), relayAirtimeAdvertFlood, "ADVERT (flood)", "flood"}, + {"advert direct", PayloadADVERT, intPtr(RouteDirect), relayAirtimeAdvertZeroHop, "ADVERT (zero-hop)", "zero_hop"}, + {"advert transport direct", PayloadADVERT, intPtr(RouteTransportDirect), relayAirtimeAdvertZeroHop, "ADVERT (zero-hop)", "zero_hop"}, + {"advert NULL route", PayloadADVERT, nil, relayAirtimeAdvertUnknown, "ADVERT", "legacy"}, + {"advert route 4", PayloadADVERT, intPtr(4), relayAirtimeAdvertUnknown, "ADVERT", "legacy"}, + {"advert route 99", PayloadADVERT, intPtr(99), relayAirtimeAdvertUnknown, "ADVERT", "legacy"}, + {"advert route -1", PayloadADVERT, intPtr(-1), relayAirtimeAdvertUnknown, "ADVERT", "legacy"}, + {"advert route -2", PayloadADVERT, intPtr(-2), relayAirtimeAdvertUnknown, "ADVERT", "legacy"}, + {"unknown payload 12", 12, intPtr(RouteFlood), relayAirtimeAdvertUnknown, "UNK", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tx := &StoreTx{PayloadType: intPtr(tc.payloadType), RouteType: tc.routeType} + key := relayAirtimeKey(tx) + want := relayAirtimeBucketKey{payloadType: tc.payloadType, advertRoute: tc.wantRoute} + if key != want { + t.Fatalf("relayAirtimeKey = %+v, want %+v", key, want) + } + if got := relayAirtimeBucketName(key); got != tc.wantLabel { + t.Fatalf("relayAirtimeBucketName = %q, want %q", got, tc.wantLabel) + } + got := relayAirtimeRouteClass(key) + if (got == nil) != (tc.wantClass == "") || (got != nil && *got != tc.wantClass) { + t.Fatalf("relayAirtimeRouteClass = %v, want %q", got, tc.wantClass) + } + }) + } +} + +// Every non-ADVERT payload type, including unnamed ones, must map to exactly +// one bucket regardless of route, keeping the pre-split grouping. +func TestRelayAirtimeKey_NonAdvertPayloadsIgnoreRoute(t *testing.T) { + routes := []*int{nil, intPtr(0), intPtr(1), intPtr(2), intPtr(3), intPtr(7), intPtr(-1)} + for pt := 0; pt <= 15; pt++ { + if pt == PayloadADVERT { + continue + } + want := relayAirtimeBucketKey{payloadType: pt} + wantName := payloadTypeNames[pt] + if wantName == "" { + wantName = "UNK" + } + for _, rt := range routes { + key := relayAirtimeKey(&StoreTx{PayloadType: intPtr(pt), RouteType: rt}) + if key != want { + t.Fatalf("payload %d route %v: key = %+v, want %+v", pt, rt, key, want) + } + if got := relayAirtimeBucketName(key); got != wantName { + t.Fatalf("payload %d route %v: name = %q, want %q", pt, rt, got, wantName) + } + if got := relayAirtimeRouteClass(key); got != nil { + t.Fatalf("payload %d route %v: route_class = %q, want nil", pt, rt, *got) + } + } + } +} + +type relayAirtimeSplitRow struct { + PayloadType string `json:"payload_type"` + Type int `json:"type"` + Count int `json:"count"` + CountPct float64 `json:"count_pct"` + Score int64 `json:"score"` + AirtimePct float64 `json:"airtime_pct"` + RouteClass *string `json:"route_class"` +} + +type relayAirtimeSplitResponse struct { + Rows []relayAirtimeSplitRow `json:"rows"` + TotalCount int `json:"total_count"` + TotalScore int64 `json:"total_score"` +} + +type relayAirtimeSplitFixture struct { + payloadType int + route *int + bytes int + relays int + hash string +} + +// relayAirtimeSplitStore builds a store whose transmissions each have the +// fixture's route type and number of distinct relays. +func relayAirtimeSplitStore(fixtures []relayAirtimeSplitFixture) *PacketStore { + packets := make([]*StoreTx, 0, len(fixtures)) + for i, f := range fixtures { + tx := makeRelayAirtimeTx(i+1, f.payloadType, f.bytes, f.relays, f.hash) + tx.RouteType = f.route + packets = append(packets, tx) + } + store := newRelayAirtimeShareTestStore(packets) + for i, f := range fixtures { + if f.relays == 0 { + continue + } + pks := make([]string, f.relays) + for r := range pks { + pks[r] = "relay-" + zeroPad(r, 3) + } + store.addToResolvedPubkeyIndex(packets[i].ID, pks) + } + return store +} + +func relayAirtimeSplitScore(bytes, relays int) int64 { + return int64(lora.TimeOnAir(bytes, defaultLoRaPreset())) * int64(relays) +} + +// mixedAdvertFixtures holds all three ADVERT buckets, ACKs on both route +// classes, a GRP_TXT, an unnamed payload type, a zero-score ADVERT and a +// re-observed hash that must only be counted once. +func mixedAdvertFixtures() []relayAirtimeSplitFixture { + return []relayAirtimeSplitFixture{ + {PayloadADVERT, intPtr(0), 120, 3, "adv-tf"}, + {PayloadADVERT, intPtr(1), 110, 4, "adv-f"}, + {PayloadADVERT, intPtr(1), 110, 4, "adv-f"}, // re-observation: same hash + {PayloadADVERT, intPtr(2), 100, 0, "adv-d"}, // zero-hop, never relayed + {PayloadADVERT, intPtr(3), 100, 1, "adv-td"}, + {PayloadADVERT, nil, 90, 2, "adv-null"}, + {PayloadADVERT, intPtr(7), 90, 1, "adv-unk"}, + {PayloadACK, intPtr(0), 10, 2, "ack-tf"}, + {PayloadACK, intPtr(2), 10, 1, "ack-d"}, + {PayloadGRP_TXT, intPtr(1), 40, 2, "grp"}, + {13, intPtr(1), 30, 1, "pt13"}, + } +} + +func TestRelayAirtimeShare_AdvertSplitTotalsAndDedup(t *testing.T) { + store := relayAirtimeSplitStore(mixedAdvertFixtures()) + result := store.computeRelayAirtimeShare(TimeWindow{}) + rows, ok := result["rows"].([]map[string]interface{}) + if !ok { + t.Fatalf("rows has type %T", result["rows"]) + } + + type want struct { + typ int + count int + score int64 + } + wants := map[string]want{ + "ADVERT (flood)": {PayloadADVERT, 2, relayAirtimeSplitScore(120, 3) + relayAirtimeSplitScore(110, 4)}, + "ADVERT (zero-hop)": {PayloadADVERT, 2, relayAirtimeSplitScore(100, 1)}, + "ADVERT": {PayloadADVERT, 2, relayAirtimeSplitScore(90, 2) + relayAirtimeSplitScore(90, 1)}, + "ACK": {PayloadACK, 2, relayAirtimeSplitScore(10, 2) + relayAirtimeSplitScore(10, 1)}, + "GRP_TXT": {PayloadGRP_TXT, 1, relayAirtimeSplitScore(40, 2)}, + "UNK": {13, 1, relayAirtimeSplitScore(30, 1)}, + } + var wantTotalCount int + var wantTotalScore int64 + for _, w := range wants { + wantTotalCount += w.count + wantTotalScore += w.score + } + if wantTotalCount != 10 { + t.Fatalf("fixture sanity: expected 10 distinct hashes, got %d", wantTotalCount) + } + + if got := result["total_count"].(int); got != wantTotalCount { + t.Errorf("total_count = %d, want %d (re-observed hash must count once)", got, wantTotalCount) + } + if got := result["total_score"].(int64); got != wantTotalScore { + t.Errorf("total_score = %d, want %d", got, wantTotalScore) + } + if len(rows) != len(wants) { + t.Fatalf("got %d rows, want %d: %+v", len(rows), len(wants), rows) + } + + seen := make(map[string]bool, len(rows)) + var sumCount int + var sumScore int64 + var sumCountPct, sumAirtimePct float64 + for _, r := range rows { + label := r["payload_type"].(string) + if seen[label] { + t.Fatalf("duplicate payload_type label %q", label) + } + seen[label] = true + w, ok := wants[label] + if !ok { + t.Fatalf("unexpected row %q", label) + } + count, score := r["count"].(int), r["score"].(int64) + countPct, airtimePct := r["count_pct"].(float64), r["airtime_pct"].(float64) + if r["type"].(int) != w.typ || count != w.count || score != w.score { + t.Errorf("%s: type=%v count=%d score=%d, want type=%d count=%d score=%d", + label, r["type"], count, score, w.typ, w.count, w.score) + } + if want := float64(w.count) / float64(wantTotalCount) * 100; math.Abs(countPct-want) > 1e-9 { + t.Errorf("%s: count_pct = %v, want %v", label, countPct, want) + } + if want := float64(w.score) / float64(wantTotalScore) * 100; math.Abs(airtimePct-want) > 1e-9 { + t.Errorf("%s: airtime_pct = %v, want %v", label, airtimePct, want) + } + sumCount += count + sumScore += score + sumCountPct += countPct + sumAirtimePct += airtimePct + } + if sumCount != wantTotalCount || sumScore != wantTotalScore { + t.Errorf("row sums count=%d score=%d do not match totals %d/%d", sumCount, sumScore, wantTotalCount, wantTotalScore) + } + if math.Abs(sumCountPct-100) > 1e-9 || math.Abs(sumAirtimePct-100) > 1e-9 { + t.Errorf("percentages sum to count=%v airtime=%v, want 100", sumCountPct, sumAirtimePct) + } + + // Rows are sorted airtime desc, count desc, label asc, so repeated + // computations over the same data must return identical ordering. + firstOrder := relayAirtimeRowLabels(rows) + for i := 0; i < 20; i++ { + again := store.computeRelayAirtimeShare(TimeWindow{})["rows"].([]map[string]interface{}) + if got := relayAirtimeRowLabels(again); !reflect.DeepEqual(got, firstOrder) { + t.Fatalf("row order not deterministic: %v vs %v", got, firstOrder) + } + } + if !sort.SliceIsSorted(rows, func(i, j int) bool { + return rows[i]["airtime_pct"].(float64) > rows[j]["airtime_pct"].(float64) + }) { + t.Errorf("rows not sorted by airtime_pct desc: %v", firstOrder) + } +} + +func relayAirtimeRowLabels(rows []map[string]interface{}) []string { + labels := make([]string, len(rows)) + for i, r := range rows { + labels[i] = r["payload_type"].(string) + } + return labels +} + +func TestRelayAirtimeShare_AdvertSplitSingleClassAndEmpty(t *testing.T) { + cases := []struct { + name string + fixtures []relayAirtimeSplitFixture + want []string + }{ + {"empty", nil, []string{}}, + {"flood only", []relayAirtimeSplitFixture{ + {PayloadADVERT, intPtr(0), 100, 1, "a"}, + {PayloadADVERT, intPtr(1), 100, 1, "b"}, + }, []string{"ADVERT (flood)"}}, + {"zero-hop only", []relayAirtimeSplitFixture{ + {PayloadADVERT, intPtr(2), 100, 1, "a"}, + {PayloadADVERT, intPtr(3), 100, 1, "b"}, + }, []string{"ADVERT (zero-hop)"}}, + {"legacy only", []relayAirtimeSplitFixture{ + {PayloadADVERT, nil, 100, 1, "a"}, + {PayloadADVERT, intPtr(42), 100, 1, "b"}, + }, []string{"ADVERT"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := relayAirtimeSplitStore(tc.fixtures).computeRelayAirtimeShare(TimeWindow{}) + rows := result["rows"].([]map[string]interface{}) + if got := relayAirtimeRowLabels(rows); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("labels = %v, want %v", got, tc.want) + } + if result["total_count"].(int) != len(tc.fixtures) { + t.Errorf("total_count = %v, want %d", result["total_count"], len(tc.fixtures)) + } + if len(rows) == 1 { + if rows[0]["count_pct"].(float64) != 100 || rows[0]["airtime_pct"].(float64) != 100 { + t.Errorf("single bucket should hold 100%%: %+v", rows[0]) + } + } + }) + } +} + +// The serialized API response is the contract the frontend and external +// clients see: the three ADVERT rows share numeric type 4, several unnamed +// payload types share the "UNK" label, and (type, route_class) identifies +// every row. Existing fields are unchanged; route_class is always present. +func TestRelayAirtimeShareAPI_AdvertRowsShareNumericType(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + srv := NewServer(db, &Config{Port: 3000}, NewHub()) + fixtures := append(mixedAdvertFixtures(), relayAirtimeSplitFixture{14, intPtr(1), 30, 1, "pt14"}) + srv.store = relayAirtimeSplitStore(fixtures) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/analytics/relay-airtime-share", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + + var raw struct { + Rows []map[string]json.RawMessage `json:"rows"` + } + if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode rows: %v", err) + } + wantFields := []string{"airtime_pct", "count", "count_pct", "payload_type", "route_class", "score", "type"} + for _, r := range raw.Rows { + fields := make([]string, 0, len(r)) + for k := range r { + fields = append(fields, k) + } + sort.Strings(fields) + if !reflect.DeepEqual(fields, wantFields) { + t.Fatalf("row fields = %v, want %v", fields, wantFields) + } + } + + // route_class is the machine-readable ADVERT identity; it is JSON null on + // every other row, never absent. + wantClass := map[string]string{ + "ADVERT (flood)": `"flood"`, + "ADVERT (zero-hop)": `"zero_hop"`, + "ADVERT": `"legacy"`, + "ACK": `null`, + "GRP_TXT": `null`, + "UNK": `null`, + } + for _, r := range raw.Rows { + var label string + if err := json.Unmarshal(r["payload_type"], &label); err != nil { + t.Fatalf("decode payload_type: %v", err) + } + if got := string(r["route_class"]); got != wantClass[label] { + t.Errorf("%s: route_class = %s, want %s", label, got, wantClass[label]) + } + } + + var resp relayAirtimeSplitResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + advertLabels := []string{} + unknownTypes := []int{} + identities := make(map[string]bool, len(resp.Rows)) + for _, r := range resp.Rows { + class := "" + if r.RouteClass != nil { + class = *r.RouteClass + } + id := fmt.Sprintf("%d/%s", r.Type, class) + if identities[id] { + t.Fatalf("(type, route_class) %s repeated; it must identify each row", id) + } + identities[id] = true + if r.Type == PayloadADVERT { + advertLabels = append(advertLabels, r.PayloadType) + } + if r.PayloadType == "UNK" { + unknownTypes = append(unknownTypes, r.Type) + } + } + sort.Strings(advertLabels) + if want := []string{"ADVERT", "ADVERT (flood)", "ADVERT (zero-hop)"}; !reflect.DeepEqual(advertLabels, want) { + t.Fatalf("rows with type %d = %v, want %v", PayloadADVERT, advertLabels, want) + } + sort.Ints(unknownTypes) + if !reflect.DeepEqual(unknownTypes, []int{13, 14}) { + t.Fatalf("UNK rows have types %v, want [13 14]", unknownTypes) + } + if resp.TotalCount != 11 { + t.Errorf("total_count = %d, want 11", resp.TotalCount) + } +} + +// Payload Type Mix (RF analytics) must keep a single combined ADVERT entry; +// the route split is specific to Relay Airtime Share. +func TestPayloadTypeMix_AdvertStaysCombined(t *testing.T) { + store := relayAirtimeSplitStore(mixedAdvertFixtures()) + result := store.computeAnalyticsRF("", "", TimeWindow{}) + encoded, err := json.Marshal(result["payloadTypes"]) + if err != nil { + t.Fatalf("marshal payloadTypes: %v", err) + } + var entries []struct { + Type int `json:"type"` + Name string `json:"name"` + Count int `json:"count"` + } + if err := json.Unmarshal(encoded, &entries); err != nil { + t.Fatalf("decode payloadTypes: %v", err) + } + var adverts []string + for _, e := range entries { + if e.Type == PayloadADVERT { + adverts = append(adverts, e.Name) + if e.Name != "ADVERT" || e.Count != 6 { + t.Errorf("ADVERT entry = %+v, want name ADVERT count 6", e) + } + } + } + if len(adverts) != 1 { + t.Fatalf("Payload Type Mix has %d ADVERT entries %v, want exactly 1", len(adverts), adverts) + } +} + +// Unnamed payload types share the "UNK" label, so equal-airtime UNK rows +// need the numeric type as the final tiebreak to keep a stable order. +func TestRelayAirtimeShare_UnknownPayloadTieOrderIsStable(t *testing.T) { + store := relayAirtimeSplitStore([]relayAirtimeSplitFixture{ + {14, intPtr(1), 30, 1, "pt14"}, + {12, intPtr(1), 30, 1, "pt12"}, + {13, intPtr(1), 30, 1, "pt13"}, + }) + for i := 0; i < 50; i++ { + rows := store.computeRelayAirtimeShare(TimeWindow{})["rows"].([]map[string]interface{}) + got := make([]int, len(rows)) + for j, r := range rows { + got[j] = r["type"].(int) + } + if !reflect.DeepEqual(got, []int{12, 13, 14}) { + t.Fatalf("run %d: UNK row types = %v, want [12 13 14]", i, got) + } + } +} + +// A contact re-shared as a zero-hop advert (shareContactZeroHop) has the same +// content hash as the original flood advert, so both land on one transmission +// whose route_type is that of the first observation the ingestor inserted +// (pinned in cmd/ingestor TestInsertTransmission_AdvertRouteIsFirstIngested). +// This test models the stored result of that contract: a single transmission +// with the first-inserted route_type whose relays come from the flood +// observations. Relay Airtime Share classifies by the stored route alone, so +// a relayed transmission stored as zero-hop stays on the zero_hop row rather +// than being reclassified from its relay count. +func TestRelayAirtimeShare_MixedRouteHashFollowsStoredRoute(t *testing.T) { + cases := []struct { + name string + stored int + wantLabel string + wantClass string + }{ + {"flood observation inserted first", RouteFlood, "ADVERT (flood)", "flood"}, + {"zero-hop re-share inserted first", RouteTransportDirect, "ADVERT (zero-hop)", "zero_hop"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Relays come from the flood observations' resolved paths; the + // zero-hop observation contributes none. + store := relayAirtimeSplitStore([]relayAirtimeSplitFixture{ + {PayloadADVERT, intPtr(tc.stored), 110, 2, "mixed"}, + }) + rows := store.computeRelayAirtimeShare(TimeWindow{})["rows"].([]map[string]interface{}) + if len(rows) != 1 { + t.Fatalf("got %d rows, want 1: %+v", len(rows), rows) + } + r := rows[0] + class, _ := r["route_class"].(*string) + if r["payload_type"] != tc.wantLabel || class == nil || *class != tc.wantClass { + t.Fatalf("row = %v/%v, want %s/%s", r["payload_type"], class, tc.wantLabel, tc.wantClass) + } + if r["count"].(int) != 1 || r["score"].(int64) != relayAirtimeSplitScore(110, 2) { + t.Errorf("count=%v score=%v, want 1 and the flood relays' score", r["count"], r["score"]) + } + }) + } +} diff --git a/cmd/server/relay_airtime_share_test.go b/cmd/server/relay_airtime_share_test.go index 62ffb2bf3..a1b41d4eb 100644 --- a/cmd/server/relay_airtime_share_test.go +++ b/cmd/server/relay_airtime_share_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "math" "strings" "testing" @@ -156,6 +157,80 @@ func TestRelayAirtimeShare_ADVERTvsACKDivergence(t *testing.T) { } } +// TestRelayAirtimeShare_SplitsAdvertRouteClasses is the regression gate for +// Kpa-clawbot/CoreScope#2041. MeshCore firmware defines route types 0/1 as +// flood and 2/3 as direct; adverts using the direct route are zero-hop adverts. +// Legacy rows without a route type retain the historical ADVERT label, while +// non-advert payloads must remain aggregated solely by payload type. +func TestRelayAirtimeShare_SplitsAdvertRouteClasses(t *testing.T) { + makeTx := func(id, payloadType, routeType int, hash string) *StoreTx { + tx := makeRelayAirtimeTx(id, payloadType, 20, 1, hash) + tx.RouteType = &routeType + return tx + } + + transportFloodAdvert := makeTx(1, PayloadADVERT, 0, "advert-tf") + floodAdvert := makeTx(2, PayloadADVERT, 1, "advert-f") + directAdvert := makeTx(3, PayloadADVERT, 2, "advert-d") + transportDirectAdvert := makeTx(4, PayloadADVERT, 3, "advert-td") + legacyAdvert := makeRelayAirtimeTx(5, PayloadADVERT, 20, 1, "advert-legacy") + unknownRouteAdvert := makeTx(6, PayloadADVERT, 99, "advert-unknown") + floodACK := makeTx(7, PayloadACK, 0, "ack-f") + directACK := makeTx(8, PayloadACK, 2, "ack-d") + + packets := []*StoreTx{ + transportFloodAdvert, floodAdvert, directAdvert, transportDirectAdvert, + legacyAdvert, unknownRouteAdvert, floodACK, directACK, + } + store := newRelayAirtimeShareTestStore(packets) + for _, tx := range packets { + store.addToResolvedPubkeyIndex(tx.ID, []string{"relay"}) + } + + encoded, err := json.Marshal(store.computeRelayAirtimeShare(TimeWindow{})) + if err != nil { + t.Fatalf("marshal result: %v", err) + } + var response struct { + Rows []struct { + PayloadType string `json:"payload_type"` + Type int `json:"type"` + Count int `json:"count"` + } `json:"rows"` + } + if err := json.Unmarshal(encoded, &response); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + counts := make(map[string]int, len(response.Rows)) + types := make(map[string]int, len(response.Rows)) + for _, row := range response.Rows { + counts[row.PayloadType] = row.Count + types[row.PayloadType] = row.Type + } + + wantCounts := map[string]int{ + "ADVERT (flood)": 2, + "ADVERT (zero-hop)": 2, + "ADVERT": 2, + "ACK": 2, + } + if len(counts) != len(wantCounts) { + t.Fatalf("bucket count = %d (%v), want %d (%v)", len(counts), counts, len(wantCounts), wantCounts) + } + for label, want := range wantCounts { + if got := counts[label]; got != want { + t.Errorf("%s count = %d, want %d (all rows: %v)", label, got, want, counts) + } + } + if types["ADVERT (flood)"] != PayloadADVERT || types["ADVERT (zero-hop)"] != PayloadADVERT || types["ADVERT"] != PayloadADVERT { + t.Errorf("advert bucket numeric types = %v, want all %d", types, PayloadADVERT) + } + if types["ACK"] != PayloadACK { + t.Errorf("ACK numeric type = %d, want %d", types["ACK"], PayloadACK) + } +} + // TestRelayAirtimeShare_ToAReplacesByteProxy is the issue #1768 acceptance // gate: airtime_pct must follow true LoRa Time-on-Air, NOT bytes. // diff --git a/public/analytics.js b/public/analytics.js index 7f6bfad8b..c482a8ffc 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -567,6 +567,10 @@ var apct = Number(r.airtime_pct || 0); var score = Number(r.score || 0); var color = palette[i % palette.length]; + // Rows are rendered positionally; (type, route_class) is the stable row + // identity. The three ADVERT rows share type 4, so type alone is not. + var identityAttrs = ' data-payload-type="' + esc(String(Number(r.type))) + '"' + + (typeof r.route_class === 'string' ? ' data-route-class="' + esc(r.route_class) + '"' : ''); var loPct = Math.min(cpct, apct); var hiPct = Math.max(cpct, apct); // Tooltip per row — payload_type, count %, count N, airtime %, raw score, caveat. @@ -582,7 +586,7 @@ 'Count: ' + cnt.toLocaleString() + ' (' + cpct.toFixed(2) + '%)\n' + 'Airtime: ' + apct.toFixed(2) + '% (score ' + scoreStr + ' · airtime × repeaters)\n' + 'Score = LoRa Time-on-Air × distinct repeaters. Within-mesh only.'; - html += '
' + + html += '
' + '
' + esc(name) + '
' + '
' + '
' + @@ -3065,6 +3069,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf window._analyticsChannelTbodyHtml = channelTbodyHtml; window._analyticsChannelTheadHtml = channelTheadHtml; window._analyticsRfNFColumnChart = rfNFColumnChart; + window._analyticsRenderRelayAirtimeDumbbell = renderRelayAirtimeDumbbell; window._analyticsRenderMultiByteCapability = renderMultiByteCapability; window._analyticsRenderMultiByteAdopters = renderMultiByteAdopters; window._analyticsHashStatCardsHtml = hashStatCardsHtml; diff --git a/test-all.sh b/test-all.sh index 0e63d460f..bd1abe219 100755 --- a/test-all.sh +++ b/test-all.sh @@ -45,6 +45,7 @@ node test-analytics-channels-integration.js node test-analytics-foreign-traffic-tab.js node test-analytics-nodes-without-scope.js node test-analytics-table-ids-unique.js +node test-analytics-relay-airtime-dumbbell.js node test-live-vcr-mode-contrast.js node test-observers-headings.js node test-issue-1789-observer-firmware-cols.js diff --git a/test-analytics-relay-airtime-dumbbell.js b/test-analytics-relay-airtime-dumbbell.js new file mode 100644 index 000000000..262af5cce --- /dev/null +++ b/test-analytics-relay-airtime-dumbbell.js @@ -0,0 +1,172 @@ +/** + * Relay Airtime Share dumbbell: one rendered row per API row. + * + * /api/analytics/relay-airtime-share splits ADVERT into "ADVERT (flood)", + * "ADVERT (zero-hop)" and the legacy "ADVERT" bucket, and all three rows + * carry the same numeric `type` (4). The renderer must therefore treat each + * row independently: keying rows by `type` would collapse the split. + * + * Drives the real renderRelayAirtimeDumbbell from public/analytics.js. + */ +'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 makeCtx() { + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '' }), + head: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: () => {}, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + localStorage: (() => { const s = {}; return { getItem: k => s[k] || null, setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(), + location: { hash: '' }, + CustomEvent: class CustomEvent {}, + Map, Set, Promise, URLSearchParams, + addEventListener: () => {}, + dispatchEvent: () => {}, + requestAnimationFrame: () => 0, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + registerPage: () => {}, + RegionFilter: { init: () => {}, onChange: () => {}, regionQueryString: () => '' }, + onWS: () => {}, offWS: () => {}, connectWS: () => {}, + invalidateApiCache: () => {}, initTabBar: () => {}, + IATA_COORDS_GEO: {}, + }; + vm.createContext(ctx); + const load = (file) => { + vm.runInContext(fs.readFileSync(file, 'utf8'), ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + }; + load('public/payload-labels.js'); + load('public/roles.js'); + load('public/app.js'); + try { load('public/analytics.js'); } catch (_) { + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } + return ctx; +} + +// Shape returned by the API for a window containing all three ADVERT buckets. +const splitResponse = { + rows: [ + { payload_type: 'ADVERT (flood)', type: 4, route_class: 'flood', count: 30, count_pct: 30, score: 6e9, airtime_pct: 60 }, + { payload_type: 'ACK', type: 3, route_class: null, count: 50, count_pct: 50, score: 2e9, airtime_pct: 20 }, + { payload_type: 'ADVERT (zero-hop)', type: 4, route_class: 'zero_hop', count: 15, count_pct: 15, score: 1.5e9, airtime_pct: 15 }, + { payload_type: 'ADVERT', type: 4, route_class: 'legacy', count: 5, count_pct: 5, score: 5e8, airtime_pct: 5 }, + ], + total_count: 100, + total_score: 1e10, + preset: { freq_hz: 869.6e6, bw_khz: 62.5, sf: 8, cr: 5, preamble: 16 }, +}; + +// Split rendered HTML into per-row chunks (each starts at a dumbbell-row div). +function rowsOf(html) { + return html.split('
(row.match(/class="dumbbell-label"[^>]*>([^<]*) (row.match(/^ title="([^"]*)"/) || [])[1] || ''; +const airtimeColourOf = (row) => (row.match(/dumbbell-dot-airtime" style="[^"]*background:([^;"]+)/) || [])[1]; +const attrOf = (row, name) => { const m = row.match(new RegExp('^[^>]*\\s' + name + '="([^"]*)"')); return m ? m[1] : null; }; +const identityOf = (row) => attrOf(row, 'data-payload-type') + '/' + attrOf(row, 'data-route-class'); +const airtimeLeftOf = (row) => (row.match(/dumbbell-dot-airtime" style="[^"]*left:([0-9.]+)%/) || [])[1]; + +console.log('\n=== analytics.js: renderRelayAirtimeDumbbell ==='); +const ctx = makeCtx(); +const render = ctx._analyticsRenderRelayAirtimeDumbbell; + +test('renderRelayAirtimeDumbbell is exposed for tests', () => { + assert.strictEqual(typeof render, 'function', 'window._analyticsRenderRelayAirtimeDumbbell must be a function'); +}); + +if (typeof render === 'function') { + test('ADVERT rows sharing numeric type 4 each render as their own row, in API order', () => { + const rows = rowsOf(render(splitResponse)); + assert.deepStrictEqual(rows.map(labelOf), ['ADVERT (flood)', 'ACK', 'ADVERT (zero-hop)', 'ADVERT']); + }); + + test('each row carries its own tooltip, count and airtime position', () => { + const rows = rowsOf(render(splitResponse)); + splitResponse.rows.forEach((r, i) => { + const tip = titleOf(rows[i]); + assert.ok(tip.startsWith(r.payload_type + '\n'), `row ${i} tooltip starts with ${JSON.stringify(tip.slice(0, 30))}`); + assert.ok(tip.includes('Count: ' + r.count.toLocaleString() + ' (' + r.count_pct.toFixed(2) + '%)'), `row ${i} tooltip count`); + assert.ok(tip.includes('Airtime: ' + r.airtime_pct.toFixed(2) + '%'), `row ${i} tooltip airtime`); + assert.strictEqual(airtimeLeftOf(rows[i]), r.airtime_pct.toFixed(3), `row ${i} airtime dot position`); + }); + }); + + test('ADVERT rows sharing numeric type get distinct airtime colours', () => { + const rows = rowsOf(render(splitResponse)); + const colours = rows.filter(r => /ADVERT/.test(labelOf(r))).map(airtimeColourOf); + assert.strictEqual(colours.length, 3); + assert.strictEqual(new Set(colours).size, 3, `colours: ${colours.join(', ')}`); + }); + + test('each row exposes its stable (type, route_class) identity', () => { + const rows = rowsOf(render(splitResponse)); + assert.deepStrictEqual(rows.map(identityOf), ['4/flood', '3/null', '4/zero_hop', '4/legacy']); + assert.strictEqual(new Set(rows.map(identityOf)).size, rows.length, 'row identities must be unique'); + }); + + test('identity comes from route_class, not from the display label', () => { + const relabelled = Object.assign({}, splitResponse, { + rows: splitResponse.rows.map(r => Object.assign({}, r, { payload_type: 'Advert #' + r.count })), + }); + const rows = rowsOf(render(relabelled)); + assert.deepStrictEqual(rows.map(identityOf), ['4/flood', '3/null', '4/zero_hop', '4/legacy']); + }); + + test('responses without route_class (older servers) still render every row', () => { + const legacy = Object.assign({}, splitResponse, { + rows: splitResponse.rows.map(r => { const c = Object.assign({}, r); delete c.route_class; return c; }), + }); + const rows = rowsOf(render(legacy)); + assert.deepStrictEqual(rows.map(labelOf), ['ADVERT (flood)', 'ACK', 'ADVERT (zero-hop)', 'ADVERT']); + assert.deepStrictEqual(rows.map(r => attrOf(r, 'data-route-class')), [null, null, null, null]); + }); + + test('rendered chart introduces no element ids', () => { + const html = render(splitResponse); + assert.ok(!/\sid="/.test(html), 'dumbbell markup must not contain id attributes'); + }); + + test('a single ADVERT class renders one row', () => { + const only = { rows: [{ payload_type: 'ADVERT (zero-hop)', type: 4, count: 3, count_pct: 100, score: 1e9, airtime_pct: 100 }], total_count: 3, total_score: 1e9 }; + assert.deepStrictEqual(rowsOf(render(only)).map(labelOf), ['ADVERT (zero-hop)']); + }); + + test('empty rows render the empty-state message', () => { + const html = render({ rows: [], total_count: 0, total_score: 0 }); + assert.ok(html.includes('No relay-airtime data in this window.')); + assert.strictEqual(rowsOf(html).length, 0); + }); +} + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1);