mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 15:28:06 +00:00
Ranks configured areas by number of distinct pings with a relay hop or hearing station inside them -- "which area is most active" -- reusing the same touched-areas config/matching View Path already draws from (annotatePacketPathTouchedAreas), rather than reimplementing area matching. Omitted entirely on deployments with no areas configured.
519 lines
22 KiB
Go
519 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
// seedPingTrigger inserts a CHAN transmission plus its ping_triggers row --
|
|
// what the ingestor would have written for a real "ping" message.
|
|
func seedPingTrigger(t *testing.T, srv *Server, hash, channelHash, sender, firstSeen string) int64 {
|
|
t.Helper()
|
|
res, err := srv.db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
|
VALUES ('AABBCCDDEE', ?, ?, 1, 5, '{"type":"CHAN","channel":"'||?||'","text":"'||?||': ping","sender":"'||?||'"}', ?)`,
|
|
hash, firstSeen, channelHash, sender, sender, channelHash)
|
|
if err != nil {
|
|
t.Fatalf("insert tx: %v", err)
|
|
}
|
|
txID, _ := res.LastInsertId()
|
|
if _, err := srv.db.conn.Exec(`INSERT INTO ping_triggers (tx_id, hash, channel_hash, sender, first_seen) VALUES (?, ?, ?, ?, ?)`,
|
|
txID, hash, channelHash, sender, firstSeen); err != nil {
|
|
t.Fatalf("insert ping_trigger: %v", err)
|
|
}
|
|
return txID
|
|
}
|
|
|
|
func seedPingObservation(t *testing.T, srv *Server, txID int64, observerID string, snr float64, pathJSON, resolvedPath string, tsUnix int64) {
|
|
t.Helper()
|
|
var observerIdx int64
|
|
if err := srv.db.conn.QueryRow(`SELECT rowid FROM observers WHERE id = ?`, observerID).Scan(&observerIdx); err != nil {
|
|
t.Fatalf("lookup observer %s: %v", observerID, err)
|
|
}
|
|
if _, err := srv.db.conn.Exec(
|
|
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) VALUES (?,?,?,?,?,?,?)`,
|
|
txID, observerIdx, snr, -90.0, pathJSON, resolvedPath, tsUnix,
|
|
); err != nil {
|
|
t.Fatalf("insert observation: %v", err)
|
|
}
|
|
}
|
|
|
|
// setupPingScoresFixture seeds observers with known positions (via nodes
|
|
// row for lat/lon lookups the same way GetPacketPath resolves them) and
|
|
// returns the server (+ router, for handler-level tests) ready for
|
|
// computePingScore/computeAllPingScores.
|
|
func setupPingScoresFixture(t *testing.T) (*Server, *mux.Router) {
|
|
t.Helper()
|
|
srv, router := setupTestServer(t)
|
|
// setupTestDB's shared schema predates the ping-score feature; create
|
|
// ping_triggers locally here rather than touching the schema shared by
|
|
// every other test in the package.
|
|
if _, err := srv.db.conn.Exec(`CREATE TABLE IF NOT EXISTS ping_triggers (
|
|
tx_id INTEGER PRIMARY KEY,
|
|
hash TEXT NOT NULL,
|
|
channel_hash TEXT,
|
|
sender TEXT,
|
|
first_seen TEXT NOT NULL
|
|
)`); err != nil {
|
|
t.Fatalf("create ping_triggers: %v", err)
|
|
}
|
|
obs := []struct {
|
|
id, name, iata string
|
|
}{
|
|
{"pingobsa", "ObsA", "AAA"},
|
|
{"pingobsb", "ObsB", "BBB"},
|
|
{"pingobsc", "ObsC", "CCC"},
|
|
}
|
|
for _, o := range obs {
|
|
if _, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, o.id, o.name, o.iata); err != nil {
|
|
t.Fatalf("insert observer %s: %v", o.id, err)
|
|
}
|
|
}
|
|
// Distinct positions so DistanceFromFirstKm is real and non-trivial.
|
|
positions := []struct {
|
|
pubkey string
|
|
lat, lon float64
|
|
}{
|
|
{"pingobsa", 56.0, 10.0}, // first-hearer landmark for most scenarios
|
|
{"pingobsb", 57.8, 12.6}, // ~230km from A
|
|
{"pingobsc", 56.05, 10.05}, // ~6km from A
|
|
}
|
|
for _, p := range positions {
|
|
if _, err := srv.db.conn.Exec(`INSERT INTO nodes (public_key, name, lat, lon) VALUES (?,?,?,?)`, p.pubkey, p.pubkey, p.lat, p.lon); err != nil {
|
|
t.Fatalf("insert node position for %s: %v", p.pubkey, err)
|
|
}
|
|
}
|
|
return srv, router
|
|
}
|
|
|
|
// TestComputePingScore_Basic covers the core per-ping derivation: deepest
|
|
// (most hops) vs farthest (most distanceFromFirstKm) can be DIFFERENT
|
|
// branches, spread seconds is the max across all branches, and the relay
|
|
// pubkeys feeding the leaderboard are collected from every branch's hop
|
|
// chain, not just the deepest/farthest one.
|
|
func TestComputePingScore_Basic(t *testing.T) {
|
|
srv, _ := setupPingScoresFixture(t)
|
|
txID := seedPingTrigger(t, srv, "scorebasic00001", "#test", "Alice", "2026-01-15T10:00:00Z")
|
|
|
|
// obs A: first hearer, 0 hops.
|
|
seedPingObservation(t, srv, txID, "pingobsa", 9.0, `[]`, `[]`, 1736935200)
|
|
// obs B: farther away (230km) but fewer hops (1) than C.
|
|
seedPingObservation(t, srv, txID, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, 1736935210)
|
|
// obs C: closer (6km) but MORE hops (2) -- the deepest branch.
|
|
seedPingObservation(t, srv, txID, "pingobsc", 4.0, `["aa","bb"]`, `["pkrelay1","pkrelay2"]`, 1736935260)
|
|
|
|
score := srv.computePingScore(pingTriggerRow{txID: txID, hash: "scorebasic00001", channelHash: "#test", sender: "Alice", firstSeen: "2026-01-15T10:00:00Z"})
|
|
if score == nil {
|
|
t.Fatal("computePingScore returned nil")
|
|
}
|
|
if score.StationCount != 3 {
|
|
t.Errorf("StationCount = %d, want 3", score.StationCount)
|
|
}
|
|
if score.DeepestHops != 2 {
|
|
t.Errorf("DeepestHops = %d, want 2 (obs C's branch)", score.DeepestHops)
|
|
}
|
|
if score.FarthestKm == nil {
|
|
t.Fatal("FarthestKm is nil, want a value")
|
|
}
|
|
if *score.FarthestKm < 200 || *score.FarthestKm > 260 {
|
|
t.Errorf("FarthestKm = %v, want ~230 (obs B's branch)", *score.FarthestKm)
|
|
}
|
|
if score.FarthestPubkey != "pingobsb" {
|
|
t.Errorf("FarthestPubkey = %q, want pingobsb -- farthest and deepest must be independently attributed", score.FarthestPubkey)
|
|
}
|
|
if score.DeepestPubkey != "pingobsc" {
|
|
t.Errorf("DeepestPubkey = %q, want pingobsc", score.DeepestPubkey)
|
|
}
|
|
if score.SpreadSeconds == nil || *score.SpreadSeconds < 59 || *score.SpreadSeconds > 61 {
|
|
t.Errorf("SpreadSeconds = %v, want ~60 (obs C arrived 60s after obs A)", score.SpreadSeconds)
|
|
}
|
|
relaySet := map[string]bool{}
|
|
for _, pk := range score.relayPubkeys {
|
|
relaySet[pk] = true
|
|
}
|
|
if !relaySet["pkrelay1"] || !relaySet["pkrelay2"] {
|
|
t.Errorf("relayPubkeys = %v, want both pkrelay1 and pkrelay2 collected across all branches", score.relayPubkeys)
|
|
}
|
|
if score.firstPubkey != "pingobsa" {
|
|
t.Errorf("firstPubkey = %q, want pingobsa (earliest-arriving observation)", score.firstPubkey)
|
|
}
|
|
}
|
|
|
|
// TestComputeAllPingScores_RecordSelection seeds several pings with
|
|
// deliberately distinct winning stats and asserts each record picks the
|
|
// right one -- including the fastest-full-spread record correctly
|
|
// excluding a lone-station ping (StationCount<2) that would otherwise
|
|
// trivially "win" with a 0-second spread.
|
|
func TestComputeAllPingScores_RecordSelection(t *testing.T) {
|
|
srv, _ := setupPingScoresFixture(t)
|
|
|
|
// Ping FAR: farthest (230km) and deepest (2 hops).
|
|
txFar := seedPingTrigger(t, srv, "recordfar000001", "#test", "Alice", "2026-01-15T10:00:00Z")
|
|
seedPingObservation(t, srv, txFar, "pingobsa", 9.0, `[]`, `[]`, 1736935200)
|
|
seedPingObservation(t, srv, txFar, "pingobsb", 6.0, `["aa","bb"]`, `["pkrelay1","pkrelay2"]`, 1736935210)
|
|
|
|
// Ping WIDE: 3 stations (more than any other ping here) -- wins widest spread.
|
|
txWide := seedPingTrigger(t, srv, "recordwide00001", "#test", "Bob", "2026-01-15T11:00:00Z")
|
|
seedPingObservation(t, srv, txWide, "pingobsa", 9.0, `[]`, `[]`, 1736938800)
|
|
seedPingObservation(t, srv, txWide, "pingobsb", 6.0, `["aa"]`, `["pkrelay3"]`, 1736938850)
|
|
seedPingObservation(t, srv, txWide, "pingobsc", 4.0, `["aa"]`, `["pkrelay3"]`, 1736938900)
|
|
|
|
// Ping FAST: 2 stations, arrives within 1 second -- wins fastest spread.
|
|
txFast := seedPingTrigger(t, srv, "recordfast00001", "#test", "Carol", "2026-01-15T12:00:00Z")
|
|
seedPingObservation(t, srv, txFast, "pingobsa", 9.0, `[]`, `[]`, 1736942400)
|
|
seedPingObservation(t, srv, txFast, "pingobsc", 6.0, `["aa"]`, `["pkrelay4"]`, 1736942401)
|
|
|
|
// Ping LONE: single station, 0-second "spread" -- must NOT win fastest
|
|
// spread despite the smallest possible SpreadSeconds, since there's
|
|
// nothing to spread to.
|
|
txLone := seedPingTrigger(t, srv, "recordlone00001", "#test", "Dave", "2026-01-15T13:00:00Z")
|
|
seedPingObservation(t, srv, txLone, "pingobsa", 9.0, `[]`, `[]`, 1736946000)
|
|
|
|
snap := srv.computeAllPingScores()
|
|
if snap == nil {
|
|
t.Fatal("computeAllPingScores returned nil")
|
|
}
|
|
if snap.TotalPings != 4 {
|
|
t.Errorf("TotalPings = %d, want 4", snap.TotalPings)
|
|
}
|
|
if snap.FarthestPing == nil || snap.FarthestPing.Hash != "recordfar000001" {
|
|
t.Errorf("FarthestPing = %+v, want recordfar000001", snap.FarthestPing)
|
|
}
|
|
if snap.MostHopsPing == nil || snap.MostHopsPing.Hash != "recordfar000001" {
|
|
t.Errorf("MostHopsPing = %+v, want recordfar000001", snap.MostHopsPing)
|
|
}
|
|
if snap.WidestSpreadPing == nil || snap.WidestSpreadPing.Hash != "recordwide00001" {
|
|
t.Errorf("WidestSpreadPing = %+v, want recordwide00001 (3 stations)", snap.WidestSpreadPing)
|
|
}
|
|
if snap.FastestSpreadPing == nil || snap.FastestSpreadPing.Hash != "recordfast00001" {
|
|
t.Errorf("FastestSpreadPing = %+v, want recordfast00001, NOT the lone-station ping", snap.FastestSpreadPing)
|
|
}
|
|
}
|
|
|
|
// TestComputeAllPingScores_Leaderboards verifies relay credit is deduped
|
|
// per-ping (a relay appearing in several branches of the SAME ping earns
|
|
// only one point for it) while still tallying correctly ACROSS pings, and
|
|
// that the observer leaderboard credits whoever was first each time.
|
|
func TestComputeAllPingScores_Leaderboards(t *testing.T) {
|
|
srv, _ := setupPingScoresFixture(t)
|
|
|
|
// Ping 1: pkrelay1 appears in BOTH branches -- must count once for this ping.
|
|
// Uses a recent (now-relative) timestamp since SenderLeaderboard is a
|
|
// rolling 30-day window -- see TestComputeAllPingScores_SenderLeaderboard30DayCutoff.
|
|
ts1 := time.Now().Add(-1 * time.Hour)
|
|
tx1 := seedPingTrigger(t, srv, "lbtest0000001", "#test", "Alice", ts1.Format(time.RFC3339))
|
|
seedPingObservation(t, srv, tx1, "pingobsa", 9.0, `[]`, `[]`, ts1.Unix())
|
|
seedPingObservation(t, srv, tx1, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, ts1.Unix()+10)
|
|
seedPingObservation(t, srv, tx1, "pingobsc", 4.0, `["aa"]`, `["pkrelay1"]`, ts1.Unix()+20)
|
|
|
|
// Ping 2: pkrelay1 appears again -- second distinct ping, should now be 2.
|
|
ts2 := time.Now().Add(-30 * time.Minute)
|
|
tx2 := seedPingTrigger(t, srv, "lbtest0000002", "#test", "Bob", ts2.Format(time.RFC3339))
|
|
seedPingObservation(t, srv, tx2, "pingobsa", 9.0, `[]`, `[]`, ts2.Unix())
|
|
seedPingObservation(t, srv, tx2, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, ts2.Unix()+10)
|
|
|
|
snap := srv.computeAllPingScores()
|
|
if snap == nil {
|
|
t.Fatal("computeAllPingScores returned nil")
|
|
}
|
|
|
|
var relay1Count int
|
|
found := false
|
|
for _, e := range snap.RelayLeaderboard {
|
|
if e.Pubkey == "pkrelay1" {
|
|
relay1Count = e.Count
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("pkrelay1 not found in RelayLeaderboard: %+v", snap.RelayLeaderboard)
|
|
}
|
|
if relay1Count != 2 {
|
|
t.Errorf("pkrelay1 count = %d, want 2 (one per DISTINCT ping, not 3 for appearing in 3 branches total)", relay1Count)
|
|
}
|
|
|
|
var obsACount int
|
|
for _, e := range snap.ObserverLeaderboard {
|
|
if e.Pubkey == "pingobsa" {
|
|
obsACount = e.Count
|
|
}
|
|
}
|
|
if obsACount != 2 {
|
|
t.Errorf("pingobsa first-hearer count = %d, want 2 (first on both pings)", obsACount)
|
|
}
|
|
|
|
// Alice sent ping 1, Bob sent ping 2 -- one each, and neither entry
|
|
// should carry a pubkey since senders are keyed by display name only.
|
|
senderCounts := map[string]PingLeaderboardEntry{}
|
|
for _, e := range snap.SenderLeaderboard {
|
|
senderCounts[e.Name] = e
|
|
}
|
|
if senderCounts["Alice"].Count != 1 {
|
|
t.Errorf("Alice sender count = %+v, want Count=1", senderCounts["Alice"])
|
|
}
|
|
if senderCounts["Bob"].Count != 1 {
|
|
t.Errorf("Bob sender count = %+v, want Count=1", senderCounts["Bob"])
|
|
}
|
|
if senderCounts["Alice"].Pubkey != "" {
|
|
t.Errorf("Alice entry has Pubkey=%q, want empty -- senders have no resolved pubkey", senderCounts["Alice"].Pubkey)
|
|
}
|
|
}
|
|
|
|
// TestComputeAllPingScores_SenderLeaderboard30DayCutoff confirms
|
|
// SenderLeaderboard only counts pings from the last 30 days, while the
|
|
// other leaderboards and the 5 all-time records stay unaffected by a ping's
|
|
// age.
|
|
func TestComputeAllPingScores_SenderLeaderboard30DayCutoff(t *testing.T) {
|
|
srv, _ := setupPingScoresFixture(t)
|
|
|
|
// Old: 40 days ago -- outside the 30-day window, must NOT appear in
|
|
// SenderLeaderboard, but must still count in RelayLeaderboard.
|
|
oldTs := time.Now().AddDate(0, 0, -40)
|
|
txOld := seedPingTrigger(t, srv, "lbold0000001", "#test", "OldSender", oldTs.Format(time.RFC3339))
|
|
seedPingObservation(t, srv, txOld, "pingobsa", 9.0, `[]`, `[]`, oldTs.Unix())
|
|
seedPingObservation(t, srv, txOld, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, oldTs.Unix()+10)
|
|
|
|
// Recent: 5 days ago -- inside the window, must appear in SenderLeaderboard.
|
|
recentTs := time.Now().AddDate(0, 0, -5)
|
|
txRecent := seedPingTrigger(t, srv, "lbnew0000001", "#test", "RecentSender", recentTs.Format(time.RFC3339))
|
|
seedPingObservation(t, srv, txRecent, "pingobsa", 9.0, `[]`, `[]`, recentTs.Unix())
|
|
seedPingObservation(t, srv, txRecent, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, recentTs.Unix()+10)
|
|
|
|
snap := srv.computeAllPingScores()
|
|
if snap == nil {
|
|
t.Fatal("computeAllPingScores returned nil")
|
|
}
|
|
|
|
for _, e := range snap.SenderLeaderboard {
|
|
if e.Name == "OldSender" {
|
|
t.Errorf("OldSender (40 days old) must be excluded from SenderLeaderboard, got entry: %+v", e)
|
|
}
|
|
}
|
|
var foundRecent bool
|
|
for _, e := range snap.SenderLeaderboard {
|
|
if e.Name == "RecentSender" {
|
|
foundRecent = true
|
|
if e.Count != 1 {
|
|
t.Errorf("RecentSender count = %d, want 1", e.Count)
|
|
}
|
|
}
|
|
}
|
|
if !foundRecent {
|
|
t.Errorf("RecentSender (5 days old) missing from SenderLeaderboard: %+v", snap.SenderLeaderboard)
|
|
}
|
|
|
|
// The 40-day-old ping's relay must still count -- only the sender
|
|
// leaderboard is time-windowed, not the underlying data or other boards.
|
|
var relay1Count int
|
|
for _, e := range snap.RelayLeaderboard {
|
|
if e.Pubkey == "pkrelay1" {
|
|
relay1Count = e.Count
|
|
}
|
|
}
|
|
if relay1Count != 2 {
|
|
t.Errorf("pkrelay1 relay count = %d, want 2 (both old and recent pings count toward RelayLeaderboard)", relay1Count)
|
|
}
|
|
}
|
|
|
|
// TestComputeAllPingScores_ThisWeek confirms ThisWeek is an independently
|
|
// windowed record set: an older, BIGGER ping still wins the all-time slot,
|
|
// while a smaller but recent ping wins the equivalent ThisWeek slot since
|
|
// the older one falls outside the 7-day window.
|
|
func TestComputeAllPingScores_ThisWeek(t *testing.T) {
|
|
srv, _ := setupPingScoresFixture(t)
|
|
|
|
// Old: 10 days ago, farther (pingobsb, ~230km from pingobsa) -- must
|
|
// win the all-time FarthestPing but be excluded from ThisWeek.
|
|
oldTs := time.Now().AddDate(0, 0, -10)
|
|
txOld := seedPingTrigger(t, srv, "weekold0000001", "#test", "Alice", oldTs.Format(time.RFC3339))
|
|
seedPingObservation(t, srv, txOld, "pingobsa", 9.0, `[]`, `[]`, oldTs.Unix())
|
|
seedPingObservation(t, srv, txOld, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, oldTs.Unix()+10)
|
|
|
|
// Recent: 2 days ago, closer (pingobsc, ~6km from pingobsa) -- the
|
|
// only ping inside the 7-day window, so it must win ThisWeek's
|
|
// FarthestPing even though it's smaller than the old one.
|
|
recentTs := time.Now().AddDate(0, 0, -2)
|
|
txRecent := seedPingTrigger(t, srv, "weeknew0000001", "#test", "Bob", recentTs.Format(time.RFC3339))
|
|
seedPingObservation(t, srv, txRecent, "pingobsa", 9.0, `[]`, `[]`, recentTs.Unix())
|
|
seedPingObservation(t, srv, txRecent, "pingobsc", 6.0, `["aa"]`, `["pkrelay2"]`, recentTs.Unix()+10)
|
|
|
|
snap := srv.computeAllPingScores()
|
|
if snap == nil {
|
|
t.Fatal("computeAllPingScores returned nil")
|
|
}
|
|
if snap.FarthestPing == nil || snap.FarthestPing.Hash != "weekold0000001" {
|
|
t.Errorf("all-time FarthestPing = %+v, want weekold0000001 (the farther, older ping)", snap.FarthestPing)
|
|
}
|
|
if snap.ThisWeek == nil {
|
|
t.Fatal("ThisWeek is nil, want a populated record set from the recent ping")
|
|
}
|
|
if snap.ThisWeek.FarthestPing == nil || snap.ThisWeek.FarthestPing.Hash != "weeknew0000001" {
|
|
t.Errorf("ThisWeek.FarthestPing = %+v, want weeknew0000001 -- the old ping is outside the 7-day window", snap.ThisWeek.FarthestPing)
|
|
}
|
|
}
|
|
|
|
// TestComputeAllPingScores_ThisWeekNilWhenNoRecentPings confirms ThisWeek
|
|
// stays nil (not a zero-valued struct) when every ping is outside the
|
|
// 7-day window, matching the frontend's null-safe "no record yet"
|
|
// rendering rather than an empty-but-present object.
|
|
func TestComputeAllPingScores_ThisWeekNilWhenNoRecentPings(t *testing.T) {
|
|
srv, _ := setupPingScoresFixture(t)
|
|
oldTs := time.Now().AddDate(0, 0, -30)
|
|
txOld := seedPingTrigger(t, srv, "weeknone000001", "#test", "Alice", oldTs.Format(time.RFC3339))
|
|
seedPingObservation(t, srv, txOld, "pingobsa", 9.0, `[]`, `[]`, oldTs.Unix())
|
|
seedPingObservation(t, srv, txOld, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, oldTs.Unix()+10)
|
|
|
|
snap := srv.computeAllPingScores()
|
|
if snap == nil {
|
|
t.Fatal("computeAllPingScores returned nil")
|
|
}
|
|
if snap.ThisWeek != nil {
|
|
t.Errorf("ThisWeek = %+v, want nil when no ping in the last 7 days resolved to a usable score", snap.ThisWeek)
|
|
}
|
|
}
|
|
|
|
// TestComputeAllPingScores_AreaLeaderboard confirms AreaLeaderboard tallies
|
|
// DISTINCT pings per configured area (a ping with two hearing stations in
|
|
// the SAME area still counts once for it, mirroring RelayLeaderboard's
|
|
// per-ping dedup), correctly attributes a ping that touches two areas to
|
|
// BOTH, and reuses the exact same area-matching config View Path's
|
|
// touchedAreas draws from.
|
|
func TestComputeAllPingScores_AreaLeaderboard(t *testing.T) {
|
|
srv, _ := setupPingScoresFixture(t)
|
|
|
|
f := func(v float64) *float64 { return &v }
|
|
srv.cfg.Areas = map[string]AreaEntry{
|
|
"AREAA": {Label: "Area A", LatMin: f(55.9), LatMax: f(56.2), LonMin: f(9.9), LonMax: f(10.2)},
|
|
"AREAB": {Label: "Area B", LatMin: f(57.7), LatMax: f(57.9), LonMin: f(12.5), LonMax: f(12.7)},
|
|
}
|
|
|
|
// Ping 1: pingobsa AND pingobsc both fall in Area A (must count once
|
|
// for Area A, not twice), pingobsb falls in Area B -- touches both.
|
|
tx1 := seedPingTrigger(t, srv, "arealb0000001", "#test", "Alice", "2026-01-15T10:00:00Z")
|
|
seedPingObservation(t, srv, tx1, "pingobsa", 9.0, `[]`, `[]`, 1736935200)
|
|
seedPingObservation(t, srv, tx1, "pingobsc", 6.0, `["aa"]`, `["pkrelay1"]`, 1736935210)
|
|
seedPingObservation(t, srv, tx1, "pingobsb", 4.0, `["aa","bb"]`, `["pkrelay1","pkrelay2"]`, 1736935260)
|
|
|
|
// Ping 2: only pingobsa (Area A) -- Area B must NOT get credit for this one.
|
|
tx2 := seedPingTrigger(t, srv, "arealb0000002", "#test", "Bob", "2026-01-15T11:00:00Z")
|
|
seedPingObservation(t, srv, tx2, "pingobsa", 9.0, `[]`, `[]`, 1736938800)
|
|
|
|
snap := srv.computeAllPingScores()
|
|
if snap == nil {
|
|
t.Fatal("computeAllPingScores returned nil")
|
|
}
|
|
counts := map[string]int{}
|
|
for _, e := range snap.AreaLeaderboard {
|
|
counts[e.Name] = e.Count
|
|
}
|
|
if counts["Area A"] != 2 {
|
|
t.Errorf("Area A count = %d, want 2 (both pings touch it, dedup within ping 1's two same-area stations)", counts["Area A"])
|
|
}
|
|
if counts["Area B"] != 1 {
|
|
t.Errorf("Area B count = %d, want 1 (only ping 1 touches it)", counts["Area B"])
|
|
}
|
|
}
|
|
|
|
// TestComputeAllPingScores_AreaLeaderboardNoAreasConfigured confirms
|
|
// AreaLeaderboard stays nil (not present) rather than an empty-but-present
|
|
// array when the deployment has no areas configured -- matches
|
|
// annotatePacketPathTouchedAreas's own early-return for the same case.
|
|
func TestComputeAllPingScores_AreaLeaderboardNoAreasConfigured(t *testing.T) {
|
|
srv, _ := setupPingScoresFixture(t)
|
|
srv.cfg.Areas = nil
|
|
|
|
txID := seedPingTrigger(t, srv, "arealbnone0001", "#test", "Alice", "2026-01-15T10:00:00Z")
|
|
seedPingObservation(t, srv, txID, "pingobsa", 9.0, `[]`, `[]`, 1736935200)
|
|
seedPingObservation(t, srv, txID, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, 1736935210)
|
|
|
|
snap := srv.computeAllPingScores()
|
|
if snap == nil {
|
|
t.Fatal("computeAllPingScores returned nil")
|
|
}
|
|
if snap.AreaLeaderboard != nil {
|
|
t.Errorf("AreaLeaderboard = %+v, want nil when no areas are configured", snap.AreaLeaderboard)
|
|
}
|
|
}
|
|
|
|
// TestHandlePingScores_EmptyState confirms the endpoint returns a
|
|
// well-formed 200 with zero-valued/omitted fields rather than an error
|
|
// when no ping has ever been recorded -- an ordinary state, not a failure.
|
|
func TestHandlePingScores_EmptyState(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
srv.pingScores.Store(&PingScoresSnapshot{GeneratedAt: time.Now().UTC().Format(time.RFC3339)})
|
|
|
|
req := httptest.NewRequest("GET", "/api/ping-scores", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp PingScoresSnapshot
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.TotalPings != 0 {
|
|
t.Errorf("TotalPings = %d, want 0", resp.TotalPings)
|
|
}
|
|
if resp.FarthestPing != nil {
|
|
t.Errorf("FarthestPing = %+v, want nil/omitted with no ping history", resp.FarthestPing)
|
|
}
|
|
}
|
|
|
|
// TestHandlePingScores_NilCache confirms the handler tolerates the cache
|
|
// never having been populated at all (e.g. a request racing the
|
|
// recomputer's first tick) rather than panicking on a nil pointer.
|
|
func TestHandlePingScores_NilCache(t *testing.T) {
|
|
_, router := setupTestServer(t)
|
|
|
|
req := httptest.NewRequest("GET", "/api/ping-scores", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp PingScoresSnapshot
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.GeneratedAt == "" {
|
|
t.Error("GeneratedAt should still be populated even when the cache was never warmed")
|
|
}
|
|
}
|
|
|
|
// TestHandlePingScores_Populated exercises the full HTTP path end-to-end
|
|
// against real fixture data, confirming the JSON shape matches openapi.go
|
|
// (nested PingScore objects, leaderboards present).
|
|
func TestHandlePingScores_Populated(t *testing.T) {
|
|
srv, router := setupPingScoresFixture(t)
|
|
ts := time.Now().Add(-1 * time.Hour)
|
|
txID := seedPingTrigger(t, srv, "handlerpop00001", "#test", "Alice", ts.Format(time.RFC3339))
|
|
seedPingObservation(t, srv, txID, "pingobsa", 9.0, `[]`, `[]`, ts.Unix())
|
|
seedPingObservation(t, srv, txID, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, ts.Unix()+10)
|
|
|
|
srv.pingScores.Store(srv.computeAllPingScores())
|
|
|
|
req := httptest.NewRequest("GET", "/api/ping-scores", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var raw map[string]interface{}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
|
|
t.Fatalf("decode raw: %v", err)
|
|
}
|
|
if _, present := raw["farthestPing"]; !present {
|
|
t.Error("expected farthestPing in the JSON response")
|
|
}
|
|
if _, present := raw["relayLeaderboard"]; !present {
|
|
t.Error("expected relayLeaderboard in the JSON response")
|
|
}
|
|
if _, present := raw["senderLeaderboard"]; !present {
|
|
t.Error("expected senderLeaderboard in the JSON response")
|
|
}
|
|
}
|