fix: View Path airtime estimate omits fields on 0-relay (direct) packets

Caught on real stg data: a directly-received packet has relays=0, and
while the plain int AirtimeRelayCount's omitempty correctly dropped it
from JSON, the *float64 EstimatedAirtimeMs still encoded as a bare
"estimatedAirtimeMs":0 (a non-nil pointer isn't "empty" to omitempty
even when it points at zero). The frontend's typeof-number check then
rendered "~0ms estimated airtime (undefined relays)". Now both fields
are omitted together whenever there's nothing to relay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
dborup
2026-07-26 11:10:54 +02:00
co-authored by Claude Sonnet 5
parent e9e828c7e9
commit e81f89da1e
2 changed files with 65 additions and 1 deletions
+59
View File
@@ -112,6 +112,65 @@ func TestHandlePacketPath_Airtime(t *testing.T) {
}
}
// TestHandlePacketPath_Airtime_ZeroRelays confirms the fields are omitted
// -- not a bare "estimatedAirtimeMs":0 with no accompanying relay count --
// for a directly-received packet (no resolved_path relays at all). This
// caught a real bug on stg: the *float64 EstimatedAirtimeMs survived JSON
// encoding as 0 (a non-nil pointer isn't "empty"), while the plain int
// AirtimeRelayCount's omitempty dropped it at exactly 0, leaving the
// frontend a number with nothing to pair it with.
func TestHandlePacketPath_Airtime_ZeroRelays(t *testing.T) {
srv, router := setupTestServer(t)
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
t.Fatalf("clear transmissions: %v", err)
}
if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil {
t.Fatalf("clear observations: %v", err)
}
txRes, err := srv.db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AABBCCDDEE', 'airtimepath00003', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
if err != nil {
t.Fatalf("insert tx: %v", err)
}
txID, _ := txRes.LastInsertId()
obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, "airtimeobs1", "ObsOne", "SJC")
if err != nil {
t.Fatalf("insert observer: %v", err)
}
obsIdx, _ := obsRes.LastInsertId()
if _, err := srv.db.conn.Exec(
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) VALUES (?,?,?,?,?,?,?)`,
txID, obsIdx, 9.0, -88.0, `[]`, `[]`, 1736935200,
); err != nil {
t.Fatalf("insert observation: %v", err)
}
if err := srv.store.Load(); err != nil {
t.Fatalf("reload store: %v", err)
}
if !srv.store.WaitIndexesReady(5 * time.Second) {
t.Fatal("background indexes never became ready after reload")
}
req := httptest.NewRequest("GET", "/api/packets/airtimepath00003/path", 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: %v", err)
}
if _, present := raw["estimatedAirtimeMs"]; present {
t.Errorf("estimatedAirtimeMs = %v, want absent for a direct reception with 0 relays", raw["estimatedAirtimeMs"])
}
if _, present := raw["airtimeRelayCount"]; present {
t.Errorf("airtimeRelayCount = %v, want absent for a direct reception with 0 relays", raw["airtimeRelayCount"])
}
}
// TestHandlePacketPath_Airtime_StoreUnavailable confirms the field is
// simply omitted -- not a guessed zero -- when the in-memory store has no
// record of this transmission's ID (e.g. DB-only mode, or an old packet
+6 -1
View File
@@ -3286,7 +3286,12 @@ func (s *Server) annotatePacketPathAirtime(resp *PacketPathResponse) {
return
}
total, relays, ok := s.store.AirtimeAndRelayCountForTransmission(resp.TxID)
if !ok {
if !ok || relays == 0 {
// relays == 0 means a direct reception with nothing to relay --
// there's no meaningful airtime estimate to show (and 0 would
// otherwise survive JSON encoding as a bare "estimatedAirtimeMs":0
// while the omitempty int AirtimeRelayCount vanishes alongside it,
// leaving the frontend with a number but no relay count to pair it with).
return
}
ms := float64(total.Microseconds()) / 1000.0