mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-19 08:25:14 +00:00
353c5264ad
Red commit: 5ffdf6b07c (CI run: pending —
see PR Checks tab)
Fixes #1197
## What this changes
Two-part fix matching the issue spec:
1. **Tier-3/4 tiebreak by observation count, not slice order**
(`store.go` resolver + `getAllNodes`).
- Plumbs `nodes.advert_count` → new `nodeInfo.ObservationCount` field
via the existing `getAllNodes` query (graceful fallback when the column
is absent on legacy DBs).
- `resolveWithContext` tier 3 (GPS preference) now picks the GPS-having
candidate with the highest observation count.
- Tier 4 (no-GPS fallback) likewise picks by observation count instead
of `candidates[0]`.
2. **Plumb hop-context to the resolver** at all four call sites called
out in the issue.
- New `buildHopContextPubkeys(tx, pm)` collects: sender pubkey from
`tx.DecodedJSON.pubKey`, observer pubkey from `tx.ObserverID`, plus
unambiguous-prefix anchors (single-candidate prefixes in the path).
- Wired into the four sites: broadcast distance compute (~1707),
recompute-on-path-change (~2944), `buildDistanceIndex` (~2982),
`computeAnalyticsTopology` (~5125).
- Per-tx hop caches were moved inside the per-tx loop on the distance
paths since context now varies per tx (was safely shared before only
because every caller passed `nil`).
- `computeAnalyticsTopology` aggregates context across the analytics
scan rather than per-tx because `resolveHop` is called outside the scan
loop downstream.
## Tests
Red→green pairs visible in the commit history:
- Pair A — tier-3 observation-count tiebreak
(`TestResolveWithContext_Tier3_PicksHigherObservationCount`).
- Pair B — context plumbing
(`TestBuildHopContextPubkeys_IncludesSenderAndUnambiguousAnchors`) +
tier-2 geo-proximity
(`TestResolveWithContext_Tier2_PicksGeographicallyCloserCandidate`).
`go test ./...` green on `cmd/server`.
## Out of scope (per issue)
300 km hop cap, API confidence/alternative-count surfacing, firmware
prefix-collision space — all explicitly excluded in #1197.
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
310 lines
10 KiB
Go
310 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ─── resolveWithContext unit tests ─────────────────────────────────────────────
|
|
|
|
func TestResolveWithContext_UniquePrefix(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1b2c3d4", Name: "Node-A", HasGPS: true, Lat: 1, Lon: 2},
|
|
})
|
|
ni, confidence, _ := pm.resolveWithContext("a1b2c3d4", nil, nil)
|
|
if ni == nil || ni.Name != "Node-A" {
|
|
t.Fatal("expected Node-A")
|
|
}
|
|
if confidence != "unique_prefix" {
|
|
t.Fatalf("expected unique_prefix, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_NoMatch(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1b2c3d4", Name: "Node-A"},
|
|
})
|
|
ni, confidence, _ := pm.resolveWithContext("ff", nil, nil)
|
|
if ni != nil {
|
|
t.Fatal("expected nil")
|
|
}
|
|
if confidence != "no_match" {
|
|
t.Fatalf("expected no_match, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_AffinityWins(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1aaaaaa", Name: "Node-A1"},
|
|
{Role: "repeater", PublicKey: "a1bbbbbb", Name: "Node-A2"},
|
|
})
|
|
|
|
graph := NewNeighborGraph()
|
|
for i := 0; i < 100; i++ {
|
|
graph.upsertEdge("c0c0c0c0", "a1aaaaaa", "a1", "obs1", nil, time.Now())
|
|
}
|
|
|
|
ni, confidence, score := pm.resolveWithContext("a1", []string{"c0c0c0c0"}, graph)
|
|
if ni == nil || ni.Name != "Node-A1" {
|
|
t.Fatalf("expected Node-A1, got %v", ni)
|
|
}
|
|
if confidence != "neighbor_affinity" {
|
|
t.Fatalf("expected neighbor_affinity, got %s", confidence)
|
|
}
|
|
if score <= 0 {
|
|
t.Fatalf("expected positive score, got %f", score)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_AffinityTooClose_FallsToGeo(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1aaaaaa", Name: "Node-A1", HasGPS: true, Lat: 10, Lon: 20},
|
|
{Role: "repeater", PublicKey: "a1bbbbbb", Name: "Node-A2", HasGPS: true, Lat: 11, Lon: 21},
|
|
{Role: "repeater", PublicKey: "c0c0c0c0", Name: "Ctx", HasGPS: true, Lat: 10.1, Lon: 20.1},
|
|
})
|
|
|
|
graph := NewNeighborGraph()
|
|
for i := 0; i < 50; i++ {
|
|
graph.upsertEdge("c0c0c0c0", "a1aaaaaa", "a1", "obs1", nil, time.Now())
|
|
graph.upsertEdge("c0c0c0c0", "a1bbbbbb", "a1", "obs1", nil, time.Now())
|
|
}
|
|
|
|
ni, confidence, _ := pm.resolveWithContext("a1", []string{"c0c0c0c0"}, graph)
|
|
if ni == nil {
|
|
t.Fatal("expected a result")
|
|
}
|
|
if confidence != "geo_proximity" {
|
|
t.Fatalf("expected geo_proximity, got %s", confidence)
|
|
}
|
|
if ni.Name != "Node-A1" {
|
|
t.Fatalf("expected Node-A1 (closer to context), got %s", ni.Name)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_GPSPreference(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
|
{Role: "repeater", PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
|
|
})
|
|
|
|
ni, confidence, _ := pm.resolveWithContext("a1", nil, nil)
|
|
if ni == nil || ni.Name != "HasGPS" {
|
|
t.Fatalf("expected HasGPS, got %v", ni)
|
|
}
|
|
if confidence != "gps_preference" {
|
|
t.Fatalf("expected gps_preference, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_FirstMatchFallback(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1aaaaaa", Name: "First"},
|
|
{Role: "repeater", PublicKey: "a1bbbbbb", Name: "Second"},
|
|
})
|
|
|
|
ni, confidence, _ := pm.resolveWithContext("a1", nil, nil)
|
|
if ni == nil || ni.Name != "First" {
|
|
t.Fatalf("expected First, got %v", ni)
|
|
}
|
|
if confidence != "observation_count_fallback" {
|
|
t.Fatalf("expected observation_count_fallback, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_NilGraphFallsToGPS(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
|
{Role: "repeater", PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
|
|
})
|
|
|
|
ni, confidence, _ := pm.resolveWithContext("a1", []string{"someone"}, nil)
|
|
if ni == nil || ni.Name != "HasGPS" {
|
|
t.Fatalf("expected HasGPS, got %v", ni)
|
|
}
|
|
if confidence != "gps_preference" {
|
|
t.Fatalf("expected gps_preference, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_BackwardCompatResolve(t *testing.T) {
|
|
// Verify original resolve() still works unchanged
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
|
{Role: "repeater", PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
|
|
})
|
|
ni := pm.resolve("a1")
|
|
if ni == nil || ni.Name != "HasGPS" {
|
|
t.Fatalf("expected HasGPS from resolve(), got %v", ni)
|
|
}
|
|
}
|
|
|
|
// ─── geoDistApprox ─────────────────────────────────────────────────────────────
|
|
|
|
func TestGeoDistApprox_SamePoint(t *testing.T) {
|
|
d := geoDistApprox(37.0, -122.0, 37.0, -122.0)
|
|
if d != 0 {
|
|
t.Fatalf("expected 0, got %f", d)
|
|
}
|
|
}
|
|
|
|
func TestGeoDistApprox_Ordering(t *testing.T) {
|
|
d1 := geoDistApprox(37.0, -122.0, 37.01, -122.01)
|
|
d2 := geoDistApprox(37.0, -122.0, 38.0, -121.0)
|
|
if d1 >= d2 {
|
|
t.Fatal("closer point should have smaller distance")
|
|
}
|
|
}
|
|
|
|
// ─── handleResolveHops enhanced response (API tests) ───────────────────────────
|
|
|
|
func TestResolveHopsAPI_UniquePrefix(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
_ = srv
|
|
|
|
// Insert a unique node
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"ff11223344", "UniqueNode", 37.0, -122.0, "repeater")
|
|
srv.store.InvalidateNodeCache()
|
|
|
|
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ff11223344", nil)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
var result ResolveHopsResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("bad JSON: %v", err)
|
|
}
|
|
|
|
hr, ok := result.Resolved["ff11223344"]
|
|
if !ok {
|
|
t.Fatal("expected hop in resolved map")
|
|
}
|
|
if hr.Confidence != "unique_prefix" {
|
|
t.Fatalf("expected unique_prefix, got %s", hr.Confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveHopsAPI_AmbiguousNoContext(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"ee1aaaaaaa", "Node-E1", 37.0, -122.0, "repeater")
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"ee1bbbbbbb", "Node-E2", 38.0, -121.0, "repeater")
|
|
srv.store.InvalidateNodeCache()
|
|
|
|
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ee1", nil)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
var result ResolveHopsResponse
|
|
json.Unmarshal(rr.Body.Bytes(), &result)
|
|
|
|
hr := result.Resolved["ee1"]
|
|
if hr == nil {
|
|
t.Fatal("expected hop in resolved map")
|
|
}
|
|
// With both candidates having GPS and no affinity context, the resolver
|
|
// picks the GPS-preferred candidate → confidence is "gps_preference".
|
|
if hr.Confidence != "gps_preference" {
|
|
t.Fatalf("expected gps_preference, got %s", hr.Confidence)
|
|
}
|
|
if len(hr.Candidates) != 2 {
|
|
t.Fatalf("expected 2 candidates, got %d", len(hr.Candidates))
|
|
}
|
|
for _, c := range hr.Candidates {
|
|
if c.AffinityScore != nil {
|
|
t.Fatal("expected nil affinity score without context")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveHopsAPI_WithAffinityContext(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"dd1aaaaaaa", "Node-D1", 37.0, -122.0, "repeater")
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"dd1bbbbbbb", "Node-D2", 38.0, -121.0, "repeater")
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"c0c0c0c0c0", "Context", 37.1, -122.1, "repeater")
|
|
|
|
// Invalidate node cache so the PM includes newly inserted nodes.
|
|
srv.store.cacheMu.Lock()
|
|
srv.store.nodeCacheTime = time.Time{}
|
|
srv.store.cacheMu.Unlock()
|
|
|
|
// Build graph with strong affinity
|
|
graph := NewNeighborGraph()
|
|
for i := 0; i < 100; i++ {
|
|
graph.upsertEdge("c0c0c0c0c0", "dd1aaaaaaa", "dd1", "obs1", nil, time.Now())
|
|
}
|
|
graph.builtAt = time.Now()
|
|
srv.neighborMu.Lock()
|
|
srv.neighborGraph = graph
|
|
srv.neighborMu.Unlock()
|
|
|
|
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=dd1&from_node=c0c0c0c0c0", nil)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
var result ResolveHopsResponse
|
|
json.Unmarshal(rr.Body.Bytes(), &result)
|
|
|
|
hr := result.Resolved["dd1"]
|
|
if hr == nil {
|
|
t.Fatal("expected hop in resolved map")
|
|
}
|
|
if hr.Confidence != "neighbor_affinity" {
|
|
t.Fatalf("expected neighbor_affinity, got %s", hr.Confidence)
|
|
}
|
|
if hr.BestCandidate == nil || *hr.BestCandidate != "dd1aaaaaaa" {
|
|
t.Fatalf("expected bestCandidate dd1aaaaaaa, got %v", hr.BestCandidate)
|
|
}
|
|
|
|
// Verify affinity scores present
|
|
hasScore := false
|
|
for _, c := range hr.Candidates {
|
|
if c.AffinityScore != nil && *c.AffinityScore > 0 {
|
|
hasScore = true
|
|
}
|
|
}
|
|
if !hasScore {
|
|
t.Fatal("expected at least one candidate with affinity score")
|
|
}
|
|
}
|
|
|
|
func TestResolveHopsAPI_ResponseShape(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"bb1aaaaaaa", "Node-B1", 37.0, -122.0, "repeater")
|
|
|
|
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=bb1a", nil)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
var raw map[string]json.RawMessage
|
|
json.Unmarshal(rr.Body.Bytes(), &raw)
|
|
|
|
if _, ok := raw["resolved"]; !ok {
|
|
t.Fatal("missing 'resolved' key")
|
|
}
|
|
|
|
var resolved map[string]map[string]interface{}
|
|
json.Unmarshal(raw["resolved"], &resolved)
|
|
|
|
for _, hr := range resolved {
|
|
if _, ok := hr["confidence"]; !ok {
|
|
t.Error("missing 'confidence' field in HopResolution")
|
|
}
|
|
if _, ok := hr["candidates"]; !ok {
|
|
t.Error("missing 'candidates' field")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Helpers used only in this test file ───────────────────────────────────────
|