mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 01:48:13 +00:00
Merge branch 'areas-meshguide-sync'
This commit is contained in:
@@ -155,3 +155,161 @@ func TestHandleChannelMessages_EntryPointArea_Unresolved(t *testing.T) {
|
||||
t.Error("entryPrefix must never reach the client")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleChannelMessages_EntryPointArea_DirectObserverFallback covers a
|
||||
// 0-hop (direct) reception: there's no relay path at all, so path[0]
|
||||
// resolution has nothing to work with, but the hearing station's own GPS
|
||||
// fix is a reasonable stand-in for "where this happened" at 0 hops. The
|
||||
// observer here has role "client" (not repeater), deliberately proving the
|
||||
// fallback bypasses the path-hop role filter that buildPrefixMap applies --
|
||||
// a listening station doesn't need to be relay-eligible to have its own
|
||||
// position count here.
|
||||
func TestHandleChannelMessages_EntryPointArea_DirectObserverFallback(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)
|
||||
}
|
||||
if !srv.db.hasScopeName {
|
||||
if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil {
|
||||
t.Fatalf("add scope_name column: %v", err)
|
||||
}
|
||||
srv.db.hasScopeName = true
|
||||
}
|
||||
|
||||
f := func(v float64) *float64 { return &v }
|
||||
srv.cfg.Areas = map[string]AreaEntry{
|
||||
"DK_DJURS": {Label: "Djursland", LatMin: f(56.10), LatMax: f(56.55), LonMin: f(10.35), LonMax: f(10.90)},
|
||||
}
|
||||
|
||||
// The hearing station itself: a plain client, not a repeater, sitting
|
||||
// in Djursland.
|
||||
if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
||||
"pkebeltoftobserver", "DK_8400_Ebeltoft Observer", 56.1959, 10.6801, "client"); err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
srv.store.InvalidateNodeCache()
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := srv.db.conn.Exec(
|
||||
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json,scope_name) VALUES (?,?,?,0,5,'#test',?,'#dk')`,
|
||||
"aa", "chmsgdirect1", now, `{"sender":"HSVI","text":"HSVI: Tak tak"}`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tx: %v", err)
|
||||
}
|
||||
txID, _ := res.LastInsertId()
|
||||
|
||||
obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`,
|
||||
"pkebeltoftobserver", "DK_8400_Ebeltoft Observer", "EBT")
|
||||
if err != nil {
|
||||
t.Fatalf("insert observer: %v", err)
|
||||
}
|
||||
obsIdx, _ := obsRes.LastInsertId()
|
||||
// path_json '[]' -- direct reception, no relay hops at all.
|
||||
if _, err := srv.db.conn.Exec(
|
||||
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`,
|
||||
txID, obsIdx, 12.8, -70.0, `[]`, time.Now().Unix(),
|
||||
); err != nil {
|
||||
t.Fatalf("insert observation: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/channels/%23test/messages?limit=10", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
messages, _ := body["messages"].([]interface{})
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("messages = %+v, want 1", messages)
|
||||
}
|
||||
msg, _ := messages[0].(map[string]interface{})
|
||||
|
||||
if msg["hops"] != float64(0) {
|
||||
t.Errorf("hops = %v, want 0 (direct)", msg["hops"])
|
||||
}
|
||||
if msg["area"] != "Djursland" {
|
||||
t.Errorf("area = %v, want \"Djursland\" (resolved from the hearing station's own position, no relay path needed)", msg["area"])
|
||||
}
|
||||
if _, present := msg["entryObserverPubkey"]; present {
|
||||
t.Error("entryObserverPubkey must never reach the client -- it's an internal-only intermediate field")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleChannelMessages_EntryPointArea_DirectObserverFallback_NoGPS
|
||||
// confirms the fallback stays silent (never guesses) when the hearing
|
||||
// station of a 0-hop message has no GPS fix of its own on file -- the
|
||||
// common case, since most observers aren't also positioned mesh nodes.
|
||||
func TestHandleChannelMessages_EntryPointArea_DirectObserverFallback_NoGPS(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)
|
||||
}
|
||||
if !srv.db.hasScopeName {
|
||||
if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil {
|
||||
t.Fatalf("add scope_name column: %v", err)
|
||||
}
|
||||
srv.db.hasScopeName = true
|
||||
}
|
||||
|
||||
f := func(v float64) *float64 { return &v }
|
||||
srv.cfg.Areas = map[string]AreaEntry{
|
||||
"DK_DJURS": {Label: "Djursland", LatMin: f(56.20), LatMax: f(56.55), LonMin: f(10.35), LonMax: f(10.90)},
|
||||
}
|
||||
// Deliberately no nodes row for the observer -- no GPS fix on file.
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := srv.db.conn.Exec(
|
||||
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json,scope_name) VALUES (?,?,?,0,5,'#test',?,'#dk')`,
|
||||
"aa", "chmsgdirect2", now, `{"sender":"Someone","text":"Someone: hi"}`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tx: %v", err)
|
||||
}
|
||||
txID, _ := res.LastInsertId()
|
||||
obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`,
|
||||
"pknogps", "No GPS Observer", "XXX")
|
||||
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, timestamp) VALUES (?,?,?,?,?,?)`,
|
||||
txID, obsIdx, 5.0, -90.0, `[]`, time.Now().Unix(),
|
||||
); err != nil {
|
||||
t.Fatalf("insert observation: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/channels/%23test/messages?limit=10", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
messages, _ := body["messages"].([]interface{})
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("messages = %+v, want 1", messages)
|
||||
}
|
||||
msg, _ := messages[0].(map[string]interface{})
|
||||
if _, present := msg["area"]; present {
|
||||
t.Errorf("area = %v, want absent (observer has no GPS fix on file)", msg["area"])
|
||||
}
|
||||
if _, present := msg["entryObserverPubkey"]; present {
|
||||
t.Error("entryObserverPubkey must never reach the client")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestHandleChannelMessages_BotReplyTouchedAreas covers dborup's follow-up
|
||||
// request on the ping-bot reply: beyond the numeric "spread up to Nkm", show
|
||||
// which named areas the packet actually touched, deduped across whichever
|
||||
// hearing stations have their own GPS fix on file.
|
||||
func TestHandleChannelMessages_BotReplyTouchedAreas(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)
|
||||
}
|
||||
|
||||
f := func(v float64) *float64 { return &v }
|
||||
srv.cfg.Areas = map[string]AreaEntry{
|
||||
"AAR": {Label: "Aarhus by", LatMin: f(56.05), LatMax: f(56.25), LonMin: f(9.95), LonMax: f(10.35)},
|
||||
"ODE": {Label: "Odense by", LatMin: f(55.30), LatMax: f(55.50), LonMin: f(10.25), LonMax: f(10.45)},
|
||||
}
|
||||
|
||||
// Two observers inside Aarhus by (must dedupe to one label), one inside
|
||||
// Odense by.
|
||||
for _, n := range []struct {
|
||||
pk string
|
||||
lat, lon float64
|
||||
}{
|
||||
{"pkaarhusobs1", 56.15, 10.20},
|
||||
{"pkaarhusobs2", 56.16, 10.21},
|
||||
{"pkodenseobs1", 55.40, 10.38},
|
||||
} {
|
||||
if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
||||
n.pk, n.pk, n.lat, n.lon, "client"); err != nil {
|
||||
t.Fatalf("insert node %s: %v", n.pk, err)
|
||||
}
|
||||
}
|
||||
srv.store.InvalidateNodeCache()
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := srv.db.conn.Exec(
|
||||
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,0,5,'#ping',?)`,
|
||||
"aa", "chmsgtouch1", now, `{"sender":"Alice","text":"Alice: ping"}`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tx: %v", err)
|
||||
}
|
||||
txID, _ := res.LastInsertId()
|
||||
|
||||
obsPKs := []string{"pkaarhusobs1", "pkaarhusobs2", "pkodenseobs1"}
|
||||
for i, pk := range obsPKs {
|
||||
obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, pk, pk, "")
|
||||
if err != nil {
|
||||
t.Fatalf("insert observer %s: %v", pk, err)
|
||||
}
|
||||
obsIdx, _ := obsRes.LastInsertId()
|
||||
if _, err := srv.db.conn.Exec(
|
||||
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`,
|
||||
txID, obsIdx, 10.0, -80.0, `[]`, time.Now().Unix()+int64(i),
|
||||
); err != nil {
|
||||
t.Fatalf("insert observation %s: %v", pk, err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/channels/%23ping/messages?limit=10", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
messages, _ := body["messages"].([]interface{})
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("messages = %+v, want 1", messages)
|
||||
}
|
||||
msg, _ := messages[0].(map[string]interface{})
|
||||
br, _ := msg["botReply"].(map[string]interface{})
|
||||
if br == nil {
|
||||
t.Fatal("expected a botReply on the ping message")
|
||||
}
|
||||
text, _ := br["text"].(string)
|
||||
if !strings.Contains(text, "touched Aarhus by, Odense by") {
|
||||
t.Errorf("botReply text = %q, want \"touched Aarhus by, Odense by\" (deduped, alphabetical)", text)
|
||||
}
|
||||
if _, present := br["touchedObserverPubkeys"]; present {
|
||||
t.Error("touchedObserverPubkeys must never reach the client -- it's an internal-only intermediate field")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleChannelMessages_BotReplyTouchedAreas_Capped covers the display
|
||||
// cap: a packet touching more than botReplyMaxAreasShown distinct areas
|
||||
// shows only the first few (alphabetically) plus a "+N more" count, rather
|
||||
// than growing the chat bubble unboundedly.
|
||||
func TestHandleChannelMessages_BotReplyTouchedAreas_Capped(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)
|
||||
}
|
||||
|
||||
f := func(v float64) *float64 { return &v }
|
||||
srv.cfg.Areas = map[string]AreaEntry{
|
||||
"A1": {Label: "Area One", LatMin: f(56.00), LatMax: f(56.10), LonMin: f(10.00), LonMax: f(10.10)},
|
||||
"A2": {Label: "Area Two", LatMin: f(56.20), LatMax: f(56.30), LonMin: f(10.20), LonMax: f(10.30)},
|
||||
"A3": {Label: "Area Three", LatMin: f(56.40), LatMax: f(56.50), LonMin: f(10.40), LonMax: f(10.50)},
|
||||
"A4": {Label: "Area Four", LatMin: f(56.60), LatMax: f(56.70), LonMin: f(10.60), LonMax: f(10.70)},
|
||||
}
|
||||
|
||||
for _, n := range []struct {
|
||||
pk string
|
||||
lat, lon float64
|
||||
}{
|
||||
{"pkarea1", 56.05, 10.05},
|
||||
{"pkarea2", 56.25, 10.25},
|
||||
{"pkarea3", 56.45, 10.45},
|
||||
{"pkarea4", 56.65, 10.65},
|
||||
} {
|
||||
if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
||||
n.pk, n.pk, n.lat, n.lon, "client"); err != nil {
|
||||
t.Fatalf("insert node %s: %v", n.pk, err)
|
||||
}
|
||||
}
|
||||
srv.store.InvalidateNodeCache()
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := srv.db.conn.Exec(
|
||||
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,0,5,'#ping',?)`,
|
||||
"aa", "chmsgtouch2", now, `{"sender":"Bob","text":"Bob: ping"}`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tx: %v", err)
|
||||
}
|
||||
txID, _ := res.LastInsertId()
|
||||
|
||||
obsPKs := []string{"pkarea1", "pkarea2", "pkarea3", "pkarea4"}
|
||||
for i, pk := range obsPKs {
|
||||
obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, pk, pk, "")
|
||||
if err != nil {
|
||||
t.Fatalf("insert observer %s: %v", pk, err)
|
||||
}
|
||||
obsIdx, _ := obsRes.LastInsertId()
|
||||
if _, err := srv.db.conn.Exec(
|
||||
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`,
|
||||
txID, obsIdx, 10.0, -80.0, `[]`, time.Now().Unix()+int64(i),
|
||||
); err != nil {
|
||||
t.Fatalf("insert observation %s: %v", pk, err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/channels/%23ping/messages?limit=10", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
messages, _ := body["messages"].([]interface{})
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("messages = %+v, want 1", messages)
|
||||
}
|
||||
msg, _ := messages[0].(map[string]interface{})
|
||||
br, _ := msg["botReply"].(map[string]interface{})
|
||||
if br == nil {
|
||||
t.Fatal("expected a botReply on the ping message")
|
||||
}
|
||||
text, _ := br["text"].(string)
|
||||
want := "touched Area Four, Area One, Area Three +1 more"
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("botReply text = %q, want it to contain %q (capped to 3, alphabetical, remainder counted)", text, want)
|
||||
}
|
||||
}
|
||||
+95
-15
@@ -1602,6 +1602,13 @@ type PacketPathResponse struct {
|
||||
Hash string `json:"hash"`
|
||||
Branches []PacketPathBranch `json:"branches"`
|
||||
First *PacketPathBranch `json:"first,omitempty"`
|
||||
// TouchedAreas is every configured area any point or observer on this
|
||||
// path resolved to (deduped, alphabetized, uncapped -- unlike the
|
||||
// ping-bot reply's capped list, the map has room to show all of them).
|
||||
// Populated by routes.go's annotatePacketPathTouchedAreas, not here:
|
||||
// area resolution needs config.Areas, not available at this SQL-only
|
||||
// DB layer.
|
||||
TouchedAreas []string `json:"touchedAreas,omitempty"`
|
||||
}
|
||||
|
||||
// GetPacketPath resolves every distinct station that observed a packet to
|
||||
@@ -2643,22 +2650,34 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
}
|
||||
}
|
||||
senderTs := decoded["sender_timestamp"]
|
||||
// entryObserverPubkey: a 0-hop (direct) reception has no relay path,
|
||||
// so entryPrefix is empty and annotateMessageAreas has nothing to
|
||||
// resolve -- even though the hearing station's own position is a
|
||||
// reasonable stand-in for "where this happened" at 0 hops. Only
|
||||
// captured for the direct case; a message with an unresolvable
|
||||
// multi-hop path deliberately still gets no fallback (an estimate
|
||||
// from the wrong end of a relay chain isn't worth showing).
|
||||
var entryObserverPubkey string
|
||||
if hops == 0 && obsID.Valid && obsID.String != "" {
|
||||
entryObserverPubkey = strings.ToLower(strings.TrimSpace(obsID.String))
|
||||
}
|
||||
m := &msg{
|
||||
Data: map[string]interface{}{
|
||||
"sender": displaySender,
|
||||
"text": displayText,
|
||||
"timestamp": nullStr(fs),
|
||||
"first_seen": nullStr(fs),
|
||||
"sender_timestamp": senderTs,
|
||||
"packetId": pktID,
|
||||
"packetHash": nullStr(pktHash),
|
||||
"repeats": 1,
|
||||
"observers": []string{},
|
||||
"hops": hops,
|
||||
"snr": nullFloat(snr),
|
||||
"scope": nullStr(scopeName),
|
||||
"routeType": nullInt(routeType),
|
||||
"entryPrefix": entryPrefix,
|
||||
"sender": displaySender,
|
||||
"text": displayText,
|
||||
"timestamp": nullStr(fs),
|
||||
"first_seen": nullStr(fs),
|
||||
"sender_timestamp": senderTs,
|
||||
"packetId": pktID,
|
||||
"packetHash": nullStr(pktHash),
|
||||
"repeats": 1,
|
||||
"observers": []string{},
|
||||
"hops": hops,
|
||||
"snr": nullFloat(snr),
|
||||
"scope": nullStr(scopeName),
|
||||
"routeType": nullInt(routeType),
|
||||
"entryPrefix": entryPrefix,
|
||||
"entryObserverPubkey": entryObserverPubkey,
|
||||
},
|
||||
Repeats: 1,
|
||||
}
|
||||
@@ -2803,7 +2822,19 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
}
|
||||
}
|
||||
if m, ok := msgMap[txID]; ok {
|
||||
m.Data["botReply"] = pingBotReply(p.hops, p.snr, observerLabel, repeaterNames, farthestKm)
|
||||
br := pingBotReply(p.hops, p.snr, observerLabel, repeaterNames, farthestKm)
|
||||
// touchedObserverPubkeys never reaches the client -- routes.go's
|
||||
// annotateBotReplyTouchedAreas resolves it to area labels
|
||||
// (needs s.cfg.Areas, not available at this SQL-only DB
|
||||
// layer) and deletes it before the response is written.
|
||||
if len(p.observerPubkeys) > 0 {
|
||||
pks := make([]string, 0, len(p.observerPubkeys))
|
||||
for pk := range p.observerPubkeys {
|
||||
pks = append(pks, pk)
|
||||
}
|
||||
br["touchedObserverPubkeys"] = pks
|
||||
}
|
||||
m.Data["botReply"] = br
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4255,6 +4286,55 @@ func (db *DB) namesAndRolesForPubkeys(pubkeys []string) (names, roles map[string
|
||||
return names, roles
|
||||
}
|
||||
|
||||
// gpsByPubkeysExact bulk-resolves GPS positions for a set of FULL pubkeys
|
||||
// via an exact match -- unlike the path-hop prefix machinery (buildPrefixMap
|
||||
// /resolveEntryPointArea), which only indexes repeater/room-server roles as
|
||||
// path-hop candidates, this has no role filter: any node type qualifies,
|
||||
// since a hearing station's own position doesn't depend on whether it could
|
||||
// ever appear as a relay hop in someone else's path.
|
||||
func (db *DB) gpsByPubkeysExact(pubkeys []string) map[string][2]float64 {
|
||||
result := make(map[string][2]float64, len(pubkeys))
|
||||
if len(pubkeys) == 0 {
|
||||
return result
|
||||
}
|
||||
const chunkSize = 499
|
||||
for start := 0; start < len(pubkeys); start += chunkSize {
|
||||
end := start + chunkSize
|
||||
if end > len(pubkeys) {
|
||||
end = len(pubkeys)
|
||||
}
|
||||
chunk := pubkeys[start:end]
|
||||
placeholders := make([]byte, 0, len(chunk)*2)
|
||||
args := make([]interface{}, len(chunk))
|
||||
for i, pk := range chunk {
|
||||
if i > 0 {
|
||||
placeholders = append(placeholders, ',')
|
||||
}
|
||||
placeholders = append(placeholders, '?')
|
||||
args[i] = pk
|
||||
}
|
||||
query := "SELECT public_key, lat, lon FROM nodes WHERE public_key IN (" + string(placeholders) + ")"
|
||||
rows, err := db.conn.Query(query, args...)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for rows.Next() {
|
||||
var pk string
|
||||
var lat, lon sql.NullFloat64
|
||||
if rows.Scan(&pk, &lat, &lon) != nil {
|
||||
continue
|
||||
}
|
||||
// (0,0) is the ocean off Ghana, not a real fix -- same
|
||||
// exclusion GetPacketPath/packetSpreadStats apply.
|
||||
if lat.Valid && lon.Valid && !(lat.Float64 == 0 && lon.Float64 == 0) {
|
||||
result[pk] = [2]float64{lat.Float64, lon.Float64}
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetChannelMessageScopeStats narrows the scoped/unscoped/unknown question
|
||||
// to channel chat specifically (payload_type=5), for the given window.
|
||||
// Unlike GetScopeStats' TransportTotal (route_type 0/3 only), TotalMessages
|
||||
|
||||
@@ -377,9 +377,10 @@ func componentSchemas() map[string]interface{} {
|
||||
"PacketPathResponse": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"hash": str("The packet hash this path was resolved for."),
|
||||
"branches": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathBranch"), "description": "One branch per distinct station that observed the packet, each kept at that station's own deepest observation, sorted deepest-first -- shows the full flood spread, not just the single farthest route."},
|
||||
"first": schemaRef("PacketPathBranch"),
|
||||
"hash": str("The packet hash this path was resolved for."),
|
||||
"branches": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathBranch"), "description": "One branch per distinct station that observed the packet, each kept at that station's own deepest observation, sorted deepest-first -- shows the full flood spread, not just the single farthest route."},
|
||||
"first": schemaRef("PacketPathBranch"),
|
||||
"touchedAreas": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "Every configured area any point or observer on the path falls in, deduped and alphabetized. Omitted when no areas are configured or none resolved."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHandlePacketPath_TouchedAreas covers dborup's follow-up to the
|
||||
// ping-bot reply's capped "touched" list: View Path has room to show every
|
||||
// area the packet's points and observers fall in, not just the first few.
|
||||
// Deduped and alphabetized, but uncapped -- unlike
|
||||
// annotateBotReplyTouchedAreas's pong-reply version.
|
||||
func TestHandlePacketPath_TouchedAreas(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)
|
||||
}
|
||||
|
||||
f := func(v float64) *float64 { return &v }
|
||||
srv.cfg.Areas = map[string]AreaEntry{
|
||||
"AAR": {Label: "Aarhus by", LatMin: f(56.05), LatMax: f(56.25), LonMin: f(9.95), LonMax: f(10.35)},
|
||||
"ODE": {Label: "Odense by", LatMin: f(55.30), LatMax: f(55.50), LonMin: f(10.25), LonMax: f(10.45)},
|
||||
}
|
||||
|
||||
// A relay hop positioned in Aarhus, an observer positioned in Odense --
|
||||
// touchedAreas must cover both, from a single 1-hop branch.
|
||||
if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
||||
"pkaarhusrepeater", "AarhusRepeater", 56.15, 10.20, "repeater"); err != nil {
|
||||
t.Fatalf("insert repeater node: %v", err)
|
||||
}
|
||||
if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
||||
"pkodenseobserver", "OdenseObserver", 55.40, 10.38, "client"); err != nil {
|
||||
t.Fatalf("insert observer node: %v", err)
|
||||
}
|
||||
srv.store.InvalidateNodeCache()
|
||||
|
||||
txRes, err := srv.db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('AA', 'touchedpath00001', '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 (?,?,?)`,
|
||||
"pkodenseobserver", "OdenseObserver", "")
|
||||
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, 8.0, -85.0, `["aa"]`, `["pkaarhusrepeater"]`, 1736935200,
|
||||
); err != nil {
|
||||
t.Fatalf("insert observation: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/packets/touchedpath00001/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 resp PacketPathResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.TouchedAreas) != 2 || resp.TouchedAreas[0] != "Aarhus by" || resp.TouchedAreas[1] != "Odense by" {
|
||||
t.Errorf("TouchedAreas = %v, want [\"Aarhus by\" \"Odense by\"] (alphabetical, one from the relay hop, one from the observer)", resp.TouchedAreas)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlePacketPath_TouchedAreas_NoAreasConfigured confirms the field is
|
||||
// simply omitted (never guessed) when no areas are configured.
|
||||
func TestHandlePacketPath_TouchedAreas_NoAreasConfigured(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)
|
||||
}
|
||||
srv.cfg.Areas = nil
|
||||
|
||||
if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
||||
"pkaarhusrepeater", "AarhusRepeater", 56.15, 10.20, "repeater"); err != nil {
|
||||
t.Fatalf("insert repeater node: %v", err)
|
||||
}
|
||||
srv.store.InvalidateNodeCache()
|
||||
|
||||
txRes, err := srv.db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('AA', 'touchedpath00002', '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 (?,?,?)`, "obsX", "ObsX", "")
|
||||
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, 8.0, -85.0, `["aa"]`, `["pkaarhusrepeater"]`, 1736935200,
|
||||
); err != nil {
|
||||
t.Fatalf("insert observation: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/packets/touchedpath00002/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 body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if _, present := body["touchedAreas"]; present {
|
||||
t.Errorf("touchedAreas = %v, want absent (no areas configured)", body["touchedAreas"])
|
||||
}
|
||||
}
|
||||
+147
-2
@@ -2876,20 +2876,123 @@ func (s *Server) resolveEntryPointArea(prefixes []string) (label string, ok bool
|
||||
// sitting in Aarhus but sending with the broad #dk scope should still show
|
||||
// "Aarhus by" here. The raw entryPrefix never reaches the client, whether
|
||||
// or not it resolved.
|
||||
//
|
||||
// A 0-hop (direct) message has no relay path at all, so there's no
|
||||
// entryPrefix to resolve -- even though the hearing station's own GPS fix
|
||||
// (captured as "entryObserverPubkey", direct-reception case only) is a
|
||||
// reasonable stand-in for "where this happened". That fallback is resolved
|
||||
// in a second bulk pass below, deliberately not extended to messages whose
|
||||
// multi-hop path just failed to resolve (an estimate from the wrong end of
|
||||
// a relay chain isn't worth showing). entryObserverPubkey never reaches the
|
||||
// client either way.
|
||||
func (s *Server) annotateMessageAreas(messages []map[string]interface{}) {
|
||||
hasAreas := s.cfg != nil && len(s.cfg.Areas) > 0
|
||||
needsFallback := make([]map[string]interface{}, 0)
|
||||
fallbackPubkeys := make([]string, 0)
|
||||
for _, m := range messages {
|
||||
prefix, _ := m["entryPrefix"].(string)
|
||||
delete(m, "entryPrefix")
|
||||
if !hasAreas || prefix == "" {
|
||||
observerPK, _ := m["entryObserverPubkey"].(string)
|
||||
delete(m, "entryObserverPubkey")
|
||||
if !hasAreas {
|
||||
continue
|
||||
}
|
||||
if label, ok := s.resolveEntryPointArea([]string{prefix}); ok {
|
||||
if prefix != "" {
|
||||
if label, ok := s.resolveEntryPointArea([]string{prefix}); ok {
|
||||
m["area"] = label
|
||||
continue
|
||||
}
|
||||
}
|
||||
if observerPK != "" {
|
||||
needsFallback = append(needsFallback, m)
|
||||
fallbackPubkeys = append(fallbackPubkeys, observerPK)
|
||||
}
|
||||
}
|
||||
if len(needsFallback) == 0 || s.db == nil {
|
||||
return
|
||||
}
|
||||
gpsByPK := s.db.gpsByPubkeysExact(fallbackPubkeys)
|
||||
for i, m := range needsFallback {
|
||||
pos, ok := gpsByPK[fallbackPubkeys[i]]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if label, ok := AreaForPoint(pos[0], pos[1], s.cfg.Areas); ok {
|
||||
m["area"] = label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// annotateBotReplyTouchedAreas extends a ping-bot reply with the distinct
|
||||
// configured areas any hearing station (with its own GPS fix on file) was
|
||||
// in -- "how wide" the spread was in named-place terms, alongside the
|
||||
// numeric "spread up to Nkm" pingBotReply already reports. Capped to keep
|
||||
// the chat bubble readable, since a broadly-flooded packet can easily touch
|
||||
// a dozen+ areas. touchedObserverPubkeys never reaches the client either
|
||||
// way.
|
||||
const botReplyMaxAreasShown = 3
|
||||
|
||||
func (s *Server) annotateBotReplyTouchedAreas(messages []map[string]interface{}) {
|
||||
hasAreas := s.cfg != nil && len(s.cfg.Areas) > 0
|
||||
type pending struct {
|
||||
br map[string]interface{}
|
||||
pubkeys []string
|
||||
}
|
||||
var work []pending
|
||||
pubkeySet := map[string]bool{}
|
||||
for _, m := range messages {
|
||||
br, ok := m["botReply"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pks, _ := br["touchedObserverPubkeys"].([]string)
|
||||
delete(br, "touchedObserverPubkeys")
|
||||
if !hasAreas || len(pks) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, pk := range pks {
|
||||
pubkeySet[pk] = true
|
||||
}
|
||||
work = append(work, pending{br: br, pubkeys: pks})
|
||||
}
|
||||
if len(work) == 0 || s.db == nil {
|
||||
return
|
||||
}
|
||||
allPubkeys := make([]string, 0, len(pubkeySet))
|
||||
for pk := range pubkeySet {
|
||||
allPubkeys = append(allPubkeys, pk)
|
||||
}
|
||||
gpsByPK := s.db.gpsByPubkeysExact(allPubkeys)
|
||||
for _, w := range work {
|
||||
seen := map[string]bool{}
|
||||
var labels []string
|
||||
for _, pk := range w.pubkeys {
|
||||
pos, ok := gpsByPK[pk]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
label, ok := AreaForPoint(pos[0], pos[1], s.cfg.Areas)
|
||||
if !ok || seen[label] {
|
||||
continue
|
||||
}
|
||||
seen[label] = true
|
||||
labels = append(labels, label)
|
||||
}
|
||||
if len(labels) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(labels)
|
||||
shown := labels
|
||||
suffix := ""
|
||||
if len(labels) > botReplyMaxAreasShown {
|
||||
shown = labels[:botReplyMaxAreasShown]
|
||||
suffix = fmt.Sprintf(" +%d more", len(labels)-botReplyMaxAreasShown)
|
||||
}
|
||||
text, _ := w.br["text"].(string)
|
||||
w.br["text"] = text + " · touched " + strings.Join(shown, ", ") + suffix
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) {
|
||||
region := r.URL.Query().Get("region")
|
||||
includeEncrypted := r.URL.Query().Get("includeEncrypted") == "true"
|
||||
@@ -2935,12 +3038,14 @@ func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.annotateMessageAreas(messages)
|
||||
s.annotateBotReplyTouchedAreas(messages)
|
||||
writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total})
|
||||
return
|
||||
}
|
||||
if s.store != nil {
|
||||
messages, total := s.store.GetChannelMessages(hash, limit, offset, region)
|
||||
s.annotateMessageAreas(messages)
|
||||
s.annotateBotReplyTouchedAreas(messages)
|
||||
writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total})
|
||||
return
|
||||
}
|
||||
@@ -3164,9 +3269,49 @@ func (s *Server) handlePacketPath(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
s.annotatePacketPathTouchedAreas(resp)
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
// annotatePacketPathTouchedAreas resolves resp.TouchedAreas: every
|
||||
// configured area any point or observer on the path falls in, deduped and
|
||||
// alphabetized, uncapped (unlike annotateBotReplyTouchedAreas's capped
|
||||
// pong-reply list -- the map view has room to show the full set). Unlike
|
||||
// that function, no DB round-trip is needed: GetPacketPath already
|
||||
// resolved every position (including the neighbor-centroid approximation
|
||||
// fallback), so this just reads the lat/lon already on the response.
|
||||
func (s *Server) annotatePacketPathTouchedAreas(resp *PacketPathResponse) {
|
||||
if resp == nil || s.cfg == nil || len(s.cfg.Areas) == 0 {
|
||||
return
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var labels []string
|
||||
add := func(lat, lon *float64) {
|
||||
if lat == nil || lon == nil {
|
||||
return
|
||||
}
|
||||
label, ok := AreaForPoint(*lat, *lon, s.cfg.Areas)
|
||||
if !ok || seen[label] {
|
||||
return
|
||||
}
|
||||
seen[label] = true
|
||||
labels = append(labels, label)
|
||||
}
|
||||
for _, b := range resp.Branches {
|
||||
for _, pt := range b.Points {
|
||||
add(pt.Lat, pt.Lon)
|
||||
}
|
||||
if b.Observer != nil {
|
||||
add(b.Observer.Lat, b.Observer.Lon)
|
||||
}
|
||||
}
|
||||
if len(labels) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Strings(labels)
|
||||
resp.TouchedAreas = labels
|
||||
}
|
||||
|
||||
var iataCoords = map[string]IataCoord{
|
||||
"SJC": {Lat: 37.3626, Lon: -121.929},
|
||||
"SFO": {Lat: 37.6213, Lon: -122.379},
|
||||
|
||||
@@ -2971,6 +2971,18 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
if label, ok := s.resolveEntryPointArea([]string{entryPrefix}); ok {
|
||||
pkt["area"] = label
|
||||
}
|
||||
} else if obs.ObserverID != "" && s.db != nil && s.config != nil && len(s.config.Areas) > 0 {
|
||||
// 0-hop (direct) reception: no relay path to resolve, but
|
||||
// the hearing station's own position is a reasonable
|
||||
// stand-in for "where this happened" -- same fallback as
|
||||
// annotateMessageAreas (routes.go), not extended to a
|
||||
// multi-hop path that just failed to resolve.
|
||||
pk := strings.ToLower(strings.TrimSpace(obs.ObserverID))
|
||||
if gps, ok := s.db.gpsByPubkeysExact([]string{pk})[pk]; ok {
|
||||
if label, ok := AreaForPoint(gps[0], gps[1], s.config.Areas); ok {
|
||||
pkt["area"] = label
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use decode-window resolved path for broadcast (never from struct)
|
||||
if broadcastRP != nil {
|
||||
|
||||
@@ -192,6 +192,12 @@
|
||||
var bounds = [];
|
||||
var missingTotal = 0;
|
||||
var approxTotal = 0;
|
||||
// The same physical node (e.g. a shared entry-point repeater near the
|
||||
// sender) commonly appears in many branches' chains -- dedupe by
|
||||
// identity so "N approximate" counts distinct stations, not chain
|
||||
// appearances (#1... a packet heard by 12 stations through one shared
|
||||
// repeater was showing "11 approximate" for what was really 1 node).
|
||||
var approxSeen = {};
|
||||
// Draw secondary branches first so the primary (deepest) one ends up on top.
|
||||
var ordered = plotted.slice().sort(function (a, b) { return (a.primary ? 1 : 0) - (b.primary ? 1 : 0); });
|
||||
ordered.forEach(function (p) {
|
||||
@@ -199,7 +205,13 @@
|
||||
var lineColor = p.primary ? accent : muted;
|
||||
var line = [];
|
||||
p.chain.forEach(function (pt) {
|
||||
if (pt.approx) approxTotal++;
|
||||
if (pt.approx) {
|
||||
var approxKey = pt.publicKey || pt.name;
|
||||
if (!approxSeen[approxKey]) {
|
||||
approxSeen[approxKey] = true;
|
||||
approxTotal++;
|
||||
}
|
||||
}
|
||||
bounds.push([pt.lat, pt.lon]);
|
||||
line.push([pt.lat, pt.lon]);
|
||||
var color = pt.isObserver ? observerColor : lineColor;
|
||||
@@ -269,6 +281,7 @@
|
||||
if (firstPoint) statusParts.push('entered near ' + firstPoint.name);
|
||||
if (approxTotal > 0) statusParts.push(approxTotal + ' approximate (estimated from neighbors)');
|
||||
if (missingTotal > 0) statusParts.push(missingTotal + ' hop' + (missingTotal === 1 ? '' : 's') + ' without a known position (not shown)');
|
||||
if (data.touchedAreas && data.touchedAreas.length > 0) statusParts.push('touched ' + data.touchedAreas.join(', '));
|
||||
if (statusEl) statusEl.textContent = statusParts.join(' · ');
|
||||
}
|
||||
|
||||
|
||||
@@ -320,6 +320,50 @@ function makeSandbox(apiImpl) {
|
||||
} catch (e) { failed++; console.log(' ❌ approximate (neighbor-borrowed) positions render hollow/dashed and are called out in status: ' + e.message); }
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
try {
|
||||
// A shared entry-point node with an approximate position commonly
|
||||
// appears in MANY branches' chains (e.g. one repeater near the
|
||||
// sender that a dozen stations all relayed through). The status
|
||||
// count must dedupe by identity -- 1 distinct approximate node
|
||||
// showing up in 3 branches is "1 approximate", not "3".
|
||||
const ctx = makeSandbox(() => Promise.resolve({
|
||||
hash: 'deadbeef',
|
||||
branches: [
|
||||
{
|
||||
hops: 2,
|
||||
points: [{ publicKey: 'pk-shared', name: 'SharedRepeater', lat: 56.0, lon: 10.0, approx: true }],
|
||||
observer: { name: 'ObserverA', lat: 56.1, lon: 10.1 },
|
||||
},
|
||||
{
|
||||
hops: 1,
|
||||
points: [{ publicKey: 'pk-shared', name: 'SharedRepeater', lat: 56.0, lon: 10.0, approx: true }],
|
||||
observer: { name: 'ObserverB', lat: 56.2, lon: 10.2 },
|
||||
},
|
||||
{
|
||||
hops: 1,
|
||||
points: [{ publicKey: 'pk-shared', name: 'SharedRepeater', lat: 56.0, lon: 10.0, approx: true }],
|
||||
observer: { name: 'ObserverC', lat: 56.3, lon: 10.3 },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
ctx.L = {
|
||||
map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }),
|
||||
tileLayer: () => ({ addTo() { return this; } }),
|
||||
circleMarker: () => ({ addTo() { return this; }, bindTooltip() { return this; }, on() { return this; } }),
|
||||
polyline: () => ({ addTo() { return this; } }),
|
||||
};
|
||||
|
||||
await ctx.window.PacketPathMap.open('deadbeef');
|
||||
const status = ctx.document.getElementById('packetPathStatus');
|
||||
assert.ok(status.textContent.includes('1 approximate'), 'status should count the shared node once (1 approximate), not once per branch it appears in, got: ' + status.textContent);
|
||||
assert.ok(!status.textContent.includes('3 approximate'), 'status should NOT count 3 -- that would be counting chain appearances, not distinct nodes, got: ' + status.textContent);
|
||||
passed++;
|
||||
console.log(' ✅ "N approximate" dedupes a shared node across branches instead of counting each chain appearance');
|
||||
} catch (e) { failed++; console.log(' ❌ "N approximate" dedupes a shared node across branches instead of counting each chain appearance: ' + e.message); }
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
try {
|
||||
// branch.secondsAfterFirst (0 for the earliest arrival, positive
|
||||
@@ -498,6 +542,55 @@ function makeSandbox(apiImpl) {
|
||||
} catch (e) { failed++; console.log(' ❌ markers with a publicKey are clickable and navigate to node detail, closing the modal: ' + e.message); }
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
try {
|
||||
// touchedAreas is the server-resolved, uncapped list of every
|
||||
// configured area any point/observer on the path fell in -- View
|
||||
// Path has room to show all of them (unlike the pong reply's
|
||||
// capped "+N more" version).
|
||||
const ctx = makeSandbox(() => Promise.resolve({
|
||||
hash: 'deadbeef',
|
||||
branches: [{ hops: 0, points: [], observer: { name: 'Obs', lat: 56.0, lon: 10.0 } }],
|
||||
touchedAreas: ['Aarhus by', 'Djursland', 'Odense by'],
|
||||
}));
|
||||
ctx.L = {
|
||||
map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }),
|
||||
tileLayer: () => ({ addTo() { return this; } }),
|
||||
circleMarker: () => ({ addTo() { return this; }, bindTooltip() { return this; }, on() { return this; } }),
|
||||
polyline: () => ({ addTo() { return this; } }),
|
||||
};
|
||||
|
||||
await ctx.window.PacketPathMap.open('deadbeef');
|
||||
const status = ctx.document.getElementById('packetPathStatus');
|
||||
assert.ok(status.textContent.includes('touched Aarhus by, Djursland, Odense by'), 'status should list every touched area uncapped, got: ' + status.textContent);
|
||||
passed++;
|
||||
console.log(' ✅ touchedAreas renders as an uncapped, comma-joined list in the status line');
|
||||
} catch (e) { failed++; console.log(' ❌ touchedAreas renders as an uncapped, comma-joined list in the status line: ' + e.message); }
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
try {
|
||||
// No touchedAreas field at all (no areas configured server-side, or
|
||||
// none resolved) -- must not add a stray "touched" fragment or throw.
|
||||
const ctx = makeSandbox(() => Promise.resolve({
|
||||
hash: 'deadbeef',
|
||||
branches: [{ hops: 0, points: [], observer: { name: 'Obs', lat: 56.0, lon: 10.0 } }],
|
||||
}));
|
||||
ctx.L = {
|
||||
map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }),
|
||||
tileLayer: () => ({ addTo() { return this; } }),
|
||||
circleMarker: () => ({ addTo() { return this; }, bindTooltip() { return this; }, on() { return this; } }),
|
||||
polyline: () => ({ addTo() { return this; } }),
|
||||
};
|
||||
|
||||
await ctx.window.PacketPathMap.open('deadbeef');
|
||||
const status = ctx.document.getElementById('packetPathStatus');
|
||||
assert.ok(!status.textContent.includes('touched'), 'status should have no "touched" fragment when touchedAreas is absent, got: ' + status.textContent);
|
||||
passed++;
|
||||
console.log(' ✅ omits the "touched" fragment when touchedAreas is absent');
|
||||
} catch (e) { failed++; console.log(' ❌ omits the "touched" fragment when touchedAreas is absent: ' + e.message); }
|
||||
})();
|
||||
|
||||
console.log('\n════════════════════════════════════════');
|
||||
console.log(` packet-path-map.js: ${passed} passed, ${failed} failed`);
|
||||
console.log('════════════════════════════════════════');
|
||||
|
||||
Reference in New Issue
Block a user