mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-11 22:29:53 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c970ae6e27 | ||
|
|
9c31338e3b |
@@ -1,361 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ─── Neighbor API response types ───────────────────────────────────────────────
|
||||
|
||||
type NeighborResponse struct {
|
||||
Node string `json:"node"`
|
||||
Neighbors []NeighborEntry `json:"neighbors"`
|
||||
TotalObservations int `json:"total_observations"`
|
||||
}
|
||||
|
||||
type NeighborEntry struct {
|
||||
Pubkey *string `json:"pubkey"`
|
||||
Prefix string `json:"prefix"`
|
||||
Name *string `json:"name"`
|
||||
Role *string `json:"role"`
|
||||
Count int `json:"count"`
|
||||
Score float64 `json:"score"`
|
||||
FirstSeen string `json:"first_seen"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
AvgSNR *float64 `json:"avg_snr"`
|
||||
Observers []string `json:"observers"`
|
||||
Ambiguous bool `json:"ambiguous"`
|
||||
Unresolved bool `json:"unresolved,omitempty"`
|
||||
Candidates []CandidateEntry `json:"candidates,omitempty"`
|
||||
}
|
||||
|
||||
type CandidateEntry struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type NeighborGraphResponse struct {
|
||||
Nodes []GraphNode `json:"nodes"`
|
||||
Edges []GraphEdge `json:"edges"`
|
||||
Stats GraphStats `json:"stats"`
|
||||
}
|
||||
|
||||
type GraphNode struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
NeighborCount int `json:"neighbor_count"`
|
||||
}
|
||||
|
||||
type GraphEdge struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Weight int `json:"weight"`
|
||||
Score float64 `json:"score"`
|
||||
Bidirectional bool `json:"bidirectional"`
|
||||
AvgSNR *float64 `json:"avg_snr"`
|
||||
Ambiguous bool `json:"ambiguous"`
|
||||
}
|
||||
|
||||
type GraphStats struct {
|
||||
TotalNodes int `json:"total_nodes"`
|
||||
TotalEdges int `json:"total_edges"`
|
||||
AmbiguousEdges int `json:"ambiguous_edges"`
|
||||
AvgClusterSize float64 `json:"avg_cluster_size"`
|
||||
}
|
||||
|
||||
// ─── Graph accessor on Server ──────────────────────────────────────────────────
|
||||
|
||||
// getNeighborGraph returns the current neighbor graph, rebuilding if stale.
|
||||
func (s *Server) getNeighborGraph() *NeighborGraph {
|
||||
s.neighborMu.Lock()
|
||||
defer s.neighborMu.Unlock()
|
||||
|
||||
if s.neighborGraph == nil || s.neighborGraph.IsStale() {
|
||||
if s.store != nil {
|
||||
s.neighborGraph = BuildFromStore(s.store)
|
||||
} else {
|
||||
s.neighborGraph = NewNeighborGraph()
|
||||
}
|
||||
}
|
||||
return s.neighborGraph
|
||||
}
|
||||
|
||||
// ─── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) handleNodeNeighbors(w http.ResponseWriter, r *http.Request) {
|
||||
pubkey := strings.ToLower(mux.Vars(r)["pubkey"])
|
||||
|
||||
minCount := 1
|
||||
if v := r.URL.Query().Get("min_count"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
minCount = n
|
||||
}
|
||||
}
|
||||
minScore := 0.0
|
||||
if v := r.URL.Query().Get("min_score"); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
minScore = f
|
||||
}
|
||||
}
|
||||
includeAmbiguous := true
|
||||
if v := r.URL.Query().Get("include_ambiguous"); v == "false" {
|
||||
includeAmbiguous = false
|
||||
}
|
||||
|
||||
graph := s.getNeighborGraph()
|
||||
edges := graph.Neighbors(pubkey)
|
||||
now := time.Now()
|
||||
|
||||
// Build node info lookup for names/roles.
|
||||
nodeMap := s.buildNodeInfoMap()
|
||||
|
||||
var entries []NeighborEntry
|
||||
totalObs := 0
|
||||
|
||||
for _, e := range edges {
|
||||
score := e.Score(now)
|
||||
if e.Count < minCount || score < minScore {
|
||||
continue
|
||||
}
|
||||
if e.Ambiguous && !includeAmbiguous {
|
||||
continue
|
||||
}
|
||||
|
||||
totalObs += e.Count
|
||||
|
||||
// Determine the "other" node (neighbor of the queried pubkey).
|
||||
neighborPK := e.NodeA
|
||||
if strings.EqualFold(neighborPK, pubkey) {
|
||||
neighborPK = e.NodeB
|
||||
}
|
||||
|
||||
entry := NeighborEntry{
|
||||
Prefix: e.Prefix,
|
||||
Count: e.Count,
|
||||
Score: score,
|
||||
FirstSeen: e.FirstSeen.UTC().Format(time.RFC3339),
|
||||
LastSeen: e.LastSeen.UTC().Format(time.RFC3339),
|
||||
Ambiguous: e.Ambiguous,
|
||||
Observers: observerList(e.Observers),
|
||||
}
|
||||
|
||||
if e.SNRCount > 0 {
|
||||
avg := e.AvgSNR()
|
||||
entry.AvgSNR = &avg
|
||||
}
|
||||
|
||||
if e.Ambiguous {
|
||||
if len(e.Candidates) == 0 {
|
||||
entry.Unresolved = true
|
||||
}
|
||||
for _, cpk := range e.Candidates {
|
||||
ce := CandidateEntry{Pubkey: cpk}
|
||||
if info, ok := nodeMap[strings.ToLower(cpk)]; ok {
|
||||
ce.Name = info.Name
|
||||
ce.Role = info.Role
|
||||
}
|
||||
entry.Candidates = append(entry.Candidates, ce)
|
||||
}
|
||||
} else if neighborPK != "" {
|
||||
entry.Pubkey = &neighborPK
|
||||
if info, ok := nodeMap[strings.ToLower(neighborPK)]; ok {
|
||||
entry.Name = &info.Name
|
||||
entry.Role = &info.Role
|
||||
}
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
// Sort by score descending.
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Score > entries[j].Score
|
||||
})
|
||||
|
||||
if entries == nil {
|
||||
entries = []NeighborEntry{}
|
||||
}
|
||||
|
||||
resp := NeighborResponse{
|
||||
Node: pubkey,
|
||||
Neighbors: entries,
|
||||
TotalObservations: totalObs,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleNeighborGraph(w http.ResponseWriter, r *http.Request) {
|
||||
minCount := 5
|
||||
if v := r.URL.Query().Get("min_count"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
minCount = n
|
||||
}
|
||||
}
|
||||
minScore := 0.1
|
||||
if v := r.URL.Query().Get("min_score"); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
minScore = f
|
||||
}
|
||||
}
|
||||
region := r.URL.Query().Get("region")
|
||||
roleFilter := strings.ToLower(r.URL.Query().Get("role"))
|
||||
|
||||
graph := s.getNeighborGraph()
|
||||
allEdges := graph.AllEdges()
|
||||
now := time.Now()
|
||||
|
||||
// Resolve region observers if filtering.
|
||||
var regionObs map[string]bool
|
||||
if region != "" && s.store != nil {
|
||||
regionObs = s.store.resolveRegionObservers(region)
|
||||
}
|
||||
|
||||
nodeMap := s.buildNodeInfoMap()
|
||||
nodeSet := make(map[string]bool)
|
||||
var filteredEdges []GraphEdge
|
||||
ambiguousCount := 0
|
||||
|
||||
for _, e := range allEdges {
|
||||
score := e.Score(now)
|
||||
if e.Count < minCount || score < minScore {
|
||||
continue
|
||||
}
|
||||
|
||||
// Role filter: at least one endpoint must match the role.
|
||||
if roleFilter != "" && nodeMap != nil {
|
||||
aInfo, aOK := nodeMap[strings.ToLower(e.NodeA)]
|
||||
bInfo, bOK := nodeMap[strings.ToLower(e.NodeB)]
|
||||
aMatch := aOK && strings.EqualFold(aInfo.Role, roleFilter)
|
||||
bMatch := bOK && strings.EqualFold(bInfo.Role, roleFilter)
|
||||
if !aMatch && !bMatch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Region filter: at least one observer must be in the region.
|
||||
if regionObs != nil {
|
||||
match := false
|
||||
for obs := range e.Observers {
|
||||
if regionObs[obs] {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
ge := GraphEdge{
|
||||
Source: e.NodeA,
|
||||
Target: e.NodeB,
|
||||
Weight: e.Count,
|
||||
Score: score,
|
||||
Bidirectional: true,
|
||||
Ambiguous: e.Ambiguous,
|
||||
}
|
||||
if e.SNRCount > 0 {
|
||||
avg := e.AvgSNR()
|
||||
ge.AvgSNR = &avg
|
||||
}
|
||||
|
||||
if e.Ambiguous {
|
||||
ambiguousCount++
|
||||
// For ambiguous edges, use prefix as target.
|
||||
if e.NodeB == "" {
|
||||
ge.Target = "prefix:" + e.Prefix
|
||||
}
|
||||
}
|
||||
|
||||
filteredEdges = append(filteredEdges, ge)
|
||||
|
||||
// Track nodes.
|
||||
if e.NodeA != "" && !strings.HasPrefix(e.NodeA, "prefix:") {
|
||||
nodeSet[e.NodeA] = true
|
||||
}
|
||||
if e.NodeB != "" && !strings.HasPrefix(e.NodeB, "prefix:") {
|
||||
nodeSet[e.NodeB] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Build node list.
|
||||
// Count neighbors per node from filtered edges.
|
||||
neighborCounts := make(map[string]int)
|
||||
for _, ge := range filteredEdges {
|
||||
neighborCounts[ge.Source]++
|
||||
neighborCounts[ge.Target]++
|
||||
}
|
||||
|
||||
var nodes []GraphNode
|
||||
for pk := range nodeSet {
|
||||
gn := GraphNode{Pubkey: pk, NeighborCount: neighborCounts[pk]}
|
||||
if info, ok := nodeMap[strings.ToLower(pk)]; ok {
|
||||
gn.Name = info.Name
|
||||
gn.Role = info.Role
|
||||
}
|
||||
nodes = append(nodes, gn)
|
||||
}
|
||||
|
||||
if filteredEdges == nil {
|
||||
filteredEdges = []GraphEdge{}
|
||||
}
|
||||
if nodes == nil {
|
||||
nodes = []GraphNode{}
|
||||
}
|
||||
|
||||
avgCluster := 0.0
|
||||
if len(nodes) > 0 {
|
||||
avgCluster = float64(len(filteredEdges)*2) / float64(len(nodes))
|
||||
}
|
||||
|
||||
resp := NeighborGraphResponse{
|
||||
Nodes: nodes,
|
||||
Edges: filteredEdges,
|
||||
Stats: GraphStats{
|
||||
TotalNodes: len(nodes),
|
||||
TotalEdges: len(filteredEdges),
|
||||
AmbiguousEdges: ambiguousCount,
|
||||
AvgClusterSize: avgCluster,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func observerList(m map[string]bool) []string {
|
||||
if len(m) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// buildNodeInfoMap returns a map of lowercase pubkey → nodeInfo for name/role lookups.
|
||||
func (s *Server) buildNodeInfoMap() map[string]nodeInfo {
|
||||
if s.store == nil {
|
||||
return nil
|
||||
}
|
||||
nodes, _ := s.store.getCachedNodesAndPM()
|
||||
m := make(map[string]nodeInfo, len(nodes))
|
||||
for _, n := range nodes {
|
||||
m[strings.ToLower(n.PublicKey)] = n
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// makeTestServer creates a Server with a pre-built neighbor graph for testing.
|
||||
func makeTestServer(graph *NeighborGraph) *Server {
|
||||
srv := &Server{
|
||||
perfStats: NewPerfStats(),
|
||||
}
|
||||
srv.neighborGraph = graph
|
||||
return srv
|
||||
}
|
||||
|
||||
// makeTestGraph creates a graph with given edges for testing.
|
||||
func makeTestGraph(edges ...*NeighborEdge) *NeighborGraph {
|
||||
g := NewNeighborGraph()
|
||||
g.mu.Lock()
|
||||
for _, e := range edges {
|
||||
key := makeEdgeKey(e.NodeA, e.NodeB)
|
||||
if e.NodeB == "" {
|
||||
key = makeEdgeKey(e.NodeA, "prefix:"+e.Prefix)
|
||||
}
|
||||
e.NodeA = key.A
|
||||
if e.NodeB != "" {
|
||||
e.NodeB = key.B
|
||||
}
|
||||
g.edges[key] = e
|
||||
g.byNode[key.A] = append(g.byNode[key.A], e)
|
||||
if key.B != "" && key.B != key.A {
|
||||
g.byNode[key.B] = append(g.byNode[key.B], e)
|
||||
}
|
||||
}
|
||||
g.builtAt = time.Now()
|
||||
g.mu.Unlock()
|
||||
return g
|
||||
}
|
||||
|
||||
func newEdge(a, b, prefix string, count int, lastSeen time.Time) *NeighborEdge {
|
||||
return &NeighborEdge{
|
||||
NodeA: a,
|
||||
NodeB: b,
|
||||
Prefix: prefix,
|
||||
Count: count,
|
||||
FirstSeen: lastSeen.Add(-24 * time.Hour),
|
||||
LastSeen: lastSeen,
|
||||
Observers: map[string]bool{"obs1": true},
|
||||
SNRSum: -8.0,
|
||||
SNRCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func newAmbiguousEdge(knownPK, prefix string, candidates []string, count int, lastSeen time.Time) *NeighborEdge {
|
||||
return &NeighborEdge{
|
||||
NodeA: knownPK,
|
||||
NodeB: "",
|
||||
Prefix: prefix,
|
||||
Count: count,
|
||||
FirstSeen: lastSeen.Add(-24 * time.Hour),
|
||||
LastSeen: lastSeen,
|
||||
Observers: map[string]bool{"obs1": true},
|
||||
Ambiguous: true,
|
||||
Candidates: candidates,
|
||||
}
|
||||
}
|
||||
|
||||
func serveRequest(srv *Server, method, path string) *httptest.ResponseRecorder {
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/api/nodes/{pubkey}/neighbors", srv.handleNodeNeighbors).Methods("GET")
|
||||
router.HandleFunc("/api/analytics/neighbor-graph", srv.handleNeighborGraph).Methods("GET")
|
||||
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// ─── Tests: /api/nodes/{pubkey}/neighbors ──────────────────────────────────────
|
||||
|
||||
func TestNeighborAPI_EmptyGraph(t *testing.T) {
|
||||
srv := makeTestServer(makeTestGraph())
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/deadbeef/neighbors")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp NeighborResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("bad JSON: %v", err)
|
||||
}
|
||||
if resp.Node != "deadbeef" {
|
||||
t.Errorf("node = %q, want deadbeef", resp.Node)
|
||||
}
|
||||
if len(resp.Neighbors) != 0 {
|
||||
t.Errorf("expected 0 neighbors, got %d", len(resp.Neighbors))
|
||||
}
|
||||
if resp.TotalObservations != 0 {
|
||||
t.Errorf("expected 0 observations, got %d", resp.TotalObservations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_SingleNeighbor(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newEdge("aaaa", "bbbb", "bb", 50, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
|
||||
}
|
||||
n := resp.Neighbors[0]
|
||||
if n.Pubkey == nil || *n.Pubkey != "bbbb" {
|
||||
t.Errorf("expected pubkey bbbb, got %v", n.Pubkey)
|
||||
}
|
||||
if n.Count != 50 {
|
||||
t.Errorf("expected count 50, got %d", n.Count)
|
||||
}
|
||||
if n.Score <= 0 {
|
||||
t.Errorf("expected positive score, got %f", n.Score)
|
||||
}
|
||||
if n.Ambiguous {
|
||||
t.Error("expected not ambiguous")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_MultipleNeighbors(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
e2 := newEdge("aaaa", "cccc", "cc", 10, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 2 {
|
||||
t.Fatalf("expected 2 neighbors, got %d", len(resp.Neighbors))
|
||||
}
|
||||
// Should be sorted by score descending.
|
||||
if resp.Neighbors[0].Score < resp.Neighbors[1].Score {
|
||||
t.Error("expected sorted by score descending")
|
||||
}
|
||||
if resp.TotalObservations != 110 {
|
||||
t.Errorf("expected 110 total observations, got %d", resp.TotalObservations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_AmbiguousCandidates(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newAmbiguousEdge("aaaa", "c0", []string{"c0de01", "c0de02"}, 12, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
|
||||
}
|
||||
n := resp.Neighbors[0]
|
||||
if !n.Ambiguous {
|
||||
t.Error("expected ambiguous")
|
||||
}
|
||||
if n.Pubkey != nil {
|
||||
t.Errorf("expected nil pubkey for ambiguous, got %v", n.Pubkey)
|
||||
}
|
||||
if len(n.Candidates) != 2 {
|
||||
t.Fatalf("expected 2 candidates, got %d", len(n.Candidates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_UnresolvedPrefix(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newAmbiguousEdge("aaaa", "ff", []string{}, 3, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
|
||||
}
|
||||
n := resp.Neighbors[0]
|
||||
if !n.Unresolved {
|
||||
t.Error("expected unresolved=true")
|
||||
}
|
||||
if len(n.Candidates) != 0 {
|
||||
t.Error("expected empty candidates for unresolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_MinCountFilter(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
e2 := newEdge("aaaa", "cccc", "cc", 2, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?min_count=10")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor after min_count filter, got %d", len(resp.Neighbors))
|
||||
}
|
||||
if *resp.Neighbors[0].Pubkey != "bbbb" {
|
||||
t.Error("expected bbbb to survive filter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_MinScoreFilter(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now) // score ~1.0
|
||||
e2 := newEdge("aaaa", "cccc", "cc", 1, now.Add(-30*24*time.Hour)) // very low score
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?min_score=0.5")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor after min_score filter, got %d", len(resp.Neighbors))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_ExcludeAmbiguous(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 50, now)
|
||||
e2 := newAmbiguousEdge("aaaa", "c0", []string{"c0de01"}, 10, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?include_ambiguous=false")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 non-ambiguous neighbor, got %d", len(resp.Neighbors))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_UnknownNode(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newEdge("aaaa", "bbbb", "bb", 50, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/unknown1234/neighbors")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for unknown node, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
if len(resp.Neighbors) != 0 {
|
||||
t.Errorf("expected 0 neighbors for unknown node, got %d", len(resp.Neighbors))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests: /api/analytics/neighbor-graph ──────────────────────────────────────
|
||||
|
||||
func TestNeighborGraphAPI_EmptyGraph(t *testing.T) {
|
||||
srv := makeTestServer(makeTestGraph())
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Edges) != 0 {
|
||||
t.Errorf("expected 0 edges, got %d", len(resp.Edges))
|
||||
}
|
||||
if resp.Stats.TotalEdges != 0 {
|
||||
t.Errorf("expected 0 total edges, got %d", resp.Stats.TotalEdges)
|
||||
}
|
||||
if resp.Stats.TotalNodes != 0 {
|
||||
t.Errorf("expected 0 total nodes, got %d", resp.Stats.TotalNodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_WithEdges(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
e2 := newEdge("bbbb", "cccc", "cc", 50, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Edges) != 2 {
|
||||
t.Fatalf("expected 2 edges, got %d", len(resp.Edges))
|
||||
}
|
||||
if resp.Stats.TotalNodes != 3 {
|
||||
t.Errorf("expected 3 nodes, got %d", resp.Stats.TotalNodes)
|
||||
}
|
||||
if resp.Stats.TotalEdges != 2 {
|
||||
t.Errorf("expected 2 total edges, got %d", resp.Stats.TotalEdges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_MinCountDefault(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now) // passes default min_count=5
|
||||
e2 := newEdge("aaaa", "cccc", "cc", 2, now) // fails default min_count=5
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph")
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Edges) != 1 {
|
||||
t.Fatalf("expected 1 edge with default min_count=5, got %d", len(resp.Edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_AmbiguousEdgesCount(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
e2 := newAmbiguousEdge("aaaa", "c0", []string{"c0de01", "c0de02"}, 50, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if resp.Stats.AmbiguousEdges != 1 {
|
||||
t.Errorf("expected 1 ambiguous edge, got %d", resp.Stats.AmbiguousEdges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_RegionFilter(t *testing.T) {
|
||||
now := time.Now()
|
||||
// Edge with observer "obs-sjc" — would match region SJC if we had region resolution.
|
||||
// Without a store, region filtering returns nothing (no observers match).
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
srv := makeTestServer(makeTestGraph(e1))
|
||||
// No store → region filter has no observers → filters everything out.
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?region=SJC&min_count=1&min_score=0")
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
// With no store, regionObs is nil so filter is skipped → all edges returned.
|
||||
// Actually: region="" when store is nil → regionObs stays nil → no filtering.
|
||||
// Wait, we set region=SJC and store is nil → resolveRegionObservers won't be called
|
||||
// because s.store is nil. So regionObs is nil → filter not applied.
|
||||
// Let's just check it doesn't crash.
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_ResponseShape(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("bad JSON: %v", err)
|
||||
}
|
||||
|
||||
// Verify top-level keys.
|
||||
for _, key := range []string{"nodes", "edges", "stats"} {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Errorf("missing key %q in response", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify stats keys.
|
||||
stats := raw["stats"].(map[string]interface{})
|
||||
for _, key := range []string{"total_nodes", "total_edges", "ambiguous_edges", "avg_cluster_size"} {
|
||||
if _, ok := stats[key]; !ok {
|
||||
t.Errorf("missing stats key %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,500 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// After this many observations, count contributes max weight to the score.
|
||||
affinitySaturationCount = 100
|
||||
// Time-decay half-life: 7 days.
|
||||
affinityHalfLifeHours = 168.0
|
||||
// Cache TTL for the built graph.
|
||||
neighborGraphTTL = 60 * time.Second
|
||||
// Auto-resolve confidence: best must be >= this factor × second-best.
|
||||
affinityConfidenceRatio = 3.0
|
||||
// Minimum observation count to auto-resolve.
|
||||
affinityMinObservations = 3
|
||||
)
|
||||
|
||||
// affinityLambda = ln(2) / half-life-hours, precomputed.
|
||||
var affinityLambda = math.Ln2 / affinityHalfLifeHours
|
||||
|
||||
// ─── Data model ────────────────────────────────────────────────────────────────
|
||||
|
||||
// edgeKey is the canonical key for an undirected edge (A < B lexicographically).
|
||||
// For ambiguous edges where NodeB is unknown, B is the raw prefix prefixed with "prefix:".
|
||||
type edgeKey struct {
|
||||
A, B string
|
||||
}
|
||||
|
||||
func makeEdgeKey(a, b string) edgeKey {
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
return edgeKey{A: a, B: b}
|
||||
}
|
||||
|
||||
// NeighborEdge represents a weighted, undirected first-hop neighbor relationship.
|
||||
type NeighborEdge struct {
|
||||
NodeA string // full pubkey
|
||||
NodeB string // full pubkey, or "" if unresolved/ambiguous
|
||||
Prefix string // raw hop prefix that established this edge
|
||||
Count int // total observations
|
||||
FirstSeen time.Time //
|
||||
LastSeen time.Time //
|
||||
SNRSum float64 // running sum for average
|
||||
SNRCount int // how many SNR samples
|
||||
Observers map[string]bool // observer pubkeys that witnessed
|
||||
Ambiguous bool // multiple candidates or zero candidates
|
||||
Candidates []string // candidate pubkeys when ambiguous
|
||||
Resolved bool // true if auto-resolved via Jaccard
|
||||
}
|
||||
|
||||
// Score computes the affinity score at query time with time decay.
|
||||
func (e *NeighborEdge) Score(now time.Time) float64 {
|
||||
countFactor := math.Min(1.0, float64(e.Count)/float64(affinitySaturationCount))
|
||||
hoursSince := now.Sub(e.LastSeen).Hours()
|
||||
if hoursSince < 0 {
|
||||
hoursSince = 0
|
||||
}
|
||||
decay := math.Exp(-affinityLambda * hoursSince)
|
||||
return countFactor * decay
|
||||
}
|
||||
|
||||
// AvgSNR returns the average SNR, or 0 if no samples.
|
||||
func (e *NeighborEdge) AvgSNR() float64 {
|
||||
if e.SNRCount == 0 {
|
||||
return 0
|
||||
}
|
||||
return e.SNRSum / float64(e.SNRCount)
|
||||
}
|
||||
|
||||
// ─── NeighborGraph ─────────────────────────────────────────────────────────────
|
||||
|
||||
// NeighborGraph is a cached, in-memory first-hop neighbor affinity graph.
|
||||
type NeighborGraph struct {
|
||||
mu sync.RWMutex
|
||||
edges map[edgeKey]*NeighborEdge
|
||||
byNode map[string][]*NeighborEdge // pubkey → edges involving this node
|
||||
builtAt time.Time
|
||||
}
|
||||
|
||||
// NewNeighborGraph creates an empty graph.
|
||||
func NewNeighborGraph() *NeighborGraph {
|
||||
return &NeighborGraph{
|
||||
edges: make(map[edgeKey]*NeighborEdge),
|
||||
byNode: make(map[string][]*NeighborEdge),
|
||||
}
|
||||
}
|
||||
|
||||
// Neighbors returns all edges for a given node pubkey.
|
||||
func (g *NeighborGraph) Neighbors(pubkey string) []*NeighborEdge {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.byNode[strings.ToLower(pubkey)]
|
||||
}
|
||||
|
||||
// AllEdges returns all edges in the graph.
|
||||
func (g *NeighborGraph) AllEdges() []*NeighborEdge {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
out := make([]*NeighborEdge, 0, len(g.edges))
|
||||
for _, e := range g.edges {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsStale returns true if the graph cache has expired.
|
||||
func (g *NeighborGraph) IsStale() bool {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.builtAt.IsZero() || time.Since(g.builtAt) > neighborGraphTTL
|
||||
}
|
||||
|
||||
// ─── Builder ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// BuildFromStore constructs the neighbor graph from all packets in the store.
|
||||
// The store's read-lock must NOT be held by the caller.
|
||||
func BuildFromStore(store *PacketStore) *NeighborGraph {
|
||||
g := NewNeighborGraph()
|
||||
|
||||
store.mu.RLock()
|
||||
// Snapshot what we need under lock.
|
||||
packets := make([]*StoreTx, len(store.packets))
|
||||
copy(packets, store.packets)
|
||||
store.mu.RUnlock()
|
||||
|
||||
// Build prefix map for candidate resolution.
|
||||
// Use cached nodes+PM (avoids DB call if cache is fresh).
|
||||
_, pm := store.getCachedNodesAndPM()
|
||||
|
||||
// Phase 1: Extract edges from every transmission + observation.
|
||||
for _, tx := range packets {
|
||||
isAdvert := tx.PayloadType != nil && *tx.PayloadType == 4
|
||||
fromNode := "" // originator pubkey (from byNode index key)
|
||||
// Find the originator pubkey — it's the key in store.byNode.
|
||||
// StoreTx doesn't store from_node directly; we find it via decoded JSON
|
||||
// or the byNode index. However, iterating byNode is expensive.
|
||||
// The originator pubkey is in the decoded JSON "from_node" field,
|
||||
// but parsing JSON per tx is expensive too.
|
||||
// Actually, let's look at how byNode is keyed.
|
||||
// Looking at store.go, byNode maps pubkey → transmissions where that
|
||||
// pubkey is the "from" node. We need the reverse: tx → from_node.
|
||||
// The from_node is embedded in DecodedJSON.
|
||||
// For efficiency, let's extract it once.
|
||||
fromNode = extractFromNode(tx)
|
||||
|
||||
for _, obs := range tx.Observations {
|
||||
path := parsePathJSON(obs.PathJSON)
|
||||
observerPK := strings.ToLower(obs.ObserverID)
|
||||
|
||||
if len(path) == 0 {
|
||||
// Zero-hop
|
||||
if isAdvert && fromNode != "" {
|
||||
fromLower := strings.ToLower(fromNode)
|
||||
if fromLower != observerPK { // self-edge guard
|
||||
g.upsertEdge(fromLower, observerPK, "", observerPK, obs.SNR, parseTimestamp(obs.Timestamp))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Edge 1: originator ↔ path[0] — ADVERTs only
|
||||
if isAdvert && fromNode != "" {
|
||||
firstHop := strings.ToLower(path[0])
|
||||
fromLower := strings.ToLower(fromNode)
|
||||
if fromLower != firstHop { // self-edge guard (shouldn't happen but spec says check)
|
||||
candidates := pm.m[firstHop]
|
||||
g.upsertEdgeWithCandidates(fromLower, firstHop, candidates, observerPK, obs.SNR, parseTimestamp(obs.Timestamp))
|
||||
}
|
||||
}
|
||||
|
||||
// Edge 2: observer ↔ path[last] — ALL packet types
|
||||
lastHop := strings.ToLower(path[len(path)-1])
|
||||
if observerPK != lastHop { // self-edge guard
|
||||
candidates := pm.m[lastHop]
|
||||
g.upsertEdgeWithCandidates(observerPK, lastHop, candidates, observerPK, obs.SNR, parseTimestamp(obs.Timestamp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Disambiguation via Jaccard similarity.
|
||||
g.disambiguate()
|
||||
|
||||
g.mu.Lock()
|
||||
g.builtAt = time.Now()
|
||||
g.mu.Unlock()
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// extractFromNode pulls the from_node pubkey from a StoreTx.
|
||||
// It looks in DecodedJSON for "from_node" or "from".
|
||||
func extractFromNode(tx *StoreTx) string {
|
||||
if tx.DecodedJSON == "" {
|
||||
return ""
|
||||
}
|
||||
// Fast path: look for "from_node" key.
|
||||
var decoded map[string]interface{}
|
||||
if err := jsonUnmarshalFast(tx.DecodedJSON, &decoded); err != nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := decoded["from_node"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if v, ok := decoded["from"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// jsonUnmarshalFast is a thin wrapper; could be optimized later.
|
||||
func jsonUnmarshalFast(data string, v interface{}) error {
|
||||
return json.Unmarshal([]byte(data), v)
|
||||
}
|
||||
|
||||
// upsertEdge adds/updates an edge between two fully-known pubkeys.
|
||||
func (g *NeighborGraph) upsertEdge(pubkeyA, pubkeyB, prefix, observer string, snr *float64, ts time.Time) {
|
||||
key := makeEdgeKey(pubkeyA, pubkeyB)
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
e, exists := g.edges[key]
|
||||
if !exists {
|
||||
e = &NeighborEdge{
|
||||
NodeA: key.A,
|
||||
NodeB: key.B,
|
||||
Prefix: prefix,
|
||||
Observers: make(map[string]bool),
|
||||
FirstSeen: ts,
|
||||
LastSeen: ts,
|
||||
}
|
||||
g.edges[key] = e
|
||||
g.byNode[key.A] = append(g.byNode[key.A], e)
|
||||
g.byNode[key.B] = append(g.byNode[key.B], e)
|
||||
}
|
||||
|
||||
e.Count++
|
||||
if ts.After(e.LastSeen) {
|
||||
e.LastSeen = ts
|
||||
}
|
||||
if ts.Before(e.FirstSeen) {
|
||||
e.FirstSeen = ts
|
||||
}
|
||||
if snr != nil {
|
||||
e.SNRSum += *snr
|
||||
e.SNRCount++
|
||||
}
|
||||
if observer != "" {
|
||||
e.Observers[observer] = true
|
||||
}
|
||||
}
|
||||
|
||||
// upsertEdgeWithCandidates handles prefix-based edges that may be ambiguous.
|
||||
func (g *NeighborGraph) upsertEdgeWithCandidates(knownPK, prefix string, candidates []nodeInfo, observer string, snr *float64, ts time.Time) {
|
||||
if len(candidates) == 1 {
|
||||
resolved := strings.ToLower(candidates[0].PublicKey)
|
||||
if resolved == knownPK {
|
||||
return // self-edge guard
|
||||
}
|
||||
g.upsertEdge(knownPK, resolved, prefix, observer, snr, ts)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out self from candidates
|
||||
filtered := make([]string, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
pk := strings.ToLower(c.PublicKey)
|
||||
if pk != knownPK {
|
||||
filtered = append(filtered, pk)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 1 {
|
||||
g.upsertEdge(knownPK, filtered[0], prefix, observer, snr, ts)
|
||||
return
|
||||
}
|
||||
|
||||
// Ambiguous or orphan: use prefix-based key
|
||||
pseudoB := "prefix:" + prefix
|
||||
key := makeEdgeKey(knownPK, pseudoB)
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
e, exists := g.edges[key]
|
||||
if !exists {
|
||||
e = &NeighborEdge{
|
||||
NodeA: key.A,
|
||||
NodeB: "",
|
||||
Prefix: prefix,
|
||||
Observers: make(map[string]bool),
|
||||
Ambiguous: true,
|
||||
Candidates: filtered,
|
||||
FirstSeen: ts,
|
||||
LastSeen: ts,
|
||||
}
|
||||
g.edges[key] = e
|
||||
g.byNode[knownPK] = append(g.byNode[knownPK], e)
|
||||
}
|
||||
|
||||
e.Count++
|
||||
if ts.After(e.LastSeen) {
|
||||
e.LastSeen = ts
|
||||
}
|
||||
if ts.Before(e.FirstSeen) {
|
||||
e.FirstSeen = ts
|
||||
}
|
||||
if snr != nil {
|
||||
e.SNRSum += *snr
|
||||
e.SNRCount++
|
||||
}
|
||||
if observer != "" {
|
||||
e.Observers[observer] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Disambiguation ────────────────────────────────────────────────────────────
|
||||
|
||||
// disambiguate resolves ambiguous edges using Jaccard similarity of neighbor sets.
|
||||
// Only fully-resolved edges are used as evidence (transitivity poisoning guard).
|
||||
func (g *NeighborGraph) disambiguate() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
// Build resolved neighbor sets: for each node, collect the set of nodes
|
||||
// it has fully-resolved (non-ambiguous) edges with.
|
||||
resolvedNeighbors := make(map[string]map[string]bool)
|
||||
for _, e := range g.edges {
|
||||
if e.Ambiguous || e.NodeB == "" {
|
||||
continue
|
||||
}
|
||||
if resolvedNeighbors[e.NodeA] == nil {
|
||||
resolvedNeighbors[e.NodeA] = make(map[string]bool)
|
||||
}
|
||||
if resolvedNeighbors[e.NodeB] == nil {
|
||||
resolvedNeighbors[e.NodeB] = make(map[string]bool)
|
||||
}
|
||||
resolvedNeighbors[e.NodeA][e.NodeB] = true
|
||||
resolvedNeighbors[e.NodeB][e.NodeA] = true
|
||||
}
|
||||
|
||||
// Try to resolve each ambiguous edge.
|
||||
for key, e := range g.edges {
|
||||
if !e.Ambiguous || len(e.Candidates) < 2 {
|
||||
continue
|
||||
}
|
||||
if e.Count < affinityMinObservations {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine the known node (the one that's a real pubkey, not the prefix side).
|
||||
knownNode := e.NodeA
|
||||
if strings.HasPrefix(e.NodeA, "prefix:") {
|
||||
knownNode = e.NodeB
|
||||
}
|
||||
// If knownNode is empty (shouldn't happen for ambiguous edges with candidates), skip.
|
||||
if knownNode == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
knownNeighbors := resolvedNeighbors[knownNode]
|
||||
|
||||
type scored struct {
|
||||
pubkey string
|
||||
jaccard float64
|
||||
}
|
||||
var scores []scored
|
||||
|
||||
for _, cand := range e.Candidates {
|
||||
candNeighbors := resolvedNeighbors[cand]
|
||||
j := jaccardSimilarity(knownNeighbors, candNeighbors)
|
||||
scores = append(scores, scored{cand, j})
|
||||
}
|
||||
|
||||
if len(scores) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find best and second-best.
|
||||
best, secondBest := scores[0], scores[1]
|
||||
if secondBest.jaccard > best.jaccard {
|
||||
best, secondBest = secondBest, best
|
||||
}
|
||||
for i := 2; i < len(scores); i++ {
|
||||
if scores[i].jaccard > best.jaccard {
|
||||
secondBest = best
|
||||
best = scores[i]
|
||||
} else if scores[i].jaccard > secondBest.jaccard {
|
||||
secondBest = scores[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-resolve only if best >= 3× second-best AND enough observations.
|
||||
if secondBest.jaccard == 0 {
|
||||
// If second-best is 0 and best > 0, ratio is infinite → resolve.
|
||||
if best.jaccard > 0 {
|
||||
g.resolveEdge(key, e, knownNode, best.pubkey)
|
||||
}
|
||||
} else if best.jaccard/secondBest.jaccard >= affinityConfidenceRatio {
|
||||
g.resolveEdge(key, e, knownNode, best.pubkey)
|
||||
}
|
||||
// Otherwise remain ambiguous.
|
||||
}
|
||||
}
|
||||
|
||||
// resolveEdge converts an ambiguous edge to a resolved one.
|
||||
// Must be called with g.mu held.
|
||||
func (g *NeighborGraph) resolveEdge(oldKey edgeKey, e *NeighborEdge, knownNode, resolvedPK string) {
|
||||
// Remove old edge.
|
||||
delete(g.edges, oldKey)
|
||||
g.removeFromByNode(oldKey.A, e)
|
||||
g.removeFromByNode(oldKey.B, e)
|
||||
|
||||
// Update edge.
|
||||
newKey := makeEdgeKey(knownNode, resolvedPK)
|
||||
e.NodeA = newKey.A
|
||||
e.NodeB = newKey.B
|
||||
e.Ambiguous = false
|
||||
e.Resolved = true
|
||||
|
||||
// Merge with existing edge if any.
|
||||
if existing, ok := g.edges[newKey]; ok {
|
||||
existing.Count += e.Count
|
||||
if e.LastSeen.After(existing.LastSeen) {
|
||||
existing.LastSeen = e.LastSeen
|
||||
}
|
||||
if e.FirstSeen.Before(existing.FirstSeen) {
|
||||
existing.FirstSeen = e.FirstSeen
|
||||
}
|
||||
existing.SNRSum += e.SNRSum
|
||||
existing.SNRCount += e.SNRCount
|
||||
for obs := range e.Observers {
|
||||
existing.Observers[obs] = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
g.edges[newKey] = e
|
||||
g.byNode[newKey.A] = append(g.byNode[newKey.A], e)
|
||||
g.byNode[newKey.B] = append(g.byNode[newKey.B], e)
|
||||
}
|
||||
|
||||
// removeFromByNode removes an edge from the byNode index for the given key.
|
||||
func (g *NeighborGraph) removeFromByNode(nodeKey string, edge *NeighborEdge) {
|
||||
edges := g.byNode[nodeKey]
|
||||
for i, e := range edges {
|
||||
if e == edge {
|
||||
g.byNode[nodeKey] = append(edges[:i], edges[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jaccardSimilarity computes |A ∩ B| / |A ∪ B|.
|
||||
func jaccardSimilarity(a, b map[string]bool) float64 {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return 0
|
||||
}
|
||||
intersection := 0
|
||||
for k := range a {
|
||||
if b[k] {
|
||||
intersection++
|
||||
}
|
||||
}
|
||||
union := len(a) + len(b) - intersection
|
||||
if union == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(intersection) / float64(union)
|
||||
}
|
||||
|
||||
// parseTimestamp parses a timestamp string into time.Time.
|
||||
func parseTimestamp(s string) time.Time {
|
||||
// Try common formats.
|
||||
for _, fmt := range []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05.000Z",
|
||||
} {
|
||||
if t, err := time.Parse(fmt, s); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
@@ -1,642 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// ngTestStore creates a minimal PacketStore with injected nodes and packets.
|
||||
func ngTestStore(nodes []nodeInfo, packets []*StoreTx) *PacketStore {
|
||||
if nodes == nil {
|
||||
nodes = []nodeInfo{}
|
||||
}
|
||||
if packets == nil {
|
||||
packets = []*StoreTx{}
|
||||
}
|
||||
ps := &PacketStore{
|
||||
packets: packets,
|
||||
byHash: make(map[string]*StoreTx),
|
||||
byTxID: make(map[int]*StoreTx),
|
||||
byObsID: make(map[int]*StoreObs),
|
||||
byObserver: make(map[string][]*StoreObs),
|
||||
byNode: make(map[string][]*StoreTx),
|
||||
nodeHashes: make(map[string]map[string]bool),
|
||||
byPayloadType: make(map[int][]*StoreTx),
|
||||
rfCache: make(map[string]*cachedResult),
|
||||
topoCache: make(map[string]*cachedResult),
|
||||
hashCache: make(map[string]*cachedResult),
|
||||
collisionCache: make(map[string]*cachedResult),
|
||||
chanCache: make(map[string]*cachedResult),
|
||||
distCache: make(map[string]*cachedResult),
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
spIndex: make(map[string]int),
|
||||
}
|
||||
ps.nodeCache = nodes
|
||||
ps.nodePM = buildPrefixMap(nodes)
|
||||
ps.nodeCacheTime = time.Now().Add(1 * time.Hour)
|
||||
return ps
|
||||
}
|
||||
|
||||
func ngIntPtr(v int) *int { return &v }
|
||||
func ngFloatPtr(v float64) *float64 { return &v }
|
||||
|
||||
func ngMakeTx(id int, payloadType int, decodedJSON string, obs []*StoreObs) *StoreTx {
|
||||
tx := &StoreTx{
|
||||
ID: id,
|
||||
PayloadType: ngIntPtr(payloadType),
|
||||
DecodedJSON: decodedJSON,
|
||||
Observations: obs,
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
func ngMakeObs(observerID, pathJSON, timestamp string, snr *float64) *StoreObs {
|
||||
return &StoreObs{
|
||||
ObserverID: observerID,
|
||||
PathJSON: pathJSON,
|
||||
Timestamp: timestamp,
|
||||
SNR: snr,
|
||||
}
|
||||
}
|
||||
|
||||
func ngFromNodeJSON(pubkey string) string {
|
||||
b, _ := json.Marshal(map[string]string{"from_node": pubkey})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
var now = time.Now()
|
||||
var nowStr = now.UTC().Format(time.RFC3339)
|
||||
var weekAgoStr = now.Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
var monthAgoStr = now.Add(-30 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBuildNeighborGraph_EmptyStore(t *testing.T) {
|
||||
store := ngTestStore(nil, nil)
|
||||
g := BuildFromStore(store)
|
||||
if len(g.edges) != 0 {
|
||||
t.Errorf("expected 0 edges, got %d", len(g.edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_AdvertSingleHopPath(t *testing.T) {
|
||||
// ADVERT from X, path=["R1_prefix"] → edges: X↔R1 and Observer↔R1
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, nowStr, ngFloatPtr(-10)),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Should have 2 edges: X↔R1 and Observer↔R1
|
||||
// But since path has 1 element, path[0]==path[last], so for ADVERTs
|
||||
// both edge types point to the same hop. X↔R1 and Obs↔R1 = 2 edges.
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 2 {
|
||||
t.Fatalf("expected 2 edges, got %d", len(edges))
|
||||
}
|
||||
|
||||
// Check X↔R1 exists
|
||||
found := false
|
||||
for _, e := range edges {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") ||
|
||||
(e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("missing originator↔path[0] edge (X↔R1)")
|
||||
}
|
||||
|
||||
// Check Observer↔R1 exists
|
||||
found = false
|
||||
for _, e := range edges {
|
||||
if (e.NodeA == "obs00001" && e.NodeB == "r1aabbcc") ||
|
||||
(e.NodeA == "r1aabbcc" && e.NodeB == "obs00001") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("missing observer↔path[last] edge (Observer↔R1)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_AdvertMultiHopPath(t *testing.T) {
|
||||
// ADVERT from X, path=["R1","R2"] → X↔R1 and Observer↔R2
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "r2ddeeff", Name: "R2"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 2 {
|
||||
t.Fatalf("expected 2 edges, got %d", len(edges))
|
||||
}
|
||||
|
||||
// X↔R1
|
||||
hasXR1 := false
|
||||
hasObsR2 := false
|
||||
for _, e := range edges {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
hasXR1 = true
|
||||
}
|
||||
if (e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001") {
|
||||
hasObsR2 = true
|
||||
}
|
||||
}
|
||||
if !hasXR1 {
|
||||
t.Error("missing X↔R1 edge")
|
||||
}
|
||||
if !hasObsR2 {
|
||||
t.Error("missing Observer↔R2 edge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_AdvertZeroHop(t *testing.T) {
|
||||
// ADVERT from X, path=[] → X↔Observer direct edge
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `[]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 1 {
|
||||
t.Fatalf("expected 1 edge, got %d", len(edges))
|
||||
}
|
||||
e := edges[0]
|
||||
if !((e.NodeA == "aaaa1111" && e.NodeB == "obs00001") || (e.NodeA == "obs00001" && e.NodeB == "aaaa1111")) {
|
||||
t.Errorf("expected X↔Observer edge, got %s↔%s", e.NodeA, e.NodeB)
|
||||
}
|
||||
if e.Ambiguous {
|
||||
t.Error("zero-hop edge should not be ambiguous")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_NonAdvertEmptyPath(t *testing.T) {
|
||||
// Non-ADVERT, path=[] → no edges
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `[]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
if len(g.edges) != 0 {
|
||||
t.Errorf("expected 0 edges for non-ADVERT empty path, got %d", len(g.edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_NonAdvertOnlyObserverEdge(t *testing.T) {
|
||||
// Non-ADVERT with path=["R1","R2"] → only Observer↔R2, NO originator edge
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "r2ddeeff", Name: "R2"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 1 {
|
||||
t.Fatalf("expected 1 edge, got %d", len(edges))
|
||||
}
|
||||
e := edges[0]
|
||||
if !((e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001")) {
|
||||
t.Errorf("expected Observer↔R2 edge, got %s↔%s", e.NodeA, e.NodeB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_NonAdvertSingleHop(t *testing.T) {
|
||||
// Non-ADVERT with path=["R1"] → Observer↔R1 only
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 1 {
|
||||
t.Fatalf("expected 1 edge, got %d", len(edges))
|
||||
}
|
||||
e := edges[0]
|
||||
if !((e.NodeA == "obs00001" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "obs00001")) {
|
||||
t.Errorf("expected Observer↔R1, got %s↔%s", e.NodeA, e.NodeB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_HashCollision(t *testing.T) {
|
||||
// Two nodes share prefix "a3" → ambiguous edge
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "a3bb1111", Name: "CandidateA"},
|
||||
{PublicKey: "a3bb2222", Name: "CandidateB"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["a3bb"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Should have ambiguous edges
|
||||
var ambigCount int
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.Ambiguous {
|
||||
ambigCount++
|
||||
if len(e.Candidates) < 2 {
|
||||
t.Errorf("expected >=2 candidates, got %d", len(e.Candidates))
|
||||
}
|
||||
}
|
||||
}
|
||||
if ambigCount == 0 {
|
||||
t.Error("expected at least one ambiguous edge for hash collision")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_JaccardScoring(t *testing.T) {
|
||||
// Test Jaccard similarity computation directly
|
||||
a := map[string]bool{"x": true, "y": true, "z": true}
|
||||
b := map[string]bool{"y": true, "z": true, "w": true}
|
||||
j := jaccardSimilarity(a, b)
|
||||
// intersection = {y, z} = 2, union = {x, y, z, w} = 4 → 0.5
|
||||
if math.Abs(j-0.5) > 0.001 {
|
||||
t.Errorf("expected Jaccard 0.5, got %f", j)
|
||||
}
|
||||
|
||||
// Empty sets
|
||||
j = jaccardSimilarity(nil, nil)
|
||||
if j != 0 {
|
||||
t.Errorf("expected 0 for empty sets, got %f", j)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_ConfidenceAutoResolve(t *testing.T) {
|
||||
// Setup: NodeX has known neighbors N1, N2, N3 (resolved edges).
|
||||
// CandidateA also has known neighbors N1, N2, N3 (high Jaccard with X).
|
||||
// CandidateB has no known neighbors (Jaccard = 0).
|
||||
// An ambiguous edge X↔prefix "a3" with candidates [A, B] should auto-resolve to A.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "n1111111", Name: "N1"},
|
||||
{PublicKey: "n2222222", Name: "N2"},
|
||||
{PublicKey: "n3333333", Name: "N3"},
|
||||
{PublicKey: "a3001111", Name: "CandidateA"},
|
||||
{PublicKey: "a3002222", Name: "CandidateB"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
|
||||
// Create resolved edges: X↔N1, X↔N2, X↔N3, A↔N1, A↔N2, A↔N3
|
||||
// Then an ambiguous edge X↔"a300" prefix with 3+ observations.
|
||||
var txs []*StoreTx
|
||||
txID := 1
|
||||
|
||||
// X sends ADVERTs through N1, N2, N3
|
||||
for _, nhop := range []string{"n111", "n222", "n333"} {
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["`+nhop+`"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
}
|
||||
|
||||
// CandidateA sends ADVERTs through N1, N2, N3
|
||||
for _, nhop := range []string{"n111", "n222", "n333"} {
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3001111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["`+nhop+`"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
}
|
||||
|
||||
// Ambiguous edge: X sends ADVERTs with path[0]="a300" (matches both candidates)
|
||||
// Need 3+ observations for confidence threshold.
|
||||
for i := 0; i < 3; i++ {
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["a300"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
}
|
||||
|
||||
store := ngTestStore(nodes, txs)
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// The ambiguous edge X↔a300 should have been resolved to CandidateA
|
||||
neighbors := g.Neighbors("aaaa1111")
|
||||
foundA := false
|
||||
for _, e := range neighbors {
|
||||
other := e.NodeB
|
||||
if e.NodeA != "aaaa1111" {
|
||||
other = e.NodeA
|
||||
}
|
||||
if other == "a3001111" {
|
||||
foundA = true
|
||||
if e.Ambiguous {
|
||||
t.Error("edge should have been resolved (not ambiguous)")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundA {
|
||||
t.Error("expected edge X↔CandidateA to be auto-resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_EqualScoresAmbiguous(t *testing.T) {
|
||||
// Two candidates with identical neighbor sets → should NOT auto-resolve.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "n1111111", Name: "N1"},
|
||||
{PublicKey: "a3001111", Name: "CandidateA"},
|
||||
{PublicKey: "a3002222", Name: "CandidateB"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
|
||||
var txs []*StoreTx
|
||||
txID := 1
|
||||
|
||||
// X↔N1
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["n111"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
|
||||
// Both candidates have same neighbor (N1)
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3001111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["n111"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3002222"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["n111"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
|
||||
// Ambiguous edge with 3+ observations
|
||||
for i := 0; i < 3; i++ {
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["a300"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
}
|
||||
|
||||
store := ngTestStore(nodes, txs)
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Should remain ambiguous
|
||||
var ambigFound bool
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.Ambiguous && e.Prefix == "a300" {
|
||||
ambigFound = true
|
||||
}
|
||||
}
|
||||
if !ambigFound {
|
||||
t.Error("expected ambiguous edge to remain unresolved with equal scores")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_ObserverSelfEdgeGuard(t *testing.T) {
|
||||
// Observer's own prefix in path → should NOT create self-edge.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["obs0"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Check no self-edge for observer
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.NodeA == e.NodeB && e.NodeA == "obs00001" {
|
||||
t.Error("self-edge created for observer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_OrphanPrefix(t *testing.T) {
|
||||
// Path contains prefix matching zero nodes → edge recorded as unresolved.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["ff99"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Should have ambiguous edges with empty candidates.
|
||||
var orphanFound bool
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.Ambiguous && len(e.Candidates) == 0 {
|
||||
orphanFound = true
|
||||
if e.Prefix != "ff99" {
|
||||
t.Errorf("expected prefix ff99, got %s", e.Prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !orphanFound {
|
||||
t.Error("expected orphan prefix edge with empty candidates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffinityScore_Fresh(t *testing.T) {
|
||||
e := &NeighborEdge{Count: 100, LastSeen: time.Now()}
|
||||
s := e.Score(time.Now())
|
||||
if s < 0.99 || s > 1.0 {
|
||||
t.Errorf("expected score ≈ 1.0, got %f", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffinityScore_Decayed(t *testing.T) {
|
||||
e := &NeighborEdge{Count: 100, LastSeen: time.Now().Add(-7 * 24 * time.Hour)}
|
||||
s := e.Score(time.Now())
|
||||
// 7 days → half-life → ~0.5
|
||||
if math.Abs(s-0.5) > 0.05 {
|
||||
t.Errorf("expected score ≈ 0.5, got %f", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffinityScore_LowCount(t *testing.T) {
|
||||
e := &NeighborEdge{Count: 5, LastSeen: time.Now()}
|
||||
s := e.Score(time.Now())
|
||||
// 5/100 = 0.05
|
||||
if math.Abs(s-0.05) > 0.01 {
|
||||
t.Errorf("expected score ≈ 0.05, got %f", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffinityScore_StaleAndLow(t *testing.T) {
|
||||
e := &NeighborEdge{Count: 5, LastSeen: time.Now().Add(-30 * 24 * time.Hour)}
|
||||
s := e.Score(time.Now())
|
||||
// Very small
|
||||
if s > 0.01 {
|
||||
t.Errorf("expected score ≈ 0, got %f", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_CountAccumulation(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
|
||||
var txs []*StoreTx
|
||||
for i := 0; i < 5; i++ {
|
||||
txs = append(txs, ngMakeTx(i+1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil),
|
||||
}))
|
||||
}
|
||||
|
||||
store := ngTestStore(nodes, txs)
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Check count on X↔R1 edge
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
if e.Count != 5 {
|
||||
t.Errorf("expected count 5, got %d", e.Count)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("X↔R1 edge not found")
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_MultipleObservers(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Obs1"},
|
||||
{PublicKey: "obs00002", Name: "Obs2"},
|
||||
}
|
||||
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil),
|
||||
ngMakeObs("obs00002", `["r1aa"]`, nowStr, nil),
|
||||
})
|
||||
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
if len(e.Observers) != 2 {
|
||||
t.Errorf("expected 2 observers, got %d", len(e.Observers))
|
||||
}
|
||||
if !e.Observers["obs00001"] || !e.Observers["obs00002"] {
|
||||
t.Error("missing expected observer")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("X↔R1 edge not found")
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_TimeDecayOldObservations(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, monthAgoStr, nil),
|
||||
})
|
||||
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
score := e.Score(time.Now())
|
||||
if score > 0.05 {
|
||||
t.Errorf("expected decayed score < 0.05, got %f", score)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("X↔R1 edge not found")
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_ADVERTOnlyConstraint(t *testing.T) {
|
||||
// Non-ADVERT: should NOT create originator↔path[0] edge, only observer↔path[last].
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "r2ddeeff", Name: "R2"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
for _, e := range g.AllEdges() {
|
||||
a, b := e.NodeA, e.NodeB
|
||||
if (a == "aaaa1111" && b == "r1aabbcc") || (a == "r1aabbcc" && b == "aaaa1111") {
|
||||
t.Error("non-ADVERT should NOT produce originator↔path[0] edge")
|
||||
}
|
||||
}
|
||||
|
||||
// Should have Observer↔R2
|
||||
found := false
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("missing Observer↔R2 edge from non-ADVERT")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraph_CacheTTL(t *testing.T) {
|
||||
g := NewNeighborGraph()
|
||||
if !g.IsStale() {
|
||||
t.Error("new graph should be stale")
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.builtAt = time.Now()
|
||||
g.mu.Unlock()
|
||||
if g.IsStale() {
|
||||
t.Error("just-built graph should not be stale")
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.builtAt = time.Now().Add(-2 * neighborGraphTTL)
|
||||
g.mu.Unlock()
|
||||
if !g.IsStale() {
|
||||
t.Error("old graph should be stale")
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── resolveWithContext unit tests ─────────────────────────────────────────────
|
||||
|
||||
func TestResolveWithContext_UniquePrefix(t *testing.T) {
|
||||
pm := buildPrefixMap([]nodeInfo{
|
||||
{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{
|
||||
{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{
|
||||
{PublicKey: "a1aaaaaa", Name: "Node-A1"},
|
||||
{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{
|
||||
{PublicKey: "a1aaaaaa", Name: "Node-A1", HasGPS: true, Lat: 10, Lon: 20},
|
||||
{PublicKey: "a1bbbbbb", Name: "Node-A2", HasGPS: true, Lat: 11, Lon: 21},
|
||||
{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{
|
||||
{PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
||||
{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{
|
||||
{PublicKey: "a1aaaaaa", Name: "First"},
|
||||
{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 != "first_match" {
|
||||
t.Fatalf("expected first_match, got %s", confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithContext_NilGraphFallsToGPS(t *testing.T) {
|
||||
pm := buildPrefixMap([]nodeInfo{
|
||||
{PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
||||
{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{
|
||||
{PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
||||
{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) VALUES (?, ?, ?, ?)",
|
||||
"ff11223344", "UniqueNode", 37.0, -122.0)
|
||||
|
||||
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) VALUES (?, ?, ?, ?)",
|
||||
"ee1aaaaaaa", "Node-E1", 37.0, -122.0)
|
||||
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
||||
"ee1bbbbbbb", "Node-E2", 38.0, -121.0)
|
||||
|
||||
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")
|
||||
}
|
||||
if hr.Confidence != "ambiguous" {
|
||||
t.Fatalf("expected ambiguous, 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) VALUES (?, ?, ?, ?)",
|
||||
"dd1aaaaaaa", "Node-D1", 37.0, -122.0)
|
||||
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
||||
"dd1bbbbbbb", "Node-D2", 38.0, -121.0)
|
||||
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
||||
"c0c0c0c0c0", "Context", 37.1, -122.1)
|
||||
|
||||
// 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) VALUES (?, ?, ?, ?)",
|
||||
"bb1aaaaaaa", "Node-B1", 37.0, -122.0)
|
||||
|
||||
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 ───────────────────────────────────────
|
||||
+3
-93
@@ -38,10 +38,6 @@ type Server struct {
|
||||
statsMu sync.Mutex
|
||||
statsCache *StatsResponse
|
||||
statsCachedAt time.Time
|
||||
|
||||
// Neighbor affinity graph (lazy-built, cached with TTL)
|
||||
neighborMu sync.Mutex
|
||||
neighborGraph *NeighborGraph
|
||||
}
|
||||
|
||||
// PerfStats tracks request performance.
|
||||
@@ -132,7 +128,6 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/nodes/{pubkey}/health", s.handleNodeHealth).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/paths", s.handleNodePaths).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/analytics", s.handleNodeAnalytics).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/neighbors", s.handleNodeNeighbors).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}", s.handleNodeDetail).Methods("GET")
|
||||
r.HandleFunc("/api/nodes", s.handleNodes).Methods("GET")
|
||||
|
||||
@@ -145,7 +140,6 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/analytics/hash-collisions", s.handleAnalyticsHashCollisions).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/subpaths", s.handleAnalyticsSubpaths).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/subpath-detail", s.handleAnalyticsSubpathDetail).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/neighbor-graph", s.handleNeighborGraph).Methods("GET")
|
||||
|
||||
// Other endpoints
|
||||
r.HandleFunc("/api/resolve-hops", s.handleResolveHops).Methods("GET")
|
||||
@@ -1309,31 +1303,6 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
|
||||
hops := strings.Split(hopsParam, ",")
|
||||
resolved := map[string]*HopResolution{}
|
||||
|
||||
// Context for affinity-based disambiguation.
|
||||
fromNode := r.URL.Query().Get("from_node")
|
||||
observer := r.URL.Query().Get("observer")
|
||||
var contextPubkeys []string
|
||||
if fromNode != "" {
|
||||
contextPubkeys = append(contextPubkeys, fromNode)
|
||||
}
|
||||
if observer != "" {
|
||||
contextPubkeys = append(contextPubkeys, observer)
|
||||
}
|
||||
|
||||
// Get the neighbor graph for affinity scoring (may be nil).
|
||||
var graph *NeighborGraph
|
||||
if len(contextPubkeys) > 0 {
|
||||
graph = s.getNeighborGraph()
|
||||
}
|
||||
|
||||
// Get the server's prefix map for resolveWithContext.
|
||||
var pm *prefixMap
|
||||
if s.store != nil {
|
||||
s.store.mu.RLock()
|
||||
_, pm = s.store.getCachedNodesAndPM()
|
||||
s.store.mu.RUnlock()
|
||||
}
|
||||
|
||||
for _, hop := range hops {
|
||||
if hop == "" {
|
||||
continue
|
||||
@@ -1341,7 +1310,7 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
|
||||
hopLower := strings.ToLower(hop)
|
||||
rows, err := s.db.conn.Query("SELECT public_key, name, lat, lon FROM nodes WHERE LOWER(public_key) LIKE ?", hopLower+"%")
|
||||
if err != nil {
|
||||
resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}, Confidence: "ambiguous"}
|
||||
resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1359,77 +1328,18 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
|
||||
rows.Close()
|
||||
|
||||
if len(candidates) == 0 {
|
||||
resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}, Confidence: "no_match"}
|
||||
resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}}
|
||||
} else if len(candidates) == 1 {
|
||||
resolved[hop] = &HopResolution{
|
||||
Name: candidates[0].Name, Pubkey: candidates[0].Pubkey,
|
||||
Candidates: candidates, Conflicts: []interface{}{},
|
||||
Confidence: "unique_prefix",
|
||||
}
|
||||
} else {
|
||||
// Compute affinity scores for each candidate if we have context.
|
||||
if graph != nil && len(contextPubkeys) > 0 {
|
||||
now := time.Now()
|
||||
for i := range candidates {
|
||||
candPK := strings.ToLower(candidates[i].Pubkey)
|
||||
bestScore := 0.0
|
||||
for _, ctxPK := range contextPubkeys {
|
||||
edges := graph.Neighbors(strings.ToLower(ctxPK))
|
||||
for _, e := range edges {
|
||||
if e.Ambiguous {
|
||||
continue
|
||||
}
|
||||
otherPK := e.NodeA
|
||||
if strings.EqualFold(otherPK, ctxPK) {
|
||||
otherPK = e.NodeB
|
||||
}
|
||||
if strings.EqualFold(otherPK, candPK) {
|
||||
sc := e.Score(now)
|
||||
if sc > bestScore {
|
||||
bestScore = sc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestScore > 0 {
|
||||
s := bestScore
|
||||
candidates[i].AffinityScore = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use resolveWithContext for 4-tier disambiguation.
|
||||
var best *nodeInfo
|
||||
var confidence string
|
||||
if pm != nil {
|
||||
best, confidence, _ = pm.resolveWithContext(hopLower, contextPubkeys, graph)
|
||||
}
|
||||
|
||||
ambig := true
|
||||
hr := &HopResolution{
|
||||
resolved[hop] = &HopResolution{
|
||||
Name: candidates[0].Name, Pubkey: candidates[0].Pubkey,
|
||||
Ambiguous: &ambig, Candidates: candidates, Conflicts: hopCandidatesToConflicts(candidates),
|
||||
Confidence: "ambiguous",
|
||||
}
|
||||
|
||||
// Use the resolved node as the default (best-effort pick).
|
||||
if best != nil {
|
||||
hr.Name = best.Name
|
||||
hr.Pubkey = best.PublicKey
|
||||
}
|
||||
|
||||
// Only promote to bestCandidate when affinity is confident.
|
||||
if confidence == "neighbor_affinity" && best != nil {
|
||||
pk := best.PublicKey
|
||||
hr.BestCandidate = &pk
|
||||
hr.Confidence = "neighbor_affinity"
|
||||
} else if (confidence == "geo_proximity" || confidence == "gps_preference") && best != nil {
|
||||
// Propagate lower-priority tiers so the API reflects the actual
|
||||
// resolution strategy used, rather than collapsing everything to "ambiguous".
|
||||
hr.Confidence = confidence
|
||||
}
|
||||
|
||||
resolved[hop] = hr
|
||||
}
|
||||
}
|
||||
writeJSON(w, ResolveHopsResponse{Resolved: resolved})
|
||||
|
||||
@@ -3304,144 +3304,6 @@ func (pm *prefixMap) resolve(hop string) *nodeInfo {
|
||||
return &candidates[0]
|
||||
}
|
||||
|
||||
// resolveWithContext resolves a hop prefix using the neighbor affinity graph
|
||||
// for disambiguation when multiple candidates match. It applies a 4-tier
|
||||
// priority: (1) affinity graph score, (2) geographic proximity to context
|
||||
// nodes, (3) GPS preference, (4) first match fallback.
|
||||
//
|
||||
// contextPubkeys are pubkeys of nodes that provide context for disambiguation
|
||||
// (e.g., the originator, observer, or adjacent hops in the path).
|
||||
// graph may be nil, in which case it falls back to the existing resolve().
|
||||
func (pm *prefixMap) resolveWithContext(hop string, contextPubkeys []string, graph *NeighborGraph) (*nodeInfo, string, float64) {
|
||||
h := strings.ToLower(hop)
|
||||
candidates := pm.m[h]
|
||||
if len(candidates) == 0 {
|
||||
return nil, "no_match", 0
|
||||
}
|
||||
if len(candidates) == 1 {
|
||||
return &candidates[0], "unique_prefix", 1.0
|
||||
}
|
||||
|
||||
// Priority 1: Affinity graph score
|
||||
//
|
||||
// NOTE: We use raw Score() (count × time-decay) here rather than Jaccard
|
||||
// similarity. Jaccard is used at the graph builder level (disambiguate() in
|
||||
// neighbor_graph.go) to resolve ambiguous edges by comparing neighbor-set
|
||||
// overlap. Here, edges are already resolved — we just need to pick the
|
||||
// highest-affinity candidate among them. Raw score is appropriate because
|
||||
// it reflects both observation frequency and recency, which are the right
|
||||
// signals for "which candidate is this hop most likely referring to."
|
||||
if graph != nil && len(contextPubkeys) > 0 {
|
||||
type scored struct {
|
||||
idx int
|
||||
score float64
|
||||
count int // observation count of the best-scoring edge
|
||||
}
|
||||
now := time.Now()
|
||||
var scores []scored
|
||||
for i, cand := range candidates {
|
||||
candPK := strings.ToLower(cand.PublicKey)
|
||||
bestScore := 0.0
|
||||
bestCount := 0
|
||||
for _, ctxPK := range contextPubkeys {
|
||||
edges := graph.Neighbors(strings.ToLower(ctxPK))
|
||||
for _, e := range edges {
|
||||
if e.Ambiguous {
|
||||
continue
|
||||
}
|
||||
otherPK := e.NodeA
|
||||
if strings.EqualFold(otherPK, ctxPK) {
|
||||
otherPK = e.NodeB
|
||||
}
|
||||
if strings.EqualFold(otherPK, candPK) {
|
||||
s := e.Score(now)
|
||||
if s > bestScore {
|
||||
bestScore = s
|
||||
bestCount = e.Count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestScore > 0 {
|
||||
scores = append(scores, scored{i, bestScore, bestCount})
|
||||
}
|
||||
}
|
||||
|
||||
if len(scores) >= 1 {
|
||||
// Sort descending
|
||||
for i := 0; i < len(scores)-1; i++ {
|
||||
for j := i + 1; j < len(scores); j++ {
|
||||
if scores[j].score > scores[i].score {
|
||||
scores[i], scores[j] = scores[j], scores[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
best := scores[0]
|
||||
// Require both score ratio ≥ 3× AND minimum observations (mirrors
|
||||
// disambiguate() in neighbor_graph.go which checks affinityMinObservations).
|
||||
if best.count >= affinityMinObservations &&
|
||||
(len(scores) == 1 || best.score >= affinityConfidenceRatio*scores[1].score) {
|
||||
return &candidates[best.idx], "neighbor_affinity", best.score
|
||||
}
|
||||
// Scores too close — fall through to lower-priority strategies
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Geographic proximity (if context pubkeys have GPS and candidates have GPS)
|
||||
if len(contextPubkeys) > 0 {
|
||||
// Find GPS positions of context nodes from the prefix map or candidates
|
||||
// We need nodeInfo for context pubkeys — look them up
|
||||
var contextLat, contextLon float64
|
||||
var contextGPSCount int
|
||||
for _, ctxPK := range contextPubkeys {
|
||||
ctxLower := strings.ToLower(ctxPK)
|
||||
if infos, ok := pm.m[ctxLower]; ok && len(infos) == 1 && infos[0].HasGPS {
|
||||
contextLat += infos[0].Lat
|
||||
contextLon += infos[0].Lon
|
||||
contextGPSCount++
|
||||
}
|
||||
}
|
||||
if contextGPSCount > 0 {
|
||||
contextLat /= float64(contextGPSCount)
|
||||
contextLon /= float64(contextGPSCount)
|
||||
|
||||
bestIdx := -1
|
||||
bestDist := math.MaxFloat64
|
||||
for i, cand := range candidates {
|
||||
if !cand.HasGPS {
|
||||
continue
|
||||
}
|
||||
d := geoDistApprox(contextLat, contextLon, cand.Lat, cand.Lon)
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
if bestIdx >= 0 {
|
||||
return &candidates[bestIdx], "geo_proximity", 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: GPS preference
|
||||
for i := range candidates {
|
||||
if candidates[i].HasGPS {
|
||||
return &candidates[i], "gps_preference", 0
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: First match fallback
|
||||
return &candidates[0], "first_match", 0
|
||||
}
|
||||
|
||||
// geoDistApprox returns an approximate distance between two lat/lon points
|
||||
// (equirectangular approximation, sufficient for relative comparison).
|
||||
func geoDistApprox(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
dLat := (lat2 - lat1) * math.Pi / 180
|
||||
dLon := (lon2 - lon1) * math.Pi / 180 * math.Cos((lat1+lat2)/2*math.Pi/180)
|
||||
return math.Sqrt(dLat*dLat + dLon*dLon)
|
||||
}
|
||||
|
||||
func parsePathJSON(pathJSON string) []string {
|
||||
if pathJSON == "" || pathJSON == "[]" {
|
||||
return nil
|
||||
|
||||
+9
-12
@@ -873,21 +873,18 @@ type TraceResponse struct {
|
||||
// ─── Resolve Hops ──────────────────────────────────────────────────────────────
|
||||
|
||||
type HopCandidate struct {
|
||||
Name interface{} `json:"name"`
|
||||
Pubkey string `json:"pubkey"`
|
||||
Lat interface{} `json:"lat"`
|
||||
Lon interface{} `json:"lon"`
|
||||
AffinityScore *float64 `json:"affinityScore"`
|
||||
Name interface{} `json:"name"`
|
||||
Pubkey string `json:"pubkey"`
|
||||
Lat interface{} `json:"lat"`
|
||||
Lon interface{} `json:"lon"`
|
||||
}
|
||||
|
||||
type HopResolution struct {
|
||||
Name interface{} `json:"name"`
|
||||
Pubkey interface{} `json:"pubkey,omitempty"`
|
||||
Ambiguous *bool `json:"ambiguous,omitempty"`
|
||||
Candidates []HopCandidate `json:"candidates"`
|
||||
Conflicts []interface{} `json:"conflicts"`
|
||||
BestCandidate *string `json:"bestCandidate,omitempty"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
Name interface{} `json:"name"`
|
||||
Pubkey interface{} `json:"pubkey,omitempty"`
|
||||
Ambiguous *bool `json:"ambiguous,omitempty"`
|
||||
Candidates []HopCandidate `json:"candidates"`
|
||||
Conflicts []interface{} `json:"conflicts"`
|
||||
}
|
||||
|
||||
type ResolveHopsResponse struct {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# CoreScope v3.3 Release Notes
|
||||
|
||||
## Headline: Neighbor Affinity, Virtual Scroll, and a Performance Overhaul
|
||||
|
||||
v3.3 is the biggest release since launch — 50 PRs merged, touching every layer of the stack. The packets page now handles 30K+ rows without breaking a sweat, nodes show their RF neighbors, and the customizer got a complete rewrite.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 New Features
|
||||
|
||||
- **Neighbor affinity graph** — see which nodes hear each other and how well, rendered as an interactive graph in analytics (#507, #508, #513)
|
||||
- **Neighbors section on node detail page** — every node now shows its direct RF neighbors with signal quality (#510)
|
||||
- **Affinity-aware hop resolution** — hop paths now resolve using real RF neighbor data instead of guessing (#511)
|
||||
- **"Show direct neighbors" map filter** — click a node on the map to highlight only its neighbors (#480)
|
||||
- **Customizer v2** — completely rewritten with event-driven state management, cleaner UX (#503)
|
||||
- **Auto-inject cache busters at server startup** — no more manual `__BUST__` bumps or merge conflicts (#481)
|
||||
- **Git-derived versioning** — version now comes from git tags, not package.json (#486)
|
||||
- **manage.sh supports pinning to release tags** — deploy a specific version instead of always latest (#456)
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
- **Virtual scroll for packets table** — 30K+ packets render smoothly, no more DOM explosion (#402)
|
||||
- **Debounced WebSocket renders** — coalesced updates prevent render storms on busy meshes (#402)
|
||||
- **Cached JSON.parse results** — packet data parsed once, reused everywhere (#400)
|
||||
- **In-place node upsert on ADVERT** — skip full reload when a node advertises (#461)
|
||||
- **Map lookups replace linear scans** — observers.find() → O(1) Map lookups (#468)
|
||||
- **Bounded memory growth on packets page** — eviction prevents unbounded DOM/data growth (#421)
|
||||
- **Server-side collision analysis** — moved from client to server, fixes UI freezes on large meshes (#415)
|
||||
- **Client-side "My Nodes" filter** — eliminated a server round-trip (#401)
|
||||
- **Targeted analytics cache invalidation** — surgical invalidation instead of blowing the whole cache (#379)
|
||||
- **Skip JSON parse when no pubkey fields present** — fast path for the common case (#499)
|
||||
- **requestAnimationFrame replaces setInterval** — smoother live page animations, capped concurrency (#470)
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
- **Region filter was silently ignored on GetNodes** — nodes now actually filter by region (#497)
|
||||
- **Region filtering missing from hash-collisions endpoint** — fixed (#477)
|
||||
- **Haversine replaces Euclidean distance** in analytics hop distances — no more wildly wrong distances (#478)
|
||||
- **Color-coded hex breakdown restored** in packet detail view (#500)
|
||||
- **Channel hash displayed as hex** instead of confusing decimal (#471)
|
||||
- **VCR timeline respects UTC/local timezone setting** (#459)
|
||||
- **Observer last_seen updates on packet ingestion** — observers no longer appear stale (#479)
|
||||
- **Packet timestamps used in bufferPacket** instead of arrival time — fixes time-travel bugs (#491)
|
||||
- **Zero-hop adverts skipped** when checking node hash size (#493)
|
||||
- **Null-guard fixes** — pathHops detail pane crash (#454), animLayer/liveAnimCount after destroy (#462), rAF callbacks in live page (#506)
|
||||
- **Stale parsed cache cleared** on observation packets (#505)
|
||||
- **Score/direction extracted from MQTT** with proper unit stripping and type safety (#371)
|
||||
- **String/uint/uint64 type handling** in toFloat64 (#352)
|
||||
- **Reset restores home steps** after SITE_CONFIG contamination (#460)
|
||||
- **Duplicate return statement removed** in _cumulativeRowOffsets (#476)
|
||||
- **Mutex added to PerfStats** — eliminates data races (#469)
|
||||
- **Graceful container shutdown** for reliable deployments (#453)
|
||||
- **Staging config always refreshed from prod** (#467)
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
- **100+ new app.js tests** — comprehensive SPA router coverage (#490)
|
||||
- **71 new live.js tests** — live page fully covered (#489)
|
||||
- **64 new packets.js tests** (#488)
|
||||
- **nodes.js P0 coverage** — sort, status, timestamps, sync (#487)
|
||||
- **Ingestor coverage 70% → 84%** (#492)
|
||||
- **Playwright packets test stabilized** with explicit time window (#348)
|
||||
|
||||
## 🔧 Internal
|
||||
|
||||
- **Docker cleanup before CI build** — prevents disk space exhaustion (#473)
|
||||
|
||||
## ⚠️ Known Limitations
|
||||
|
||||
- **Live map** does not yet use affinity-aware hop resolution — animated paths still use naive first-match for ambiguous hops (#528)
|
||||
- **Customizer v2 home section** requires server-side home defaults to be configured — instances without `home` in config.json will show empty customizer fields until #526 merges
|
||||
|
||||
---
|
||||
|
||||
*50 PRs. Zero new dependencies. Still no build step.*
|
||||
+1
-405
@@ -85,7 +85,6 @@
|
||||
<button class="tab-btn" data-tab="subpaths">Route Patterns</button>
|
||||
<button class="tab-btn" data-tab="nodes">Nodes</button>
|
||||
<button class="tab-btn" data-tab="distance">Distance</button>
|
||||
<button class="tab-btn" data-tab="neighbor-graph">Neighbor Graph</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="analyticsContent" class="analytics-content">
|
||||
@@ -172,7 +171,6 @@
|
||||
case 'subpaths': await renderSubpaths(el); break;
|
||||
case 'nodes': await renderNodesTab(el); break;
|
||||
case 'distance': await renderDistanceTab(el); break;
|
||||
case 'neighbor-graph': await renderNeighborGraphTab(el); break;
|
||||
}
|
||||
// Auto-apply column resizing to all analytics tables
|
||||
requestAnimationFrame(() => {
|
||||
@@ -1801,7 +1799,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; }
|
||||
function destroy() { _analyticsData = {}; _channelData = null; }
|
||||
|
||||
// Expose for testing
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -1812,407 +1810,5 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
window._analyticsChannelTheadHtml = channelTheadHtml;
|
||||
}
|
||||
|
||||
// ─── Neighbor Graph Tab ─────────────────────────────────────────────────────
|
||||
|
||||
let _ngState = null; // neighbor graph state
|
||||
|
||||
async function renderNeighborGraphTab(el) {
|
||||
el.innerHTML = `
|
||||
<div class="analytics-card" id="ngCard">
|
||||
<h3>🕸️ Neighbor Graph</h3>
|
||||
<div id="ngFilters" class="ng-filters" style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-bottom:12px">
|
||||
<label style="font-size:13px">Roles:
|
||||
<span id="ngRoleChecks" style="margin-left:4px"></span>
|
||||
</label>
|
||||
<label style="font-size:13px">Min Score: <input type="range" id="ngMinScore" min="0" max="100" value="10" style="width:100px;vertical-align:middle">
|
||||
<span id="ngMinScoreVal">0.10</span>
|
||||
</label>
|
||||
<label style="font-size:13px">Confidence:
|
||||
<select id="ngConfidence" style="font-size:12px;padding:2px 4px">
|
||||
<option value="all">Show All</option>
|
||||
<option value="high">High Only</option>
|
||||
<option value="hide-ambiguous">Hide Ambiguous</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="ngStats" class="stat-row" style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px"></div>
|
||||
<div style="position:relative;border:1px solid var(--border);border-radius:6px;overflow:hidden">
|
||||
<canvas id="ngCanvas" width="900" height="600" style="width:100%;height:600px;cursor:grab" role="img" aria-label="Neighbor affinity graph visualization — interactive force-directed network topology" tabindex="0"></canvas>
|
||||
<div id="ngTooltip" style="position:absolute;display:none;background:var(--bg-secondary);border:1px solid var(--border);border-radius:4px;padding:6px 10px;font-size:12px;pointer-events:none;z-index:10;box-shadow:0 2px 8px rgba(0,0,0,0.2)"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Role checkboxes
|
||||
const roles = ['repeater','companion','room','sensor'];
|
||||
const rcEl = document.getElementById('ngRoleChecks');
|
||||
roles.forEach(r => {
|
||||
const color = (window.ROLE_COLORS || {})[r] || '#888';
|
||||
rcEl.innerHTML += `<label style="font-size:12px;margin-right:8px"><input type="checkbox" data-role="${r}" checked> <span style="color:${esc(color)}">${esc(r)}</span></label>`;
|
||||
});
|
||||
|
||||
// Load data
|
||||
const rqs = RegionFilter.regionQueryString();
|
||||
const sep = rqs ? '?' + rqs.slice(1) : '';
|
||||
let graphData;
|
||||
try {
|
||||
graphData = await api('/analytics/neighbor-graph' + sep + (sep ? '&' : '?') + 'min_count=1&min_score=0', { ttl: CLIENT_TTL.analyticsRF });
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="analytics-card"><p class="text-muted">Failed to load neighbor graph: ${esc(e.message)}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
_ngState = createGraphState(graphData);
|
||||
renderNGStats(_ngState);
|
||||
startGraphRenderer();
|
||||
|
||||
// Filter listeners
|
||||
document.getElementById('ngMinScore').addEventListener('input', function() {
|
||||
document.getElementById('ngMinScoreVal').textContent = (this.value / 100).toFixed(2);
|
||||
applyNGFilters();
|
||||
});
|
||||
document.getElementById('ngConfidence').addEventListener('change', applyNGFilters);
|
||||
rcEl.addEventListener('change', applyNGFilters);
|
||||
}
|
||||
|
||||
function createGraphState(data) {
|
||||
const nodes = (data.nodes || []).map((n, i) => ({
|
||||
...n,
|
||||
x: 450 + (Math.random() - 0.5) * 400,
|
||||
y: 300 + (Math.random() - 0.5) * 300,
|
||||
vx: 0, vy: 0,
|
||||
radius: Math.max(6, Math.min(18, 6 + (n.neighbor_count || 0)))
|
||||
}));
|
||||
const nodeIdx = {};
|
||||
nodes.forEach((n, i) => { nodeIdx[n.pubkey] = i; });
|
||||
const edges = (data.edges || []).filter(e => nodeIdx[e.source] !== undefined && nodeIdx[e.target] !== undefined);
|
||||
return {
|
||||
allNodes: nodes, allEdges: edges,
|
||||
nodes, edges, nodeIdx,
|
||||
stats: data.stats || {},
|
||||
zoom: 1, panX: 0, panY: 0,
|
||||
dragging: null, panning: false,
|
||||
lastMouseX: 0, lastMouseY: 0,
|
||||
cooling: 1.0, animId: null
|
||||
};
|
||||
}
|
||||
|
||||
function applyNGFilters() {
|
||||
if (!_ngState) return;
|
||||
const minScore = parseInt(document.getElementById('ngMinScore').value, 10) / 100;
|
||||
const conf = document.getElementById('ngConfidence').value;
|
||||
const checkedRoles = new Set();
|
||||
document.querySelectorAll('#ngRoleChecks input:checked').forEach(cb => checkedRoles.add(cb.dataset.role));
|
||||
|
||||
// Filter nodes by role
|
||||
const visibleNodes = _ngState.allNodes.filter(n => {
|
||||
const role = (n.role || 'unknown').toLowerCase();
|
||||
return checkedRoles.has(role) || role === 'unknown' || role === 'observer';
|
||||
});
|
||||
const visiblePKs = new Set(visibleNodes.map(n => n.pubkey));
|
||||
|
||||
// Filter edges
|
||||
_ngState.edges = _ngState.allEdges.filter(e => {
|
||||
if (e.score < minScore) return false;
|
||||
if (conf === 'high' && (e.ambiguous || e.score < 0.5)) return false;
|
||||
if (conf === 'hide-ambiguous' && e.ambiguous) return false;
|
||||
return visiblePKs.has(e.source) && visiblePKs.has(e.target);
|
||||
});
|
||||
|
||||
// Only include nodes that have at least one visible edge
|
||||
const edgeNodes = new Set();
|
||||
_ngState.edges.forEach(e => { edgeNodes.add(e.source); edgeNodes.add(e.target); });
|
||||
_ngState.nodes = visibleNodes.filter(n => edgeNodes.has(n.pubkey));
|
||||
|
||||
// Rebuild index
|
||||
_ngState.nodeIdx = {};
|
||||
_ngState.nodes.forEach((n, i) => { _ngState.nodeIdx[n.pubkey] = i; });
|
||||
|
||||
_ngState.cooling = 1.0;
|
||||
renderNGStats(_ngState);
|
||||
}
|
||||
|
||||
function renderNGStats(st) {
|
||||
const nodes = st.nodes, edges = st.edges;
|
||||
const totalScore = edges.reduce((s, e) => s + e.score, 0);
|
||||
const avgScore = edges.length ? (totalScore / edges.length) : 0;
|
||||
const ambiguous = edges.filter(e => e.ambiguous).length;
|
||||
const resolved = edges.length ? ((edges.length - ambiguous) / edges.length * 100) : 0;
|
||||
const statsEl = document.getElementById('ngStats');
|
||||
if (!statsEl) return;
|
||||
statsEl.innerHTML = `
|
||||
<div class="stat-card"><div class="stat-value">${nodes.length}</div><div class="stat-label">Nodes</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${edges.length}</div><div class="stat-label">Edges</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${avgScore.toFixed(2)}</div><div class="stat-label">Avg Score</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${resolved.toFixed(0)}%</div><div class="stat-label">Resolved</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${ambiguous}</div><div class="stat-label">Ambiguous</div></div>`;
|
||||
}
|
||||
|
||||
function startGraphRenderer() {
|
||||
if (!_ngState) return;
|
||||
|
||||
// Node count guard: skip force simulation for very large graphs
|
||||
var NODE_LIMIT = 1000;
|
||||
if (_ngState.allNodes.length > NODE_LIMIT) {
|
||||
var el = document.getElementById('ngCanvas');
|
||||
if (el) {
|
||||
el.style.display = 'none';
|
||||
var msg = document.createElement('div');
|
||||
msg.className = 'analytics-card';
|
||||
msg.innerHTML = '<p class="text-muted">Graph has ' + _ngState.allNodes.length + ' nodes (limit: ' + NODE_LIMIT + '). Force simulation skipped for performance. Use filters to reduce the node count.</p>';
|
||||
el.parentNode.insertBefore(msg, el);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('ngCanvas');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = canvas.clientHeight * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
const W = canvas.clientWidth, H = canvas.clientHeight;
|
||||
|
||||
// Interaction
|
||||
let hoverNode = null;
|
||||
|
||||
function canvasToGraph(cx, cy) {
|
||||
return { x: (cx - _ngState.panX) / _ngState.zoom, y: (cy - _ngState.panY) / _ngState.zoom };
|
||||
}
|
||||
|
||||
function findNode(cx, cy) {
|
||||
const gp = canvasToGraph(cx, cy);
|
||||
for (let i = _ngState.nodes.length - 1; i >= 0; i--) {
|
||||
const n = _ngState.nodes[i];
|
||||
const dx = gp.x - n.x, dy = gp.y - n.y;
|
||||
if (dx * dx + dy * dy <= n.radius * n.radius) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', function(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
|
||||
const n = findNode(cx, cy);
|
||||
if (n) {
|
||||
_ngState.dragging = n;
|
||||
n._pinned = true;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
} else {
|
||||
_ngState.panning = true;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
_ngState.lastMouseX = e.clientX;
|
||||
_ngState.lastMouseY = e.clientY;
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', function(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
|
||||
if (_ngState.dragging) {
|
||||
const dx = (e.clientX - _ngState.lastMouseX) / _ngState.zoom;
|
||||
const dy = (e.clientY - _ngState.lastMouseY) / _ngState.zoom;
|
||||
_ngState.dragging.x += dx;
|
||||
_ngState.dragging.y += dy;
|
||||
_ngState.lastMouseX = e.clientX;
|
||||
_ngState.lastMouseY = e.clientY;
|
||||
_ngState.cooling = Math.max(_ngState.cooling, 0.3);
|
||||
} else if (_ngState.panning) {
|
||||
_ngState.panX += e.clientX - _ngState.lastMouseX;
|
||||
_ngState.panY += e.clientY - _ngState.lastMouseY;
|
||||
_ngState.lastMouseX = e.clientX;
|
||||
_ngState.lastMouseY = e.clientY;
|
||||
} else {
|
||||
const n = findNode(cx, cy);
|
||||
if (n !== hoverNode) {
|
||||
hoverNode = n;
|
||||
canvas.style.cursor = n ? 'pointer' : 'grab';
|
||||
const tip = document.getElementById('ngTooltip');
|
||||
if (n && tip) {
|
||||
tip.style.display = 'block';
|
||||
tip.style.left = (cx + 12) + 'px';
|
||||
tip.style.top = (cy - 8) + 'px';
|
||||
tip.innerHTML = `<strong>${esc(n.name || n.pubkey.slice(0, 12) + '…')}</strong><br>Role: ${esc(n.role || 'unknown')}<br>Neighbors: ${n.neighbor_count || 0}`;
|
||||
} else if (tip) {
|
||||
tip.style.display = 'none';
|
||||
}
|
||||
} else if (hoverNode) {
|
||||
const tip = document.getElementById('ngTooltip');
|
||||
if (tip) { tip.style.left = (cx + 12) + 'px'; tip.style.top = (cy - 8) + 'px'; }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseup', function() {
|
||||
if (_ngState.dragging) {
|
||||
_ngState.dragging._pinned = false;
|
||||
_ngState._wasDragging = true;
|
||||
}
|
||||
_ngState.dragging = null;
|
||||
_ngState.panning = false;
|
||||
canvas.style.cursor = hoverNode ? 'pointer' : 'grab';
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseleave', function() {
|
||||
_ngState.dragging = null;
|
||||
_ngState.panning = false;
|
||||
_ngState._wasDragging = false;
|
||||
const tip = document.getElementById('ngTooltip');
|
||||
if (tip) tip.style.display = 'none';
|
||||
hoverNode = null;
|
||||
});
|
||||
|
||||
canvas.addEventListener('click', function(e) {
|
||||
if (_ngState._wasDragging) { _ngState._wasDragging = false; return; }
|
||||
if (_ngState.dragging) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const n = findNode(e.clientX - rect.left, e.clientY - rect.top);
|
||||
if (n) location.hash = '#/nodes/' + n.pubkey;
|
||||
});
|
||||
|
||||
canvas.addEventListener('keydown', function(e) {
|
||||
const PAN_STEP = 30, ZOOM_STEP = 1.15;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft': _ngState.panX += PAN_STEP; e.preventDefault(); break;
|
||||
case 'ArrowRight': _ngState.panX -= PAN_STEP; e.preventDefault(); break;
|
||||
case 'ArrowUp': _ngState.panY += PAN_STEP; e.preventDefault(); break;
|
||||
case 'ArrowDown': _ngState.panY -= PAN_STEP; e.preventDefault(); break;
|
||||
case '+': case '=': _ngState.zoom = Math.min(10, _ngState.zoom * ZOOM_STEP); e.preventDefault(); break;
|
||||
case '-': case '_': _ngState.zoom = Math.max(0.1, _ngState.zoom / ZOOM_STEP); e.preventDefault(); break;
|
||||
case '0': _ngState.zoom = 1; _ngState.panX = 0; _ngState.panY = 0; e.preventDefault(); break;
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('wheel', function(e) {
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
|
||||
const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||
const newZoom = Math.max(0.1, Math.min(10, _ngState.zoom * factor));
|
||||
// Zoom towards mouse position
|
||||
_ngState.panX = cx - (cx - _ngState.panX) * (newZoom / _ngState.zoom);
|
||||
_ngState.panY = cy - (cy - _ngState.panY) * (newZoom / _ngState.zoom);
|
||||
_ngState.zoom = newZoom;
|
||||
}, { passive: false });
|
||||
|
||||
// Cache text color to avoid getComputedStyle every frame
|
||||
const _labelColor = cssVar('--text-primary') || '#e0e0e0';
|
||||
|
||||
// Force simulation + render loop
|
||||
// Performance: 500 nodes brute-force repulsion: avg ~4ms/frame = 250fps headroom (measured Chrome 120, M1)
|
||||
var _perfFrameTimes = [], _perfLastTime = 0;
|
||||
function tick() {
|
||||
if (!document.getElementById('ngCanvas')) { _ngState.animId = null; return; }
|
||||
var now = performance.now();
|
||||
if (_perfLastTime) _perfFrameTimes.push(now - _perfLastTime);
|
||||
_perfLastTime = now;
|
||||
if (_perfFrameTimes.length === 100) {
|
||||
var avg = _perfFrameTimes.reduce(function(a, b) { return a + b; }, 0) / 100;
|
||||
console.log('[NeighborGraph perf] avg frame time over 100 frames: ' + avg.toFixed(2) + 'ms (' + (1000 / avg).toFixed(0) + ' fps)');
|
||||
_perfFrameTimes = [];
|
||||
}
|
||||
const st = _ngState;
|
||||
const nodes = st.nodes, edges = st.edges, idx = st.nodeIdx;
|
||||
|
||||
if (st.cooling > 0.001) {
|
||||
// Repulsion (all pairs — use grid for large sets, brute force for small)
|
||||
const k = 80; // repulsion constant
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
let dx = nodes[j].x - nodes[i].x;
|
||||
let dy = nodes[j].y - nodes[i].y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 < 1) { dx = Math.random() - 0.5; dy = Math.random() - 0.5; d2 = 1; }
|
||||
const f = k * k / d2;
|
||||
const fx = dx / Math.sqrt(d2) * f;
|
||||
const fy = dy / Math.sqrt(d2) * f;
|
||||
nodes[i].vx -= fx; nodes[i].vy -= fy;
|
||||
nodes[j].vx += fx; nodes[j].vy += fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction along edges
|
||||
const idealLen = 120;
|
||||
for (const e of edges) {
|
||||
const si = idx[e.source], ti = idx[e.target];
|
||||
if (si === undefined || ti === undefined) continue;
|
||||
const a = nodes[si], b = nodes[ti];
|
||||
let dx = b.x - a.x, dy = b.y - a.y;
|
||||
const d = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const f = (d - idealLen) * 0.05 * (0.5 + e.score * 0.5);
|
||||
const fx = dx / d * f, fy = dy / d * f;
|
||||
a.vx += fx; a.vy += fy;
|
||||
b.vx -= fx; b.vy -= fy;
|
||||
}
|
||||
|
||||
// Center gravity
|
||||
for (const n of nodes) {
|
||||
n.vx += (W / 2 - n.x) * 0.001;
|
||||
n.vy += (H / 2 - n.y) * 0.001;
|
||||
}
|
||||
|
||||
// Apply velocities with damping
|
||||
const damping = 0.85;
|
||||
for (const n of nodes) {
|
||||
if (n._pinned) { n.vx = 0; n.vy = 0; continue; }
|
||||
n.vx *= damping * st.cooling;
|
||||
n.vy *= damping * st.cooling;
|
||||
const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
|
||||
if (speed > 10) { n.vx *= 10 / speed; n.vy *= 10 / speed; }
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
}
|
||||
st.cooling *= 0.995;
|
||||
}
|
||||
|
||||
// Render
|
||||
ctx.save();
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.translate(st.panX, st.panY);
|
||||
ctx.scale(st.zoom, st.zoom);
|
||||
|
||||
// Edges
|
||||
for (const e of edges) {
|
||||
const si = idx[e.source], ti = idx[e.target];
|
||||
if (si === undefined || ti === undefined) continue;
|
||||
const a = nodes[si], b = nodes[ti];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.strokeStyle = e.ambiguous ? 'rgba(255,200,0,0.4)' : 'rgba(150,150,150,0.35)';
|
||||
ctx.lineWidth = Math.max(0.5, e.score * 4);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Nodes
|
||||
const roleColors = window.ROLE_COLORS || {};
|
||||
for (const n of nodes) {
|
||||
const color = roleColors[(n.role || '').toLowerCase()] || '#6b7280';
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
if (n === hoverNode) {
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
// Label
|
||||
const label = n.name || (n.pubkey ? n.pubkey.slice(0, 8) + '…' : '');
|
||||
if (label && st.zoom > 0.4) {
|
||||
ctx.fillStyle = _labelColor;
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(label, n.x, n.y + n.radius + 12);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
st.animId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
_ngState.animId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
})();
|
||||
|
||||
+85
-16
@@ -136,6 +136,13 @@ function getTimestampCustomFormat() {
|
||||
function pad2(v) { return String(v).padStart(2, '0'); }
|
||||
function pad3(v) { return String(v).padStart(3, '0'); }
|
||||
|
||||
function mergeUserHomeConfig(siteConfig, userTheme) {
|
||||
if (!siteConfig || !userTheme || !userTheme.home || typeof userTheme.home !== 'object') return siteConfig;
|
||||
const serverHome = (siteConfig.home && typeof siteConfig.home === 'object') ? siteConfig.home : {};
|
||||
siteConfig.home = Object.assign({}, serverHome, userTheme.home);
|
||||
return siteConfig;
|
||||
}
|
||||
|
||||
function formatIsoLike(d, timezone, includeMs) {
|
||||
const useUtc = timezone === 'utc';
|
||||
const year = useUtc ? d.getUTCFullYear() : d.getFullYear();
|
||||
@@ -787,30 +794,92 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
debouncedOnWS(function () { updateNavStats(); });
|
||||
|
||||
// --- Theme Customization ---
|
||||
// Fetch theme config and apply via customizer v2 pipeline
|
||||
// Fetch theme config and apply branding/colors before first render
|
||||
fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => {
|
||||
// Normalize timestamp defaults
|
||||
cfg = cfg || {};
|
||||
if (!cfg.timestamps) cfg.timestamps = {};
|
||||
const tsCfg = cfg.timestamps;
|
||||
window.SITE_CONFIG = cfg || {};
|
||||
if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {};
|
||||
const tsCfg = window.SITE_CONFIG.timestamps;
|
||||
if (tsCfg.defaultMode !== 'absolute' && tsCfg.defaultMode !== 'ago') tsCfg.defaultMode = 'ago';
|
||||
if (tsCfg.timezone !== 'utc' && tsCfg.timezone !== 'local') tsCfg.timezone = 'local';
|
||||
if (tsCfg.formatPreset !== 'iso' && tsCfg.formatPreset !== 'iso-seconds' && tsCfg.formatPreset !== 'locale') tsCfg.formatPreset = 'iso';
|
||||
if (typeof tsCfg.customFormat !== 'string') tsCfg.customFormat = '';
|
||||
tsCfg.allowCustomFormat = tsCfg.allowCustomFormat === true;
|
||||
|
||||
// Customizer v2: set server defaults and run full pipeline
|
||||
// (reads localStorage overrides → merges → sets SITE_CONFIG → applies CSS → dispatches theme-changed)
|
||||
if (window._customizerV2) {
|
||||
window._customizerV2.init(cfg);
|
||||
} else {
|
||||
// Fallback if customize-v2.js didn't load
|
||||
window.SITE_CONFIG = cfg;
|
||||
// User's localStorage preferences take priority over server config
|
||||
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
|
||||
window._SITE_CONFIG_ORIGINAL_HOME = JSON.parse(JSON.stringify(window.SITE_CONFIG.home || {}));
|
||||
mergeUserHomeConfig(window.SITE_CONFIG, userTheme);
|
||||
|
||||
// Apply CSS variable overrides from theme config (skipped if user has local overrides)
|
||||
if (!userTheme.theme && !userTheme.themeDark) {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const themeData = dark ? { ...(cfg.theme || {}), ...(cfg.themeDark || {}) } : (cfg.theme || {});
|
||||
const root = document.documentElement.style;
|
||||
const varMap = {
|
||||
accent: '--accent', accentHover: '--accent-hover',
|
||||
navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted',
|
||||
background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border',
|
||||
statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red',
|
||||
surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3',
|
||||
cardBg: '--card-bg', contentBg: '--content-bg', inputBg: '--input-bg',
|
||||
rowStripe: '--row-stripe', rowHover: '--row-hover', detailBg: '--detail-bg',
|
||||
selectedBg: '--selected-bg', sectionBg: '--section-bg',
|
||||
font: '--font', mono: '--mono'
|
||||
};
|
||||
for (const [key, cssVar] of Object.entries(varMap)) {
|
||||
if (themeData[key]) root.setProperty(cssVar, themeData[key]);
|
||||
}
|
||||
// Derived vars
|
||||
if (themeData.background) root.setProperty('--content-bg', themeData.contentBg || themeData.background);
|
||||
if (themeData.surface1) root.setProperty('--card-bg', themeData.cardBg || themeData.surface1);
|
||||
// Nav gradient
|
||||
if (themeData.navBg) {
|
||||
const nav = document.querySelector('.top-nav');
|
||||
if (nav) nav.style.background = `linear-gradient(135deg, ${themeData.navBg} 0%, ${themeData.navBg2 || themeData.navBg} 50%, ${themeData.navBg} 100%)`;
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
window.SITE_CONFIG = { timestamps: { defaultMode: 'ago', timezone: 'local', formatPreset: 'iso', customFormat: '', allowCustomFormat: false } };
|
||||
if (window._customizerV2) window._customizerV2.init(window.SITE_CONFIG);
|
||||
}).finally(() => {
|
||||
|
||||
// Apply node color overrides (skip if user has local preferences)
|
||||
if (cfg.nodeColors && !userTheme.nodeColors) {
|
||||
for (const [role, color] of Object.entries(cfg.nodeColors)) {
|
||||
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = color;
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = color;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply type color overrides (skip if user has local preferences)
|
||||
if (cfg.typeColors && !userTheme.typeColors) {
|
||||
for (const [type, color] of Object.entries(cfg.typeColors)) {
|
||||
if (window.TYPE_COLORS && type in window.TYPE_COLORS) window.TYPE_COLORS[type] = color;
|
||||
}
|
||||
if (window.syncBadgeColors) window.syncBadgeColors();
|
||||
}
|
||||
|
||||
// Apply branding (skip if user has local preferences)
|
||||
if (cfg.branding && !userTheme.branding) {
|
||||
if (cfg.branding.siteName) {
|
||||
document.title = cfg.branding.siteName;
|
||||
const brandText = document.querySelector('.brand-text');
|
||||
if (brandText) brandText.textContent = cfg.branding.siteName;
|
||||
}
|
||||
if (cfg.branding.logoUrl) {
|
||||
const brandIcon = document.querySelector('.brand-icon');
|
||||
if (brandIcon) {
|
||||
const img = document.createElement('img');
|
||||
img.src = cfg.branding.logoUrl;
|
||||
img.alt = cfg.branding.siteName || 'Logo';
|
||||
img.style.height = '24px';
|
||||
img.style.width = 'auto';
|
||||
brandIcon.replaceWith(img);
|
||||
}
|
||||
}
|
||||
if (cfg.branding.faviconUrl) {
|
||||
const favicon = document.querySelector('link[rel="icon"]');
|
||||
if (favicon) favicon.href = cfg.branding.faviconUrl;
|
||||
}
|
||||
}
|
||||
}).catch(() => { window.SITE_CONFIG = { timestamps: { defaultMode: 'ago', timezone: 'local', formatPreset: 'iso', customFormat: '', allowCustomFormat: false } }; }).finally(() => {
|
||||
if (!location.hash || location.hash === '#/') location.hash = '#/home';
|
||||
else navigate();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -86,7 +86,7 @@
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=__BUST__"></script>
|
||||
<script src="customize-v2.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="customize.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=__BUST__"></script>
|
||||
<script src="hop-resolver.js?v=__BUST__"></script>
|
||||
<script src="hop-display.js?v=__BUST__"></script>
|
||||
|
||||
@@ -1957,7 +1957,6 @@
|
||||
let lastPulse = performance.now();
|
||||
const pulseStart = lastPulse;
|
||||
function animatePulse(now) {
|
||||
if (!animLayer) return;
|
||||
if (now - pulseStart > 2000) {
|
||||
try { animLayer.removeLayer(ring); } catch {}
|
||||
return;
|
||||
@@ -2202,10 +2201,6 @@
|
||||
const startTime = performance.now();
|
||||
|
||||
function tick(now) {
|
||||
if (!animLayer || !pathsLayer) {
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
const elapsed = now - startTime;
|
||||
const t = Math.min(1, elapsed / DURATION_MS);
|
||||
const lat = from[0] + (to[0] - from[0]) * t;
|
||||
@@ -2250,11 +2245,6 @@
|
||||
// Fade out
|
||||
const fadeStart = performance.now();
|
||||
function fadeOut(now) {
|
||||
if (!animLayer || !pathsLayer) {
|
||||
charMarkers.length = 0;
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
const ft = Math.min(1, (now - fadeStart) / 300);
|
||||
if (ft >= 1) {
|
||||
for (const cm of charMarkers) try { animLayer.removeLayer(cm.marker); } catch {}
|
||||
@@ -2302,10 +2292,6 @@
|
||||
|
||||
let lastStep = performance.now();
|
||||
function animateLine(now) {
|
||||
if (!animLayer || !pathsLayer) {
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
const elapsed = now - lastStep;
|
||||
if (elapsed >= 33) {
|
||||
const ticks = Math.min(Math.floor(elapsed / 33), 4);
|
||||
@@ -2334,7 +2320,6 @@
|
||||
let fadeOp = mainOpacity;
|
||||
let lastFade = performance.now();
|
||||
function animateFade(now) {
|
||||
if (!pathsLayer) return;
|
||||
const fadeElapsed = now - lastFade;
|
||||
if (fadeElapsed >= 52) {
|
||||
const fadeTicks = Math.min(Math.floor(fadeElapsed / 52), 4);
|
||||
|
||||
+12
-23
@@ -749,30 +749,21 @@
|
||||
selectedReferenceNode = pubkey;
|
||||
neighborPubkeys = new Set();
|
||||
try {
|
||||
// Use affinity-based neighbor API (server-side disambiguation) instead of
|
||||
// client-side path walking which fails on hash collisions (#484)
|
||||
const data = await api('/nodes/' + pubkey + '/neighbors?min_count=3');
|
||||
for (const n of (data.neighbors || [])) {
|
||||
if (n.pubkey) neighborPubkeys.add(n.pubkey);
|
||||
// For ambiguous edges, include all candidates (better to show extra than miss)
|
||||
if (n.candidates) n.candidates.forEach(function(c) { if (c.pubkey) neighborPubkeys.add(c.pubkey); });
|
||||
}
|
||||
// If affinity data is insufficient, fall back to client-side path walking
|
||||
if (neighborPubkeys.size === 0) {
|
||||
const pathData = await api('/nodes/' + pubkey + '/paths');
|
||||
const paths = pathData.paths || [];
|
||||
for (const p of paths) {
|
||||
const hops = p.hops || [];
|
||||
for (var i = 0; i < hops.length; i++) {
|
||||
if (hops[i].pubkey === pubkey) {
|
||||
if (i > 0 && hops[i - 1].pubkey) neighborPubkeys.add(hops[i - 1].pubkey);
|
||||
if (i < hops.length - 1 && hops[i + 1].pubkey) neighborPubkeys.add(hops[i + 1].pubkey);
|
||||
}
|
||||
const data = await api('/nodes/' + pubkey + '/paths');
|
||||
const paths = data.paths || [];
|
||||
for (const p of paths) {
|
||||
const hops = p.hops || [];
|
||||
// Find the reference node in the path; direct neighbors are adjacent hops
|
||||
for (let i = 0; i < hops.length; i++) {
|
||||
if (hops[i].pubkey === pubkey) {
|
||||
if (i > 0 && hops[i - 1].pubkey) neighborPubkeys.add(hops[i - 1].pubkey);
|
||||
if (i < hops.length - 1 && hops[i + 1].pubkey) neighborPubkeys.add(hops[i + 1].pubkey);
|
||||
}
|
||||
}
|
||||
// (Redundant block removed — the main loop above already handles first/last hops)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch neighbors for', pubkey, ':', e);
|
||||
console.warn('Failed to fetch neighbor paths for', pubkey, '— neighbor filter may be incomplete:', e);
|
||||
neighborPubkeys = new Set();
|
||||
}
|
||||
// Update sidebar UI
|
||||
@@ -788,9 +779,8 @@
|
||||
if (cb) cb.checked = true;
|
||||
renderMarkers();
|
||||
}
|
||||
// Expose for popup onclick and testing
|
||||
// Expose for popup onclick
|
||||
window._mapSelectRefNode = selectReferenceNode;
|
||||
window._mapGetNeighborPubkeys = function() { return neighborPubkeys ? Array.from(neighborPubkeys) : []; };
|
||||
|
||||
function buildPopup(node) {
|
||||
const key = node.public_key ? truncate(node.public_key, 16) : '—';
|
||||
@@ -849,7 +839,6 @@
|
||||
selectedReferenceNode = null;
|
||||
neighborPubkeys = null;
|
||||
delete window._mapSelectRefNode;
|
||||
delete window._mapGetNeighborPubkeys;
|
||||
}
|
||||
|
||||
function toggleHeatmap(on) {
|
||||
|
||||
-126
@@ -175,110 +175,6 @@
|
||||
return `<div style="font-size:11px;color:var(--text-muted);margin:-2px 0 6px;padding:6px 10px;background:var(--surface-2);border-radius:4px;border-left:3px solid var(--status-yellow)">Adverts show varying hash sizes (<strong>${sizes.join('-byte, ')}-byte</strong>). This is a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--accent)">known bug</a> where automatic adverts ignore the configured multibyte path setting. Fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--accent)">repeater v1.14.1</a>.</div>`;
|
||||
}
|
||||
|
||||
// ─── Neighbor section helpers ───────────────────────────────────────────────
|
||||
|
||||
// Cache: pubkey → { data, ts }
|
||||
var _neighborCache = {};
|
||||
|
||||
function getConfidenceIndicator(entry) {
|
||||
if (entry.ambiguous) return { icon: '⚠️', label: 'AMBIGUOUS', cls: 'confidence-ambiguous' };
|
||||
if (entry.count <= 1) return { icon: '🔴', label: 'LOW', cls: 'confidence-low' };
|
||||
if (entry.score >= 0.5 && entry.count >= 3) return { icon: '🟢', label: 'HIGH', cls: 'confidence-high' };
|
||||
return { icon: '🟡', label: 'MEDIUM', cls: 'confidence-medium' };
|
||||
}
|
||||
|
||||
function renderNeighborRows(neighbors, limit) {
|
||||
var sorted = neighbors.slice().sort(function(a, b) {
|
||||
return (b.score || b.affinity || 0) - (a.score || a.affinity || 0);
|
||||
});
|
||||
var items = limit ? sorted.slice(0, limit) : sorted;
|
||||
return items.map(function(nb) {
|
||||
var conf = getConfidenceIndicator(nb);
|
||||
var name = nb.name || (nb.prefix + '… (unknown)');
|
||||
var nameHtml = nb.pubkey
|
||||
? '<a href="#/nodes/' + encodeURIComponent(nb.pubkey) + '">' + escapeHtml(name) + '</a>'
|
||||
: '<span class="text-muted">' + escapeHtml(name) + '</span>';
|
||||
var role = nb.role || '—';
|
||||
var roleBadge = nb.role
|
||||
? '<span class="badge" style="background:' + (ROLE_COLORS[nb.role] || 'var(--surface-2)') + ';color:#fff;font-size:10px">' + escapeHtml(role) + '</span>'
|
||||
: '<span class="text-muted">—</span>';
|
||||
var scoreTitle = 'Observations: ' + nb.count;
|
||||
if (nb.avg_snr != null) scoreTitle += ' · Avg SNR: ' + Number(nb.avg_snr).toFixed(1) + ' dB';
|
||||
var showOnMap = nb.pubkey
|
||||
? ' <button class="btn-link neighbor-show-map" data-pubkey="' + escapeHtml(nb.pubkey) + '" style="font-size:11px;padding:1px 6px;white-space:nowrap">📍 Map</button>'
|
||||
: '';
|
||||
return '<tr>' +
|
||||
'<td style="font-weight:600">' + nameHtml + '</td>' +
|
||||
'<td>' + roleBadge + '</td>' +
|
||||
'<td title="' + escapeHtml(scoreTitle) + '">' + Number(nb.score).toFixed(2) + '</td>' +
|
||||
'<td>' + nb.count + '</td>' +
|
||||
'<td>' + renderNodeTimestampHtml(nb.last_seen) + '</td>' +
|
||||
'<td><span title="' + conf.label + '">' + conf.icon + '</span></td>' +
|
||||
'<td style="text-align:right">' + showOnMap + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderNeighborTable(neighbors, limit) {
|
||||
return '<table class="data-table" style="font-size:12px">' +
|
||||
'<thead><tr><th>Neighbor</th><th>Role</th><th>Score</th><th>Obs</th><th>Last Seen</th><th>Conf</th><th></th></tr></thead>' +
|
||||
'<tbody>' + renderNeighborRows(neighbors, limit) + '</tbody></table>';
|
||||
}
|
||||
|
||||
function fetchAndRenderNeighbors(pubkey, containerId, opts) {
|
||||
opts = opts || {};
|
||||
var limit = opts.limit || 0;
|
||||
var headerSelector = opts.headerSelector;
|
||||
var viewAllPubkey = opts.viewAllPubkey;
|
||||
|
||||
// Check cache
|
||||
var cached = _neighborCache[pubkey];
|
||||
if (cached && (Date.now() - cached.ts < 300000)) { // 5 min cache
|
||||
renderNeighborData(cached.data, containerId, limit, headerSelector, viewAllPubkey);
|
||||
return;
|
||||
}
|
||||
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/neighbors', { ttl: CLIENT_TTL.nodeDetail }).then(function(data) {
|
||||
_neighborCache[pubkey] = { data: data, ts: Date.now() };
|
||||
renderNeighborData(data, containerId, limit, headerSelector, viewAllPubkey);
|
||||
}).catch(function() {
|
||||
var el = document.getElementById(containerId);
|
||||
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Could not load neighbor data</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderNeighborData(data, containerId, limit, headerSelector, viewAllPubkey) {
|
||||
var el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
if (!data || !data.neighbors || !data.neighbors.length) {
|
||||
el.innerHTML = '<div class="text-muted" style="padding:8px">No neighbor data available yet. Neighbor relationships are built from observed packet paths over time.</div>';
|
||||
if (headerSelector) {
|
||||
var h = document.querySelector(headerSelector);
|
||||
if (h) h.textContent = 'Neighbors (0)';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (headerSelector) {
|
||||
var h = document.querySelector(headerSelector);
|
||||
if (h) h.textContent = 'Neighbors (' + data.neighbors.length + ')';
|
||||
}
|
||||
var html = renderNeighborTable(data.neighbors, limit);
|
||||
if (limit && data.neighbors.length > limit && viewAllPubkey) {
|
||||
html += '<div style="margin-top:6px;text-align:right"><a href="#/nodes/' + encodeURIComponent(viewAllPubkey) + '?section=node-neighbors" style="font-size:12px">View all ' + data.neighbors.length + ' neighbors →</a></div>';
|
||||
}
|
||||
el.innerHTML = html;
|
||||
|
||||
// Wire up "Show on Map" buttons via event delegation
|
||||
el.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.neighbor-show-map');
|
||||
if (!btn) return;
|
||||
var pk = btn.getAttribute('data-pubkey');
|
||||
if (pk) location.hash = '#/map?node=' + encodeURIComponent(pk);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── End neighbor helpers ─────────────────────────────────────────────────
|
||||
|
||||
let directNode = null; // set when navigating directly to #/nodes/:pubkey
|
||||
|
||||
let regionChangeHandler = null;
|
||||
@@ -451,11 +347,6 @@
|
||||
</table>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="node-full-card" id="node-neighbors">
|
||||
<h4 id="fullNeighborsHeader">Neighbors</h4>
|
||||
<div id="fullNeighborsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="node-full-card" id="fullPathsSection">
|
||||
<h4>Paths Through This Node</h4>
|
||||
<div id="fullPathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths…</div></div>
|
||||
@@ -536,11 +427,6 @@
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Fetch neighbors for this node (full-screen view)
|
||||
fetchAndRenderNeighbors(n.public_key, 'fullNeighborsContent', {
|
||||
headerSelector: '#fullNeighborsHeader'
|
||||
});
|
||||
|
||||
// Fetch paths through this node (full-screen view)
|
||||
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => {
|
||||
const el = document.getElementById('fullPathsContent');
|
||||
@@ -933,11 +819,6 @@
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="node-detail-section" id="panelNeighborsSection">
|
||||
<h4 id="panelNeighborsHeader">Neighbors</h4>
|
||||
<div id="panelNeighborsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="node-detail-section" id="pathsSection">
|
||||
<h4>Paths Through This Node</h4>
|
||||
<div id="pathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths…</div></div>
|
||||
@@ -1008,13 +889,6 @@
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Fetch neighbors for this node (condensed panel — top 5)
|
||||
fetchAndRenderNeighbors(n.public_key, 'panelNeighborsContent', {
|
||||
limit: 5,
|
||||
headerSelector: '#panelNeighborsHeader',
|
||||
viewAllPubkey: n.public_key
|
||||
});
|
||||
|
||||
// Fetch paths through this node
|
||||
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => {
|
||||
const el = document.getElementById('pathsContent');
|
||||
|
||||
@@ -20,17 +20,6 @@ window.getParsedPath = function getParsedPath(p) {
|
||||
return p._parsedPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear cached _parsedPath/_parsedDecoded from a packet object.
|
||||
* Must be called after spreading a parent packet into an observation/child,
|
||||
* otherwise the child inherits stale cached values from the parent (issue #504).
|
||||
*/
|
||||
window.clearParsedCache = function clearParsedCache(p) {
|
||||
delete p._parsedPath;
|
||||
delete p._parsedDecoded;
|
||||
return p;
|
||||
};
|
||||
|
||||
window.getParsedDecoded = function getParsedDecoded(p) {
|
||||
if (p._parsedDecoded !== undefined) return p._parsedDecoded;
|
||||
var raw = p.decoded_json;
|
||||
|
||||
+3
-5
@@ -269,7 +269,6 @@
|
||||
if (obs) {
|
||||
expandedHashes.add(h);
|
||||
const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, timestamp: obs.timestamp, first_seen: obs.timestamp};
|
||||
clearParsedCache(obsPacket);
|
||||
selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id);
|
||||
} else {
|
||||
selectPacket(data.packet.id, h, data);
|
||||
@@ -502,7 +501,7 @@
|
||||
await Promise.all(multiObs.map(async (p) => {
|
||||
try {
|
||||
const d = await api(`/packets/${p.hash}`);
|
||||
if (d?.observations) p._children = d.observations.map(o => clearParsedCache({...d.packet, ...o, _isObservation: true}));
|
||||
if (d?.observations) p._children = d.observations.map(o => ({...d.packet, ...o, _isObservation: true}));
|
||||
} catch {}
|
||||
}));
|
||||
// Flatten: replace grouped packets with individual observations
|
||||
@@ -838,7 +837,7 @@
|
||||
try {
|
||||
const data = await api(`/packets/${p.hash}`);
|
||||
if (data?.packet && data.observations) {
|
||||
p._children = data.observations.map(o => clearParsedCache({...data.packet, ...o, _isObservation: true}));
|
||||
p._children = data.observations.map(o => ({...data.packet, ...o, _isObservation: true}));
|
||||
p._fetchedData = data;
|
||||
}
|
||||
} catch {}
|
||||
@@ -1011,7 +1010,6 @@
|
||||
if (child) {
|
||||
const parentData = group._fetchedData;
|
||||
const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, timestamp: child.timestamp, first_seen: child.timestamp} : child;
|
||||
if (parentData) { clearParsedCache(obsPacket); }
|
||||
selectPacket(child.id, parentHash, {packet: obsPacket, breakdown: parentData?.breakdown, observations: parentData?.observations}, child.id);
|
||||
}
|
||||
}
|
||||
@@ -1981,7 +1979,7 @@
|
||||
if (!pkt) return;
|
||||
const group = packets.find(p => p.hash === hash);
|
||||
if (group && data.observations) {
|
||||
group._children = data.observations.map(o => clearParsedCache({...pkt, ...o, _isObservation: true}));
|
||||
group._children = data.observations.map(o => ({...pkt, ...o, _isObservation: true}));
|
||||
group._fetchedData = data;
|
||||
// Sort children based on current sort mode
|
||||
sortGroupChildren(group);
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
/* Unit tests for customizer v2 core functions */
|
||||
'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 makeSandbox() {
|
||||
const storage = {};
|
||||
const localStorage = {
|
||||
_data: storage,
|
||||
getItem(k) { return k in storage ? storage[k] : null; },
|
||||
setItem(k, v) { storage[k] = String(v); },
|
||||
removeItem(k) { delete storage[k]; },
|
||||
clear() { for (const k in storage) delete storage[k]; }
|
||||
};
|
||||
const ctx = {
|
||||
window: {
|
||||
addEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
SITE_CONFIG: {},
|
||||
_SITE_CONFIG_ORIGINAL_HOME: null,
|
||||
},
|
||||
document: {
|
||||
readyState: 'loading',
|
||||
createElement: (tag) => ({
|
||||
id: '', textContent: '', innerHTML: '', className: '',
|
||||
setAttribute: () => {}, appendChild: () => {},
|
||||
style: {}, addEventListener: () => {},
|
||||
querySelectorAll: () => [], querySelector: () => null,
|
||||
}),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
documentElement: {
|
||||
style: { setProperty: () => {}, removeProperty: () => {}, getPropertyValue: () => '' },
|
||||
dataset: { theme: 'dark' },
|
||||
getAttribute: () => 'dark',
|
||||
},
|
||||
},
|
||||
console,
|
||||
localStorage,
|
||||
setTimeout: (fn) => fn(),
|
||||
clearTimeout: () => {},
|
||||
Date, Math, Array, Object, JSON, String, Number, Boolean,
|
||||
parseInt, parseFloat, isNaN, Infinity, NaN, undefined,
|
||||
MutationObserver: class { observe() {} },
|
||||
HashChangeEvent: class {},
|
||||
CustomEvent: class CustomEvent { constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; } },
|
||||
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
||||
};
|
||||
ctx.window.localStorage = localStorage;
|
||||
ctx.self = ctx.window;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadCustomizer() {
|
||||
const ctx = makeSandbox();
|
||||
const code = fs.readFileSync('public/customize-v2.js', 'utf8');
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(code, ctx, { filename: 'customize-v2.js' });
|
||||
return { ctx, api: ctx.window._customizerV2, ls: ctx.localStorage };
|
||||
}
|
||||
|
||||
console.log('\n📋 Customizer V2 — Core Function Tests\n');
|
||||
|
||||
// ── readOverrides ──
|
||||
console.log('readOverrides:');
|
||||
test('returns {} when key is absent', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.readOverrides();
|
||||
assert.strictEqual(JSON.stringify(result), '{}');
|
||||
});
|
||||
|
||||
test('returns {} when key contains invalid JSON', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', 'not json{{{');
|
||||
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
||||
});
|
||||
|
||||
test('returns {} when key contains a non-object (string)', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '"just a string"');
|
||||
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
||||
});
|
||||
|
||||
test('returns {} when key contains an array', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '[1,2,3]');
|
||||
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
||||
});
|
||||
|
||||
test('returns {} when key contains a number', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '42');
|
||||
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
||||
});
|
||||
|
||||
test('returns parsed object when valid', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const data = { theme: { accent: '#ff0000' } };
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify(data));
|
||||
assert.deepStrictEqual(api.readOverrides(), data);
|
||||
});
|
||||
|
||||
// ── writeOverrides ──
|
||||
console.log('\nwriteOverrides:');
|
||||
test('writes serialized JSON to localStorage', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const data = { theme: { accent: '#ff0000' } };
|
||||
api.writeOverrides(data);
|
||||
assert.deepStrictEqual(JSON.parse(ls.getItem('cs-theme-overrides')), data);
|
||||
});
|
||||
|
||||
test('removes key when delta is empty {}', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '{"theme":{}}');
|
||||
api.writeOverrides({});
|
||||
assert.strictEqual(ls.getItem('cs-theme-overrides'), null);
|
||||
});
|
||||
|
||||
test('round-trips correctly (write → read = identical)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const data = { theme: { accent: '#abc', text: '#def' }, nodeColors: { repeater: '#111' } };
|
||||
api.writeOverrides(data);
|
||||
assert.deepStrictEqual(api.readOverrides(), data);
|
||||
});
|
||||
|
||||
test('strips invalid color values silently', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
api.writeOverrides({ theme: { accent: 'not-a-color' } });
|
||||
// Invalid color is stripped by _validateDelta; remaining empty object is stored as '{}'
|
||||
const stored = JSON.parse(ls.getItem('cs-theme-overrides'));
|
||||
assert.strictEqual(stored.theme, undefined);
|
||||
});
|
||||
|
||||
test('strips out-of-range opacity', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
api.writeOverrides({ heatmapOpacity: 1.5 });
|
||||
const stored1 = JSON.parse(ls.getItem('cs-theme-overrides'));
|
||||
assert.strictEqual(stored1.heatmapOpacity, undefined);
|
||||
api.writeOverrides({ heatmapOpacity: -0.1 });
|
||||
const stored2 = JSON.parse(ls.getItem('cs-theme-overrides'));
|
||||
assert.strictEqual(stored2.heatmapOpacity, undefined);
|
||||
});
|
||||
|
||||
test('accepts valid opacity', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
api.writeOverrides({ heatmapOpacity: 0.5 });
|
||||
const stored = JSON.parse(ls.getItem('cs-theme-overrides'));
|
||||
assert.strictEqual(stored.heatmapOpacity, 0.5);
|
||||
});
|
||||
|
||||
// ── computeEffective ──
|
||||
console.log('\ncomputeEffective:');
|
||||
test('returns server defaults when overrides is {}', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa', text: '#bbb' }, nodeColors: { repeater: '#ccc' } };
|
||||
const result = api.computeEffective(defaults, {});
|
||||
assert.deepStrictEqual(result, defaults);
|
||||
});
|
||||
|
||||
test('overrides a single key in a section', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa', text: '#bbb' } };
|
||||
const result = api.computeEffective(defaults, { theme: { accent: '#ff0000' } });
|
||||
assert.strictEqual(result.theme.accent, '#ff0000');
|
||||
assert.strictEqual(result.theme.text, '#bbb');
|
||||
});
|
||||
|
||||
test('overrides multiple keys across sections', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa' }, nodeColors: { repeater: '#bbb' } };
|
||||
const result = api.computeEffective(defaults, { theme: { accent: '#111' }, nodeColors: { repeater: '#222' } });
|
||||
assert.strictEqual(result.theme.accent, '#111');
|
||||
assert.strictEqual(result.nodeColors.repeater, '#222');
|
||||
});
|
||||
|
||||
test('does not mutate either input', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa' } };
|
||||
const overrides = { theme: { accent: '#bbb' } };
|
||||
const defCopy = JSON.stringify(defaults);
|
||||
const ovrCopy = JSON.stringify(overrides);
|
||||
api.computeEffective(defaults, overrides);
|
||||
assert.strictEqual(JSON.stringify(defaults), defCopy);
|
||||
assert.strictEqual(JSON.stringify(overrides), ovrCopy);
|
||||
});
|
||||
|
||||
test('handles missing sections in overrides gracefully', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa' }, nodeColors: { repeater: '#bbb' } };
|
||||
const result = api.computeEffective(defaults, { theme: { accent: '#ccc' } });
|
||||
assert.strictEqual(result.nodeColors.repeater, '#bbb');
|
||||
});
|
||||
|
||||
test('array values in home are fully replaced, not merged', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { home: { steps: [{ emoji: '1', title: 'a', description: 'b' }], heroTitle: 'X' } };
|
||||
const overrides = { home: { steps: [{ emoji: '2', title: 'c', description: 'd' }, { emoji: '3', title: 'e', description: 'f' }] } };
|
||||
const result = api.computeEffective(defaults, overrides);
|
||||
assert.strictEqual(result.home.steps.length, 2);
|
||||
assert.strictEqual(result.home.steps[0].emoji, '2');
|
||||
assert.strictEqual(result.home.heroTitle, 'X'); // untouched
|
||||
});
|
||||
|
||||
test('top-level scalars are directly replaced', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { heatmapOpacity: 0.5 };
|
||||
const result = api.computeEffective(defaults, { heatmapOpacity: 0.8 });
|
||||
assert.strictEqual(result.heatmapOpacity, 0.8);
|
||||
});
|
||||
|
||||
// ── validateShape ──
|
||||
console.log('\nvalidateShape:');
|
||||
test('accepts valid delta objects', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.validateShape({ theme: { accent: '#fff' }, heatmapOpacity: 0.5 });
|
||||
assert.strictEqual(result.valid, true);
|
||||
});
|
||||
|
||||
test('accepts empty object', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape({}).valid, true);
|
||||
});
|
||||
|
||||
test('rejects non-objects (string)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape('hello').valid, false);
|
||||
});
|
||||
|
||||
test('rejects non-objects (array)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape([1, 2]).valid, false);
|
||||
});
|
||||
|
||||
test('rejects non-objects (null)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape(null).valid, false);
|
||||
});
|
||||
|
||||
test('warns on unknown top-level keys', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.validateShape({ unknownKey: {} });
|
||||
// Unknown keys produce a console.warn but validateShape still returns valid
|
||||
assert.strictEqual(result.valid, true);
|
||||
assert.strictEqual(result.errors.length, 0);
|
||||
});
|
||||
|
||||
test('validates section types (rejects non-object section)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.validateShape({ theme: 'not an object' });
|
||||
assert.strictEqual(result.valid, false);
|
||||
});
|
||||
|
||||
test('accepts valid rgb() color values in theme', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.validateShape({ theme: { accent: 'rgb(1,2,3)' } });
|
||||
assert.strictEqual(result.valid, true);
|
||||
});
|
||||
|
||||
test('rejects out-of-range opacity values', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape({ heatmapOpacity: 2.0 }).valid, false);
|
||||
assert.strictEqual(api.validateShape({ liveHeatmapOpacity: -1 }).valid, false);
|
||||
});
|
||||
|
||||
// ── migrateOldKeys ──
|
||||
console.log('\nmigrateOldKeys:');
|
||||
test('migrates all 7 keys correctly', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' }, branding: { siteName: 'Test' } }));
|
||||
ls.setItem('meshcore-timestamp-mode', 'absolute');
|
||||
ls.setItem('meshcore-timestamp-timezone', 'utc');
|
||||
ls.setItem('meshcore-timestamp-format', 'iso-seconds');
|
||||
ls.setItem('meshcore-timestamp-custom-format', 'YYYY-MM-DD');
|
||||
ls.setItem('meshcore-heatmap-opacity', '0.7');
|
||||
ls.setItem('meshcore-live-heatmap-opacity', '0.3');
|
||||
const result = api.migrateOldKeys();
|
||||
assert.strictEqual(result.theme.accent, '#f00');
|
||||
assert.strictEqual(result.branding.siteName, 'Test');
|
||||
assert.strictEqual(result.timestamps.defaultMode, 'absolute');
|
||||
assert.strictEqual(result.timestamps.timezone, 'utc');
|
||||
assert.strictEqual(result.heatmapOpacity, 0.7);
|
||||
assert.strictEqual(result.liveHeatmapOpacity, 0.3);
|
||||
// Legacy keys removed
|
||||
assert.strictEqual(ls.getItem('meshcore-user-theme'), null);
|
||||
assert.strictEqual(ls.getItem('meshcore-timestamp-mode'), null);
|
||||
// New key written
|
||||
assert.notStrictEqual(ls.getItem('cs-theme-overrides'), null);
|
||||
});
|
||||
|
||||
test('handles partial migration (only some keys)', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('meshcore-timestamp-mode', 'ago');
|
||||
const result = api.migrateOldKeys();
|
||||
assert.strictEqual(result.timestamps.defaultMode, 'ago');
|
||||
assert.strictEqual(ls.getItem('meshcore-timestamp-mode'), null);
|
||||
});
|
||||
|
||||
test('handles invalid JSON in meshcore-user-theme', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('meshcore-user-theme', '{bad json');
|
||||
const result = api.migrateOldKeys();
|
||||
// Should not crash, returns delta (possibly empty besides what was valid)
|
||||
assert(result !== null);
|
||||
assert.strictEqual(ls.getItem('meshcore-user-theme'), null);
|
||||
});
|
||||
|
||||
test('skips migration if cs-theme-overrides already exists', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '{"theme":{}}');
|
||||
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' } }));
|
||||
const result = api.migrateOldKeys();
|
||||
assert.strictEqual(result, null);
|
||||
// Legacy key NOT removed (migration skipped entirely)
|
||||
assert.notStrictEqual(ls.getItem('meshcore-user-theme'), null);
|
||||
});
|
||||
|
||||
test('returns null when no legacy keys found', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.migrateOldKeys(), null);
|
||||
});
|
||||
|
||||
test('drops unknown keys from meshcore-user-theme', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' }, unknownStuff: 'hi' }));
|
||||
const result = api.migrateOldKeys();
|
||||
assert.strictEqual(result.theme.accent, '#f00');
|
||||
assert.strictEqual(result.unknownStuff, undefined);
|
||||
});
|
||||
|
||||
// ── THEME_CSS_MAP completeness ──
|
||||
console.log('\nTHEME_CSS_MAP:');
|
||||
test('includes surface3 mapping', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.THEME_CSS_MAP.surface3, '--surface-3');
|
||||
});
|
||||
|
||||
test('includes sectionBg mapping', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.THEME_CSS_MAP.sectionBg, '--section-bg');
|
||||
});
|
||||
|
||||
test('matches all keys from old app.js varMap', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const expectedKeys = [
|
||||
'accent', 'accentHover', 'navBg', 'navBg2', 'navText', 'navTextMuted',
|
||||
'background', 'text', 'textMuted', 'border',
|
||||
'statusGreen', 'statusYellow', 'statusRed',
|
||||
'surface1', 'surface2', 'surface3',
|
||||
'cardBg', 'contentBg', 'inputBg',
|
||||
'rowStripe', 'rowHover', 'detailBg',
|
||||
'selectedBg', 'sectionBg',
|
||||
'font', 'mono'
|
||||
];
|
||||
for (const key of expectedKeys) {
|
||||
assert(key in api.THEME_CSS_MAP, `Missing key: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── _isOverridden tests ──
|
||||
console.log('\n_isOverridden (value comparison):');
|
||||
|
||||
test('returns false when no overrides exist', () => {
|
||||
const { api } = loadCustomizer();
|
||||
api.init({ theme: { accent: '#aaa' } });
|
||||
assert.strictEqual(api.isOverridden('theme', 'accent'), false);
|
||||
});
|
||||
|
||||
test('returns false when override matches server default', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#aaa' } }));
|
||||
api.init({ theme: { accent: '#aaa' } });
|
||||
assert.strictEqual(api.isOverridden('theme', 'accent'), false);
|
||||
});
|
||||
|
||||
test('returns true when override differs from server default', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
|
||||
api.init({ theme: { accent: '#aaa' } });
|
||||
assert.strictEqual(api.isOverridden('theme', 'accent'), true);
|
||||
});
|
||||
|
||||
test('returns false for key not in overrides', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
|
||||
api.init({ theme: { accent: '#aaa', border: '#ccc' } });
|
||||
assert.strictEqual(api.isOverridden('theme', 'border'), false);
|
||||
});
|
||||
|
||||
test('returns true when server has no default for overridden key', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
|
||||
api.init({});
|
||||
assert.strictEqual(api.isOverridden('theme', 'accent'), true);
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
+6
-501
@@ -85,7 +85,7 @@ async function run() {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('cs-theme-overrides');
|
||||
localStorage.removeItem('meshcore-user-theme');
|
||||
window.SITE_CONFIG = window.SITE_CONFIG || {};
|
||||
window.SITE_CONFIG.home = {
|
||||
heroTitle: 'Server Hero (E2E)',
|
||||
@@ -122,18 +122,18 @@ async function run() {
|
||||
const homeTab = page.locator('.cust-tab[data-tab="home"]');
|
||||
await homeTab.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await homeTab.click();
|
||||
const heroInput = page.locator('[data-cv2-field="home.heroTitle"]');
|
||||
const heroInput = page.locator('#cust-heroTitle');
|
||||
if (await heroInput.count() === 0) {
|
||||
console.log(' ⏭️ home.heroTitle input not found — TODO: requires running server');
|
||||
console.log(' ⏭️ #cust-heroTitle not found — TODO: requires running server');
|
||||
return;
|
||||
}
|
||||
await heroInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await heroInput.fill(editedHero);
|
||||
await page.waitForTimeout(700); // debounce is 300ms, allow margin
|
||||
await page.waitForTimeout(700); // autoSave debounce is 500ms
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
const persistedHero = await page.evaluate(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
|
||||
const saved = JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}');
|
||||
return saved && saved.home ? saved.home.heroTitle : '';
|
||||
} catch {
|
||||
return '';
|
||||
@@ -550,7 +550,7 @@ async function run() {
|
||||
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#analyticsTabs');
|
||||
const tabs = await page.$$('#analyticsTabs .tab-btn');
|
||||
assert(tabs.length >= 10, `Expected >=10 analytics tabs, got ${tabs.length}`);
|
||||
assert(tabs.length >= 8, `Expected >=8 analytics tabs, got ${tabs.length}`);
|
||||
// Overview tab should be active by default and show stat cards
|
||||
await page.waitForSelector('#analyticsContent .stat-card', { timeout: 8000 });
|
||||
const cards = await page.$$('#analyticsContent .stat-card');
|
||||
@@ -624,53 +624,6 @@ async function run() {
|
||||
assert(content.length > 10, 'Distance tab should render content');
|
||||
});
|
||||
|
||||
await test('Analytics Neighbor Graph tab renders canvas and stats', async () => {
|
||||
await page.click('[data-tab="neighbor-graph"]');
|
||||
await page.waitForSelector('#ngCanvas', { timeout: 8000 });
|
||||
const hasCanvas = await page.$('#ngCanvas');
|
||||
assert(hasCanvas, 'Neighbor Graph tab should have a canvas element');
|
||||
const hasStats = await page.$$eval('#ngStats .stat-card', els => els.length);
|
||||
assert(hasStats >= 3, `Neighbor Graph stats should have >=3 cards, got ${hasStats}`);
|
||||
// Verify filters exist
|
||||
const hasSlider = await page.$('#ngMinScore');
|
||||
assert(hasSlider, 'Should have min score slider');
|
||||
const hasConfidence = await page.$('#ngConfidence');
|
||||
assert(hasConfidence, 'Should have confidence filter');
|
||||
});
|
||||
|
||||
await test('Analytics Neighbor Graph filter changes update stats', async () => {
|
||||
// Capture edge count before filter
|
||||
const edgesBefore = await page.$eval('#ngStats', el => {
|
||||
const cards = el.querySelectorAll('.stat-card');
|
||||
for (const c of cards) {
|
||||
if (c.textContent.toLowerCase().includes('edge')) {
|
||||
const m = c.textContent.match(/\d+/);
|
||||
if (m) return parseInt(m[0], 10);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
// Set min score slider to high value to reduce edges
|
||||
await page.$eval('#ngMinScore', el => { el.value = 90; el.dispatchEvent(new Event('input')); });
|
||||
await page.waitForTimeout(300);
|
||||
const edgesAfter = await page.$eval('#ngStats', el => {
|
||||
const cards = el.querySelectorAll('.stat-card');
|
||||
for (const c of cards) {
|
||||
if (c.textContent.toLowerCase().includes('edge')) {
|
||||
const m = c.textContent.match(/\d+/);
|
||||
if (m) return parseInt(m[0], 10);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
assert(edgesBefore >= 0, 'Should find edge count in stats before filter');
|
||||
assert(edgesAfter >= 0, 'Should find edge count in stats after filter');
|
||||
assert(edgesAfter <= edgesBefore, `Raising min score should reduce (or keep) edge count: ${edgesBefore} → ${edgesAfter}`);
|
||||
// Reset slider
|
||||
await page.$eval('#ngMinScore', el => { el.value = 0; el.dispatchEvent(new Event('input')); });
|
||||
await page.waitForTimeout(200);
|
||||
});
|
||||
|
||||
// --- Group: Compare page ---
|
||||
|
||||
await test('Compare page loads with observer dropdowns', async () => {
|
||||
@@ -1062,454 +1015,6 @@ async function run() {
|
||||
assert(hexDump, 'Hex dump should be visible after selecting a packet');
|
||||
});
|
||||
|
||||
// --- Group: Customizer v2 E2E tests ---
|
||||
|
||||
await test('Customizer v2: setOverride persists and applies CSS', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
// Clear any existing overrides
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
// Set an override via the API
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
window._customizerV2.setOverride('theme', 'accent', '#ff0000');
|
||||
// Wait for debounce
|
||||
return new Promise(resolve => setTimeout(() => {
|
||||
const stored = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
|
||||
const cssVal = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
|
||||
resolve({ stored, cssVal });
|
||||
}, 500));
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.stored.theme && result.stored.theme.accent === '#ff0000',
|
||||
'Override not persisted to localStorage');
|
||||
assert(result.cssVal === '#ff0000',
|
||||
`CSS variable --accent expected #ff0000 but got "${result.cssVal}"`);
|
||||
// Cleanup
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: clearOverride resets to server default', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
// Get the server default accent
|
||||
window._customizerV2.setOverride('theme', 'accent', '#ff0000');
|
||||
return new Promise(resolve => setTimeout(() => {
|
||||
window._customizerV2.clearOverride('theme', 'accent');
|
||||
const stored = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
|
||||
const hasAccent = stored.theme && stored.theme.hasOwnProperty('accent');
|
||||
resolve({ hasAccent });
|
||||
}, 500));
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(!result.hasAccent, 'accent should be removed from overrides after clearOverride');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: full reset clears all overrides', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' }, nodeColors: { repeater: '#00ff00' } }));
|
||||
// Simulate full reset
|
||||
localStorage.removeItem('cs-theme-overrides');
|
||||
const stored = localStorage.getItem('cs-theme-overrides');
|
||||
return { stored };
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.stored === null, 'cs-theme-overrides should be null after full reset');
|
||||
});
|
||||
|
||||
await test('Customizer v2: export produces valid JSON', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
// Set some overrides
|
||||
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#123456' } }));
|
||||
const delta = window._customizerV2.readOverrides();
|
||||
const json = JSON.stringify(delta, null, 2);
|
||||
try { JSON.parse(json); return { valid: true, hasAccent: delta.theme && delta.theme.accent === '#123456' }; }
|
||||
catch { return { valid: false }; }
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.valid, 'Exported JSON must be valid');
|
||||
assert(result.hasAccent, 'Exported JSON must contain the stored override');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: import applies overrides', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
localStorage.removeItem('cs-theme-overrides');
|
||||
const importData = { theme: { accent: '#abcdef' }, nodeColors: { repeater: '#112233' } };
|
||||
const validation = window._customizerV2.validateShape(importData);
|
||||
if (!validation.valid) return { error: 'Validation failed: ' + validation.errors.join(', ') };
|
||||
window._customizerV2.writeOverrides(importData);
|
||||
const stored = window._customizerV2.readOverrides();
|
||||
return { accent: stored.theme && stored.theme.accent, repeater: stored.nodeColors && stored.nodeColors.repeater };
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.accent === '#abcdef', 'Imported accent should be #abcdef');
|
||||
assert(result.repeater === '#112233', 'Imported repeater should be #112233');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: migration from legacy keys', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
// Clear new key so migration can run
|
||||
localStorage.removeItem('cs-theme-overrides');
|
||||
// Set legacy keys
|
||||
localStorage.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#aabb01' }, branding: { siteName: 'LegacyName' } }));
|
||||
localStorage.setItem('meshcore-timestamp-mode', 'absolute');
|
||||
localStorage.setItem('meshcore-heatmap-opacity', '0.5');
|
||||
// Run migration
|
||||
const migrated = window._customizerV2.migrateOldKeys();
|
||||
const stored = window._customizerV2.readOverrides();
|
||||
const legacyGone = localStorage.getItem('meshcore-user-theme') === null &&
|
||||
localStorage.getItem('meshcore-timestamp-mode') === null &&
|
||||
localStorage.getItem('meshcore-heatmap-opacity') === null;
|
||||
return {
|
||||
migrated: !!migrated,
|
||||
accent: stored.theme && stored.theme.accent,
|
||||
siteName: stored.branding && stored.branding.siteName,
|
||||
tsMode: stored.timestamps && stored.timestamps.defaultMode,
|
||||
opacity: stored.heatmapOpacity,
|
||||
legacyGone
|
||||
};
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.migrated, 'migrateOldKeys should return non-null');
|
||||
assert(result.accent === '#aabb01', 'Theme accent should be migrated');
|
||||
assert(result.siteName === 'LegacyName', 'Branding should be migrated');
|
||||
assert(result.tsMode === 'absolute', 'Timestamp mode should be migrated');
|
||||
assert(result.opacity === 0.5, 'Heatmap opacity should be migrated');
|
||||
assert(result.legacyGone, 'Legacy keys should be removed after migration');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: browser-local banner visible', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
// Open customizer
|
||||
const toggleSel = '#customizeToggle, button[title*="ustom" i], [class*="customize"]';
|
||||
const btn = await page.$(toggleSel);
|
||||
if (!btn) { console.log(' ⏭️ Customizer toggle not found'); return; }
|
||||
await btn.click();
|
||||
await page.waitForSelector('.cv2-local-banner', { timeout: 5000 });
|
||||
const bannerText = await page.$eval('.cv2-local-banner', el => el.textContent);
|
||||
assert(bannerText.includes('browser only'), `Banner should mention "browser only" but got "${bannerText}"`);
|
||||
});
|
||||
|
||||
await test('Customizer v2: auto-save status indicator', async () => {
|
||||
// Panel should already be open from previous test
|
||||
const statusEl = await page.$('#cv2-save-status');
|
||||
if (!statusEl) { console.log(' ⏭️ Save status element not found'); return; }
|
||||
const statusText = await page.$eval('#cv2-save-status', el => el.textContent);
|
||||
assert(statusText.includes('saved') || statusText.includes('Saving'),
|
||||
`Status should show save state but got "${statusText}"`);
|
||||
});
|
||||
|
||||
await test('Customizer v2: override indicator appears and disappears', async () => {
|
||||
// Set override BEFORE page load so _renderTheme sees it during init
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => {
|
||||
// Force light mode so theme tab renders 'theme' section (not 'themeDark')
|
||||
localStorage.setItem('meshcore-theme', 'light');
|
||||
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' } }));
|
||||
});
|
||||
// Reload so customizer v2 initializes with the override in place
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
// Ensure light mode is active (CI headless may default to dark)
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'));
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
return { ok: true };
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
// Open customizer and check for override dot
|
||||
const toggleSel = '#customizeToggle, button[title*="ustom" i], [class*="customize"]';
|
||||
const btn = await page.$(toggleSel);
|
||||
if (!btn) { console.log(' ⏭️ Customizer toggle not found'); return; }
|
||||
await btn.click();
|
||||
await page.waitForSelector('.cust-overlay', { timeout: 5000 });
|
||||
// Click theme tab
|
||||
const themeTab = await page.$('.cust-tab[data-tab="theme"]');
|
||||
if (themeTab) await themeTab.click();
|
||||
await page.waitForTimeout(200);
|
||||
// Check for override dot
|
||||
const dots = await page.$$('.cv2-override-dot');
|
||||
assert(dots.length > 0, 'Override dot should be visible when overrides exist');
|
||||
// Clear overrides and reload to verify dots disappear
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const btn2 = await page.$(toggleSel);
|
||||
if (btn2) await btn2.click();
|
||||
await page.waitForSelector('.cust-overlay', { timeout: 5000 });
|
||||
const themeTab2 = await page.$('.cust-tab[data-tab="theme"]');
|
||||
if (themeTab2) await themeTab2.click();
|
||||
await page.waitForTimeout(200);
|
||||
const dotsAfter = await page.$$('.cv2-override-dot');
|
||||
assert(dotsAfter.length === 0, 'Override dots should disappear after clearing overrides');
|
||||
});
|
||||
|
||||
await test('Customizer v2: presets apply through standard pipeline', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
const toggleSel = '#customizeToggle, button[title*="ustom" i], [class*="customize"]';
|
||||
const btn = await page.$(toggleSel);
|
||||
if (!btn) { console.log(' ⏭️ Customizer toggle not found'); return; }
|
||||
await btn.click();
|
||||
await page.waitForSelector('.cust-overlay', { timeout: 5000 });
|
||||
// Click theme tab
|
||||
const themeTab = await page.$('.cust-tab[data-tab="theme"]');
|
||||
if (themeTab) await themeTab.click();
|
||||
await page.waitForTimeout(200);
|
||||
// Click ocean preset
|
||||
const oceanBtn = await page.$('.cust-preset-btn[data-preset="ocean"]');
|
||||
if (!oceanBtn) { console.log(' ⏭️ Ocean preset button not found'); return; }
|
||||
await oceanBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
const result = await page.evaluate(() => {
|
||||
const stored = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
|
||||
const cssAccent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
|
||||
return { hasTheme: !!stored.theme, cssAccent };
|
||||
});
|
||||
assert(result.hasTheme, 'Preset should write theme to localStorage');
|
||||
assert(result.cssAccent.length > 0, 'CSS accent should be set after preset');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: page load applies overrides from localStorage', async () => {
|
||||
// Set overrides BEFORE navigating
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ee1122' } }));
|
||||
});
|
||||
// Reload to trigger init with overrides
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
await page.waitForTimeout(500); // allow pipeline to run
|
||||
const cssAccent = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim()
|
||||
);
|
||||
assert(cssAccent === '#ee1122', `Page load should apply override accent #ee1122 but got "${cssAccent}"`);
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
|
||||
await test('Show Neighbors populates neighborPubkeys from affinity API', async () => {
|
||||
const testPubkey = 'aabbccdd11223344556677889900aabbccddeeff00112233445566778899001122';
|
||||
const neighborPubkey1 = '1111111111111111111111111111111111111111111111111111111111111111';
|
||||
const neighborPubkey2 = '2222222222222222222222222222222222222222222222222222222222222222';
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: testPubkey,
|
||||
neighbors: [
|
||||
{ pubkey: neighborPubkey1, prefix: '11', name: 'Neighbor-1', role: 'repeater', count: 50, score: 0.9, ambiguous: false },
|
||||
{ pubkey: neighborPubkey2, prefix: '22', name: 'Neighbor-2', role: 'companion', count: 20, score: 0.7, ambiguous: false }
|
||||
],
|
||||
total_observations: 70
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const result = await page.evaluate(async (args) => {
|
||||
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no _mapSelectRefNode' };
|
||||
await window._mapSelectRefNode(args.pk, 'TestNode');
|
||||
return { neighbors: window._mapGetNeighborPubkeys() };
|
||||
}, { pk: testPubkey });
|
||||
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.neighbors.includes(neighborPubkey1), 'Should contain neighbor1');
|
||||
assert(result.neighbors.includes(neighborPubkey2), 'Should contain neighbor2');
|
||||
assert(result.neighbors.length === 2, `Expected 2 neighbors, got ${result.neighbors.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors resolves correct node on hash collision via affinity API', async () => {
|
||||
const nodeA = 'c0dedad4208acb6cbe44b848943fc6d3c5d43cf38a21e48b43826a70862980e4';
|
||||
const nodeB = 'c0f1a2b3000000000000000000000000000000000000000000000000000000ff';
|
||||
const neighborR1 = 'r1aaaaaa000000000000000000000000000000000000000000000000000000aa';
|
||||
const neighborR2 = 'r2bbbbbb000000000000000000000000000000000000000000000000000000bb';
|
||||
const neighborR4 = 'r4dddddd000000000000000000000000000000000000000000000000000000dd';
|
||||
|
||||
await page.route(`**/api/nodes/${nodeA}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: nodeA,
|
||||
neighbors: [
|
||||
{ pubkey: neighborR1, prefix: 'R1', name: 'Repeater-R1', role: 'repeater', count: 100, score: 0.95, ambiguous: false },
|
||||
{ pubkey: neighborR2, prefix: 'R2', name: 'Repeater-R2', role: 'repeater', count: 80, score: 0.85, ambiguous: false }
|
||||
],
|
||||
total_observations: 180
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/nodes/${nodeB}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: nodeB,
|
||||
neighbors: [
|
||||
{ pubkey: neighborR4, prefix: 'R4', name: 'Repeater-R4', role: 'repeater', count: 60, score: 0.75, ambiguous: false }
|
||||
],
|
||||
total_observations: 60
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Select Node A — should get R1, R2 but NOT R4
|
||||
const resultA = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'NodeA');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, nodeA);
|
||||
assert(resultA.includes(neighborR1), 'Node A should have R1');
|
||||
assert(resultA.includes(neighborR2), 'Node A should have R2');
|
||||
assert(!resultA.includes(neighborR4), 'Node A should NOT have R4');
|
||||
|
||||
// Select Node B — should get R4 but NOT R1, R2
|
||||
const resultB = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'NodeB');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, nodeB);
|
||||
assert(resultB.includes(neighborR4), 'Node B should have R4');
|
||||
assert(!resultB.includes(neighborR1), 'Node B should NOT have R1');
|
||||
assert(!resultB.includes(neighborR2), 'Node B should NOT have R2');
|
||||
|
||||
await page.unroute(`**/api/nodes/${nodeA}/neighbors*`);
|
||||
await page.unroute(`**/api/nodes/${nodeB}/neighbors*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors falls back to path walking when affinity API returns empty', async () => {
|
||||
const testPubkey = 'fallbacktest0000000000000000000000000000000000000000000000000000';
|
||||
const hopBefore = 'aaaa000000000000000000000000000000000000000000000000000000000000';
|
||||
const hopAfter = 'bbbb000000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ node: testPubkey, neighbors: [], total_observations: 0 })
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/paths*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
paths: [{
|
||||
hops: [
|
||||
{ pubkey: hopBefore, name: 'HopBefore' },
|
||||
{ pubkey: testPubkey, name: 'Self' },
|
||||
{ pubkey: hopAfter, name: 'HopAfter' }
|
||||
]
|
||||
}]
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const result = await page.evaluate(async (pk) => {
|
||||
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no-function' };
|
||||
await window._mapSelectRefNode(pk, 'FallbackNode');
|
||||
return { neighbors: window._mapGetNeighborPubkeys() };
|
||||
}, testPubkey);
|
||||
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.neighbors.includes(hopBefore), 'Fallback should find hopBefore');
|
||||
assert(result.neighbors.includes(hopAfter), 'Fallback should find hopAfter');
|
||||
assert(result.neighbors.length === 2, `Expected 2 fallback neighbors, got ${result.neighbors.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/paths*`);
|
||||
});
|
||||
// ─── Neighbor section tests ───────────────────────────────────────────────
|
||||
|
||||
await test('Node detail: neighbors section exists with correct columns', async () => {
|
||||
// Navigate to a node detail page (use the first node in the list)
|
||||
await page.goto(BASE + '/#/nodes');
|
||||
await page.waitForSelector('#nodesBody tr[data-key]', { timeout: 10000 });
|
||||
// Get the first node's pubkey from the row's data-key attribute
|
||||
const pubkey = await page.$eval('#nodesBody tr[data-key]', el => el.dataset.key);
|
||||
await page.goto(BASE + '/#/nodes/' + pubkey);
|
||||
await page.waitForSelector('#node-neighbors', { timeout: 10000 });
|
||||
// Check the section exists
|
||||
const header = await page.$eval('#fullNeighborsHeader', el => el.textContent);
|
||||
assert(header.startsWith('Neighbors'), 'Header should start with "Neighbors", got: ' + header);
|
||||
// Wait for content to load (either table or empty state)
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.getElementById('fullNeighborsContent');
|
||||
return el && !el.innerHTML.includes('spinner');
|
||||
}, { timeout: 10000 });
|
||||
const hasTable = await page.$('#fullNeighborsContent .data-table');
|
||||
if (hasTable) {
|
||||
// Check columns
|
||||
const headers = await page.$$eval('#fullNeighborsContent thead th', ths => ths.map(t => t.textContent));
|
||||
assert(headers.includes('Neighbor'), 'Should have Neighbor column');
|
||||
assert(headers.includes('Role'), 'Should have Role column');
|
||||
assert(headers.includes('Score'), 'Should have Score column');
|
||||
assert(headers.includes('Obs'), 'Should have Obs column');
|
||||
assert(headers.includes('Last Seen'), 'Should have Last Seen column');
|
||||
assert(headers.includes('Conf'), 'Should have Conf column');
|
||||
} else {
|
||||
// Empty state
|
||||
const text = await page.$eval('#fullNeighborsContent', el => el.textContent);
|
||||
assert(text.includes('No neighbor data') || text.includes('Could not load'), 'Should show empty or error state');
|
||||
}
|
||||
});
|
||||
|
||||
await test('Node detail: neighbors section loading state', async () => {
|
||||
// Navigate to a node - the section should initially show a spinner
|
||||
await page.goto(BASE + '/#/nodes');
|
||||
await page.waitForSelector('#nodesBody tr[data-key]', { timeout: 10000 });
|
||||
const pubkey = await page.$eval('#nodesBody tr[data-key]', el => el.dataset.key);
|
||||
// Intercept API to delay response
|
||||
await page.route('**/api/nodes/*/neighbors*', async route => {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
await route.continue();
|
||||
});
|
||||
await page.goto(BASE + '/#/nodes/' + pubkey);
|
||||
// Check spinner appears
|
||||
const spinnerVisible = await page.waitForSelector('#fullNeighborsContent .spinner', { timeout: 5000 }).then(() => true).catch(() => false);
|
||||
assert(spinnerVisible, 'Loading spinner should be visible initially');
|
||||
// Wait for loading to finish
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.getElementById('fullNeighborsContent');
|
||||
return el && !el.innerHTML.includes('spinner');
|
||||
}, { timeout: 15000 });
|
||||
await page.unroute('**/api/nodes/*/neighbors*');
|
||||
});
|
||||
|
||||
// ─── End neighbor section tests ───────────────────────────────────────────
|
||||
|
||||
// Extract frontend coverage if instrumented server is running
|
||||
try {
|
||||
const coverage = await page.evaluate(() => window.__coverage__);
|
||||
|
||||
+265
-111
@@ -1942,87 +1942,263 @@ console.log('\n=== analytics.js: sortChannels ===');
|
||||
}
|
||||
|
||||
|
||||
// ===== CUSTOMIZE-V2.JS: core behavior =====
|
||||
console.log('\n=== customize-v2.js: core behavior ===');
|
||||
// ===== CUSTOMIZE.JS: initState merge behavior =====
|
||||
console.log('\n=== customize.js: initState merge behavior ===');
|
||||
{
|
||||
function loadCustomizeV2(ctx) {
|
||||
const src = fs.readFileSync('public/customize-v2.js', 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
function loadCustomizeExports(ctx) {
|
||||
const src = fs.readFileSync('public/customize.js', 'utf8');
|
||||
const withExports = src.replace(
|
||||
/\}\)\(\);\s*$/,
|
||||
'window.__customizeExport = { initState: initState, autoSave: autoSave, getState: function () { return state; }, getDefaults: function () { return deepClone(DEFAULTS); }, setInitialized: function (v) { _initialized = !!v; } };})();'
|
||||
);
|
||||
vm.runInContext(withExports, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
return ctx.window._customizerV2;
|
||||
return ctx.window.__customizeExport;
|
||||
}
|
||||
|
||||
test('readOverrides returns empty object when no localStorage data', () => {
|
||||
test('autoSave no-ops before initialization on panel open path', () => {
|
||||
const ctx = makeSandbox();
|
||||
ctx.CustomEvent = function (type) { this.type = type; };
|
||||
const v2 = loadCustomizeV2(ctx);
|
||||
const overrides = v2.readOverrides();
|
||||
assert.strictEqual(Object.keys(overrides).length, 0);
|
||||
let saveTimerCalls = 0;
|
||||
ctx.setTimeout = function () { saveTimerCalls++; return 1; };
|
||||
ctx.clearTimeout = function () {};
|
||||
ctx.window.SITE_CONFIG = { home: { heroTitle: 'Server Hero' } };
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
ex.setInitialized(false);
|
||||
ex.autoSave();
|
||||
assert.strictEqual(saveTimerCalls, 0);
|
||||
assert.strictEqual(ctx.localStorage.getItem('meshcore-user-theme'), null);
|
||||
});
|
||||
|
||||
test('writeOverrides + readOverrides roundtrip', () => {
|
||||
test('server home config survives customizer open without modification', () => {
|
||||
const ctx = makeSandbox();
|
||||
ctx.CustomEvent = function (type) { this.type = type; };
|
||||
const v2 = loadCustomizeV2(ctx);
|
||||
v2.writeOverrides({ theme: { accent: '#ff0000' } });
|
||||
const result = v2.readOverrides();
|
||||
assert.strictEqual(result.theme.accent, '#ff0000');
|
||||
let saveTimerCalls = 0;
|
||||
ctx.setTimeout = function () { saveTimerCalls++; return 1; };
|
||||
ctx.clearTimeout = function () {};
|
||||
ctx.window.SITE_CONFIG = {
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
heroSubtitle: 'Server Subtitle',
|
||||
steps: [{ emoji: 'S', title: 'Server Step', description: 'server' }],
|
||||
checklist: [{ question: 'Server Q', answer: 'Server A' }],
|
||||
footerLinks: [{ label: 'Server Link', url: '#/server' }]
|
||||
}
|
||||
};
|
||||
const before = JSON.stringify(ctx.window.SITE_CONFIG.home);
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
ex.setInitialized(false);
|
||||
ex.autoSave();
|
||||
assert.strictEqual(saveTimerCalls, 0);
|
||||
assert.strictEqual(JSON.stringify(ctx.window.SITE_CONFIG.home), before);
|
||||
});
|
||||
|
||||
test('computeEffective merges server defaults with overrides', () => {
|
||||
test('post-init autoSave exports user theme without mutating SITE_CONFIG.home', () => {
|
||||
const ctx = makeSandbox();
|
||||
ctx.CustomEvent = function (type) { this.type = type; };
|
||||
const v2 = loadCustomizeV2(ctx);
|
||||
const server = { theme: { accent: '#111111', navBg: '#222222' } };
|
||||
const overrides = { theme: { accent: '#ff0000' } };
|
||||
const effective = v2.computeEffective(server, overrides);
|
||||
assert.strictEqual(effective.theme.accent, '#ff0000');
|
||||
assert.strictEqual(effective.theme.navBg, '#222222');
|
||||
let saveTimerCalls = 0;
|
||||
ctx.setTimeout = function (fn) { saveTimerCalls++; fn(); return 1; };
|
||||
ctx.clearTimeout = function () {};
|
||||
ctx.HashChangeEvent = function HashChangeEvent(type) { this.type = type; };
|
||||
ctx.window.SITE_CONFIG = {
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
heroSubtitle: 'Server Subtitle',
|
||||
steps: [{ emoji: 'S', title: 'Server Step', description: 'server' }],
|
||||
checklist: [{ question: 'Server Q', answer: 'Server A' }],
|
||||
footerLinks: [{ label: 'Server Link', url: '#/server' }]
|
||||
}
|
||||
};
|
||||
const before = JSON.stringify(ctx.window.SITE_CONFIG.home);
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
ex.setInitialized(true);
|
||||
ex.autoSave();
|
||||
const saved = ctx.localStorage.getItem('meshcore-user-theme');
|
||||
assert.strictEqual(saveTimerCalls, 1);
|
||||
assert(saved && saved.length > 0, 'Expected autoSave to persist user theme');
|
||||
assert.strictEqual(JSON.stringify(ctx.window.SITE_CONFIG.home), before);
|
||||
});
|
||||
|
||||
test('isValidColor accepts hex, rgb, hsl, and named colors', () => {
|
||||
test('partial local checklist does not wipe steps/footerLinks and keeps server colors', () => {
|
||||
const ctx = makeSandbox();
|
||||
ctx.CustomEvent = function (type) { this.type = type; };
|
||||
const v2 = loadCustomizeV2(ctx);
|
||||
assert.strictEqual(v2.isValidColor('#ff0000'), true);
|
||||
assert.strictEqual(v2.isValidColor('#abc'), true);
|
||||
assert.strictEqual(v2.isValidColor('rgb(255, 0, 0)'), true);
|
||||
assert.strictEqual(v2.isValidColor('hsl(0, 100%, 50%)'), true);
|
||||
assert.strictEqual(v2.isValidColor('red'), true);
|
||||
assert.strictEqual(v2.isValidColor('notacolor'), false);
|
||||
assert.strictEqual(v2.isValidColor(123), false);
|
||||
ctx.window.SITE_CONFIG = {
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
heroSubtitle: 'Server Subtitle',
|
||||
steps: [{ emoji: '🧪', title: 'Server Step', description: 'from server' }],
|
||||
checklist: [{ question: 'Server Q', answer: 'Server A' }],
|
||||
footerLinks: [{ label: 'Server Link', url: '#/server' }]
|
||||
},
|
||||
theme: { accent: '#123456', navBg: '#222222' },
|
||||
nodeColors: { repeater: '#aa0000' }
|
||||
};
|
||||
ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({
|
||||
home: { checklist: [{ question: 'Local Q', answer: 'Local A' }] }
|
||||
}));
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
const state = ex.getState();
|
||||
assert.strictEqual(state.home.checklist[0].question, 'Local Q');
|
||||
assert.strictEqual(state.home.steps[0].title, 'Server Step');
|
||||
assert.strictEqual(state.home.footerLinks[0].label, 'Server Link');
|
||||
assert.strictEqual(state.home.heroTitle, 'Server Hero');
|
||||
assert.strictEqual(state.theme.accent, '#123456');
|
||||
assert.strictEqual(state.nodeColors.repeater, '#aa0000');
|
||||
});
|
||||
|
||||
test('validateShape reports invalid color values', () => {
|
||||
test('server values survive when localStorage has partial overrides', () => {
|
||||
const ctx = makeSandbox();
|
||||
ctx.CustomEvent = function (type) { this.type = type; };
|
||||
const v2 = loadCustomizeV2(ctx);
|
||||
const valid = v2.validateShape({ theme: { accent: '#ff0000', navBg: '#222222' } });
|
||||
assert.strictEqual(valid.valid, true);
|
||||
const invalid = v2.validateShape({ theme: { accent: '#ff0000', navBg: 'not-a-color' } });
|
||||
assert.ok(invalid.errors.length > 0, 'should report invalid color');
|
||||
assert.ok(invalid.errors[0].includes('navBg'), 'error should mention navBg');
|
||||
ctx.window.SITE_CONFIG = {
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
heroSubtitle: 'Server Subtitle',
|
||||
steps: [{ emoji: '1️⃣', title: 'Server Step', description: 'server' }],
|
||||
footerLinks: [{ label: 'Server Footer', url: '#/s' }]
|
||||
},
|
||||
theme: { accent: '#111111', navBg: '#222222', navText: '#333333' },
|
||||
typeColors: { ADVERT: '#00aa00', REQUEST: '#aa00aa' }
|
||||
};
|
||||
ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({
|
||||
home: { heroTitle: 'Local Hero' },
|
||||
theme: { accent: '#999999' },
|
||||
typeColors: { ADVERT: '#ff00ff' }
|
||||
}));
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
const state = ex.getState();
|
||||
assert.strictEqual(state.home.heroTitle, 'Local Hero');
|
||||
assert.strictEqual(state.home.heroSubtitle, 'Server Subtitle');
|
||||
assert.strictEqual(state.home.steps[0].title, 'Server Step');
|
||||
assert.strictEqual(state.home.footerLinks[0].label, 'Server Footer');
|
||||
assert.strictEqual(state.theme.accent, '#999999');
|
||||
assert.strictEqual(state.theme.navBg, '#222222');
|
||||
assert.strictEqual(state.typeColors.ADVERT, '#ff00ff');
|
||||
assert.strictEqual(state.typeColors.REQUEST, '#aa00aa');
|
||||
});
|
||||
|
||||
test('migrateOldKeys reads legacy localStorage keys', () => {
|
||||
test('full localStorage values override server config', () => {
|
||||
const ctx = makeSandbox();
|
||||
ctx.CustomEvent = function (type) { this.type = type; };
|
||||
ctx.localStorage.setItem('meshcore-theme', 'dark');
|
||||
const v2 = loadCustomizeV2(ctx);
|
||||
// migrateOldKeys should handle legacy keys without crashing
|
||||
v2.migrateOldKeys();
|
||||
ctx.window.SITE_CONFIG = {
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
heroSubtitle: 'Server Subtitle',
|
||||
steps: [{ emoji: 'S', title: 'Server Step', description: 'server' }],
|
||||
checklist: [{ question: 'Server Q', answer: 'Server A' }],
|
||||
footerLinks: [{ label: 'Server Link', url: '#/server' }]
|
||||
},
|
||||
theme: { accent: '#101010' }
|
||||
};
|
||||
ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({
|
||||
home: {
|
||||
heroTitle: 'Local Hero',
|
||||
heroSubtitle: 'Local Subtitle',
|
||||
steps: [{ emoji: 'L', title: 'Local Step', description: 'local' }],
|
||||
checklist: [{ question: 'Local Q', answer: 'Local A' }],
|
||||
footerLinks: [{ label: 'Local Link', url: '#/local' }]
|
||||
},
|
||||
theme: { accent: '#abcdef', navBg: '#fedcba' }
|
||||
}));
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
const state = ex.getState();
|
||||
assert.strictEqual(state.home.heroTitle, 'Local Hero');
|
||||
assert.strictEqual(state.home.heroSubtitle, 'Local Subtitle');
|
||||
assert.strictEqual(state.home.steps[0].title, 'Local Step');
|
||||
assert.strictEqual(state.home.checklist[0].question, 'Local Q');
|
||||
assert.strictEqual(state.home.footerLinks[0].label, 'Local Link');
|
||||
assert.strictEqual(state.theme.accent, '#abcdef');
|
||||
assert.strictEqual(state.theme.navBg, '#fedcba');
|
||||
});
|
||||
|
||||
test('THEME_CSS_MAP includes surface3 and sectionBg', () => {
|
||||
test('initState uses _SITE_CONFIG_ORIGINAL_HOME to bypass contaminated SITE_CONFIG.home', () => {
|
||||
// Simulates: app.js called mergeUserHomeConfig which mutated SITE_CONFIG.home.steps = []
|
||||
// The original server steps must still be recoverable via _SITE_CONFIG_ORIGINAL_HOME
|
||||
const ctx = makeSandbox();
|
||||
ctx.CustomEvent = function (type) { this.type = type; };
|
||||
const src = fs.readFileSync('public/customize-v2.js', 'utf8');
|
||||
assert.ok(src.includes("surface3: '--surface-3'"), 'surface3 must map to --surface-3');
|
||||
assert.ok(src.includes("sectionBg: '--section-bg'"), 'sectionBg must map to --section-bg');
|
||||
ctx.setTimeout = function (fn) { fn(); return 1; };
|
||||
ctx.clearTimeout = function () {};
|
||||
// SITE_CONFIG.home is contaminated — steps wiped by mergeUserHomeConfig at page load
|
||||
ctx.window.SITE_CONFIG = {
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
steps: [] // contaminated — user had steps:[] in localStorage at page load
|
||||
}
|
||||
};
|
||||
// app.js snapshots original before mutation
|
||||
ctx.window._SITE_CONFIG_ORIGINAL_HOME = {
|
||||
heroTitle: 'Server Hero',
|
||||
steps: [{ emoji: '🧪', title: 'Original Step', description: 'from server' }]
|
||||
};
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
const state = ex.getState();
|
||||
assert.strictEqual(state.home.steps.length, 1, 'should restore from snapshot, not contaminated SITE_CONFIG');
|
||||
assert.strictEqual(state.home.steps[0].title, 'Original Step');
|
||||
});
|
||||
|
||||
test('initState uses DEFAULTS.home when no SITE_CONFIG and no snapshot', () => {
|
||||
const ctx = makeSandbox();
|
||||
ctx.setTimeout = function (fn) { fn(); return 1; };
|
||||
ctx.clearTimeout = function () {};
|
||||
// No SITE_CONFIG at all — pure DEFAULTS
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
const state = ex.getState();
|
||||
assert.ok(state.home.steps.length > 0, 'should use DEFAULTS.home.steps when no server config');
|
||||
assert.strictEqual(state.home.steps[0].title, 'Join the Bay Area MeshCore Discord');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== APP.JS: home rehydration merge (mergeUserHomeConfig removed — dead code) =====
|
||||
// ===== APP.JS: home rehydration merge =====
|
||||
console.log('\n=== app.js: home rehydration merge ===');
|
||||
{
|
||||
test('mergeUserHomeConfig layers local home overrides on server home', () => {
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
const merged = ctx.mergeUserHomeConfig(
|
||||
{
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
heroSubtitle: 'Server Subtitle',
|
||||
steps: [{ title: 'Server Step' }],
|
||||
footerLinks: [{ label: 'Server Link' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
home: {
|
||||
heroSubtitle: 'Local Subtitle',
|
||||
checklist: [{ question: 'Local Q', answer: 'Local A' }]
|
||||
}
|
||||
}
|
||||
);
|
||||
assert.strictEqual(merged.home.heroTitle, 'Server Hero');
|
||||
assert.strictEqual(merged.home.heroSubtitle, 'Local Subtitle');
|
||||
assert.strictEqual(merged.home.steps[0].title, 'Server Step');
|
||||
assert.strictEqual(merged.home.footerLinks[0].label, 'Server Link');
|
||||
assert.strictEqual(merged.home.checklist[0].question, 'Local Q');
|
||||
});
|
||||
|
||||
test('mergeUserHomeConfig handles refresh-style localStorage payload', () => {
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({
|
||||
home: { heroTitle: 'Local Hero' }
|
||||
}));
|
||||
const cfg = {
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
heroSubtitle: 'Server Subtitle',
|
||||
steps: [{ title: 'Server Step' }]
|
||||
}
|
||||
};
|
||||
const userTheme = JSON.parse(ctx.localStorage.getItem('meshcore-user-theme') || '{}');
|
||||
const merged = ctx.mergeUserHomeConfig(cfg, userTheme);
|
||||
assert.strictEqual(merged.home.heroTitle, 'Local Hero');
|
||||
assert.strictEqual(merged.home.heroSubtitle, 'Server Subtitle');
|
||||
assert.strictEqual(merged.home.steps[0].title, 'Server Step');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== CHANNELS.JS: WS Region Filter helper =====
|
||||
console.log('\n=== channels.js: shouldProcessWSMessageForRegion ===');
|
||||
@@ -3922,7 +4098,40 @@ console.log('\n=== app.js: debounce ===');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== APP.JS: mergeUserHomeConfig removed (dead code) =====
|
||||
// ===== APP.JS: mergeUserHomeConfig edge cases =====
|
||||
console.log('\n=== app.js: mergeUserHomeConfig edge cases ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
const merge = ctx.mergeUserHomeConfig;
|
||||
|
||||
test('returns siteConfig when userTheme is null', () => {
|
||||
const cfg = { home: { heroTitle: 'Test' } };
|
||||
assert.strictEqual(merge(cfg, null), cfg);
|
||||
});
|
||||
|
||||
test('returns siteConfig when userTheme has no home', () => {
|
||||
const cfg = { home: { heroTitle: 'Test' } };
|
||||
assert.strictEqual(merge(cfg, { theme: {} }), cfg);
|
||||
});
|
||||
|
||||
test('returns siteConfig when siteConfig is null', () => {
|
||||
assert.strictEqual(merge(null, { home: { heroTitle: 'X' } }), null);
|
||||
});
|
||||
|
||||
test('creates home on siteConfig when missing', () => {
|
||||
const cfg = {};
|
||||
merge(cfg, { home: { heroTitle: 'New' } });
|
||||
assert.strictEqual(cfg.home.heroTitle, 'New');
|
||||
});
|
||||
|
||||
test('userTheme.home non-object is ignored', () => {
|
||||
const cfg = { home: { heroTitle: 'Test' } };
|
||||
assert.strictEqual(merge(cfg, { home: 'string' }), cfg);
|
||||
assert.strictEqual(cfg.home.heroTitle, 'Test');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== APP.JS: formatAbsoluteTimestamp with custom format =====
|
||||
console.log('\n=== app.js: formatAbsoluteTimestamp (custom format) ===');
|
||||
@@ -4202,61 +4411,6 @@ console.log('\n=== app.js: routeTypeName/payloadTypeName edge cases ===');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== observation packet cache invalidation (issue #504) =====
|
||||
{
|
||||
console.log('\n=== Issue #504: observation packets must not inherit parent cache ===');
|
||||
|
||||
const helperSource = fs.readFileSync('public/packet-helpers.js', 'utf8');
|
||||
const ctx = vm.createContext({ window: {}, console, JSON, Array, Object });
|
||||
vm.runInContext(helperSource, ctx);
|
||||
const getParsedPath = ctx.window.getParsedPath;
|
||||
const getParsedDecoded = ctx.window.getParsedDecoded;
|
||||
const clearParsedCache = ctx.window.clearParsedCache;
|
||||
|
||||
test('clearParsedCache removes cached properties and returns the object', () => {
|
||||
const p = { path_json: '["A"]', decoded_json: '{"t":1}' };
|
||||
getParsedPath(p);
|
||||
getParsedDecoded(p);
|
||||
assert.ok(p._parsedPath !== undefined);
|
||||
assert.ok(p._parsedDecoded !== undefined);
|
||||
const ret = clearParsedCache(p);
|
||||
assert.strictEqual(ret, p, 'returns same object');
|
||||
assert.strictEqual(p._parsedPath, undefined);
|
||||
assert.strictEqual(p._parsedDecoded, undefined);
|
||||
});
|
||||
|
||||
test('observation packet gets its own path after cache invalidation', () => {
|
||||
const parent = { path_json: '["A","B"]', decoded_json: '{"type":"GRP_TXT"}' };
|
||||
// Prime the cache on parent
|
||||
getParsedPath(parent);
|
||||
getParsedDecoded(parent);
|
||||
|
||||
// Simulate spread + fix (like packets.js does after issue #504)
|
||||
const obs = { ...parent, path_json: '["X","Y","Z"]', decoded_json: '{"type":"TXT_MSG"}' };
|
||||
clearParsedCache(obs);
|
||||
|
||||
// getParsedPath re-parses from obs's own path_json
|
||||
const obsPath = getParsedPath(obs);
|
||||
assert.deepStrictEqual(obsPath, ['X', 'Y', 'Z'], 'obs gets its own path, not parent\'s');
|
||||
const obsDecoded = getParsedDecoded(obs);
|
||||
assert.deepStrictEqual(obsDecoded, { type: 'TXT_MSG' }, 'obs gets its own decoded, not parent\'s');
|
||||
});
|
||||
|
||||
test('observation packet path differs from parent after cache invalidation', () => {
|
||||
const parent = { path_json: '["hop1"]', decoded_json: '{"type":"REQ"}' };
|
||||
getParsedPath(parent);
|
||||
getParsedDecoded(parent);
|
||||
|
||||
const obs = { ...parent, path_json: '["hop2","hop3"]', decoded_json: '{"type":"GRP_TXT","text":"hi"}' };
|
||||
clearParsedCache(obs);
|
||||
|
||||
assert.notDeepStrictEqual(getParsedPath(obs), getParsedPath(parent),
|
||||
'observation must have different path from parent');
|
||||
assert.notDeepStrictEqual(getParsedDecoded(obs), getParsedDecoded(parent),
|
||||
'observation must have different decoded from parent');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
Promise.allSettled(pendingTests).then(() => {
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
|
||||
+1
-51
@@ -75,54 +75,4 @@ test('no setInterval remains in animation hot path', () => {
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
|
||||
/* === Null-guard coverage for rAF callbacks === */
|
||||
const src2 = fs.readFileSync('public/live.js', 'utf8');
|
||||
let p2 = 0, f2 = 0;
|
||||
function test2(name, fn) {
|
||||
try { fn(); p2++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { f2++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
console.log('\n=== Null guards on rAF animation callbacks ===');
|
||||
|
||||
test2('animatePath tick() has null guard', () => {
|
||||
// tick is inside animatePath, after "function tick(now)"
|
||||
const tickStart = src2.indexOf('function tick(now)');
|
||||
const tickBody = src2.substring(tickStart, tickStart + 200);
|
||||
assert.ok(tickBody.includes('!animLayer || !pathsLayer'), 'tick() missing animLayer/pathsLayer null guard');
|
||||
});
|
||||
|
||||
test2('animatePath fadeOut() has null guard', () => {
|
||||
const fadeOutStart = src2.indexOf('function fadeOut(now)');
|
||||
const fadeOutBody = src2.substring(fadeOutStart, fadeOutStart + 200);
|
||||
assert.ok(fadeOutBody.includes('!animLayer || !pathsLayer'), 'fadeOut() missing animLayer/pathsLayer null guard');
|
||||
});
|
||||
|
||||
test2('drawAnimatedLine animateLine() has null guard', () => {
|
||||
const lineStart = src2.indexOf('function animateLine(now)');
|
||||
const lineBody = src2.substring(lineStart, lineStart + 200);
|
||||
assert.ok(lineBody.includes('!animLayer || !pathsLayer'), 'animateLine() missing animLayer/pathsLayer null guard');
|
||||
});
|
||||
|
||||
test2('drawAnimatedLine animateFade() has null guard', () => {
|
||||
const fadeStart = src2.indexOf('function animateFade(now)');
|
||||
const fadeBody = src2.substring(fadeStart, fadeStart + 200);
|
||||
assert.ok(fadeBody.includes('!pathsLayer'), 'animateFade() missing pathsLayer null guard');
|
||||
});
|
||||
|
||||
test2('pulseNode animatePulse() has null guard', () => {
|
||||
const pulseStart = src2.indexOf('function animatePulse(now)');
|
||||
const pulseBody = src2.substring(pulseStart, pulseStart + 200);
|
||||
assert.ok(pulseBody.includes('!animLayer'), 'animatePulse() missing animLayer null guard');
|
||||
});
|
||||
|
||||
test2('ghostPulse has null guard', () => {
|
||||
const ghostStart = src2.indexOf('function ghostPulse(now)');
|
||||
const ghostBody = src2.substring(ghostStart, ghostStart + 200);
|
||||
assert.ok(ghostBody.includes('!animLayer'), 'ghostPulse() missing animLayer null guard');
|
||||
});
|
||||
|
||||
console.log(`\n${p2} passed, ${f2} failed\n`);
|
||||
if (f2 > 0) process.exit(1);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Show Neighbors E2E tests (#484 fix)
|
||||
* Tests that selectReferenceNode() uses the affinity API instead of client-side path walking.
|
||||
* Usage: CHROMIUM_PATH=/usr/bin/chromium-browser BASE_URL=http://localhost:13590 node test-show-neighbors.js
|
||||
*/
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
||||
const results = [];
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
results.push({ name, pass: true });
|
||||
console.log(` ✅ ${name}`);
|
||||
} catch (err) {
|
||||
results.push({ name, pass: false, error: err.message });
|
||||
console.log(` ❌ ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, msg) {
|
||||
if (!condition) throw new Error(msg || 'Assertion failed');
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('Launching Chromium...');
|
||||
const launchOpts = { headless: true, args: ['--no-sandbox', '--disable-gpu'] };
|
||||
if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH;
|
||||
const browser = await chromium.launch(launchOpts);
|
||||
const page = await browser.newPage();
|
||||
|
||||
console.log(`\nRunning Show Neighbors tests against ${BASE}\n`);
|
||||
|
||||
await test('Show Neighbors calls affinity API and populates neighborPubkeys', async () => {
|
||||
const testPubkey = 'aabbccdd11223344556677889900aabbccddeeff00112233445566778899001122';
|
||||
const neighborPubkey1 = '1111111111111111111111111111111111111111111111111111111111111111';
|
||||
const neighborPubkey2 = '2222222222222222222222222222222222222222222222222222222222222222';
|
||||
|
||||
let apiCalled = false;
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
apiCalled = true;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: testPubkey,
|
||||
neighbors: [
|
||||
{ pubkey: neighborPubkey1, prefix: '11', name: 'Neighbor-1', role: 'repeater', count: 50, score: 0.9, ambiguous: false },
|
||||
{ pubkey: neighborPubkey2, prefix: '22', name: 'Neighbor-2', role: 'companion', count: 20, score: 0.7, ambiguous: false }
|
||||
],
|
||||
total_observations: 70
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const result = await page.evaluate(async (args) => {
|
||||
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no _mapSelectRefNode function' };
|
||||
if (typeof window._mapGetNeighborPubkeys !== 'function') return { error: 'no _mapGetNeighborPubkeys function' };
|
||||
await window._mapSelectRefNode(args.pk, 'TestNode');
|
||||
return { neighbors: window._mapGetNeighborPubkeys() };
|
||||
}, { pk: testPubkey });
|
||||
|
||||
assert(!result.error, result.error || '');
|
||||
assert(apiCalled, 'The /neighbors API should have been called');
|
||||
assert(result.neighbors.includes(neighborPubkey1), `Should contain neighbor1, got: ${JSON.stringify(result.neighbors)}`);
|
||||
assert(result.neighbors.includes(neighborPubkey2), `Should contain neighbor2, got: ${JSON.stringify(result.neighbors)}`);
|
||||
assert(result.neighbors.length === 2, `Should have exactly 2 neighbors, got ${result.neighbors.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors resolves correct node on hash collision via affinity API', async () => {
|
||||
const nodeA = 'c0dedad4208acb6cbe44b848943fc6d3c5d43cf38a21e48b43826a70862980e4';
|
||||
const nodeB = 'c0f1a2b3000000000000000000000000000000000000000000000000000000ff';
|
||||
const neighborR1 = 'r1aaaaaa000000000000000000000000000000000000000000000000000000aa';
|
||||
const neighborR2 = 'r2bbbbbb000000000000000000000000000000000000000000000000000000bb';
|
||||
const neighborR4 = 'r4dddddd000000000000000000000000000000000000000000000000000000dd';
|
||||
|
||||
await page.route(`**/api/nodes/${nodeA}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: nodeA,
|
||||
neighbors: [
|
||||
{ pubkey: neighborR1, prefix: 'R1', name: 'Repeater-R1', role: 'repeater', count: 100, score: 0.95, ambiguous: false },
|
||||
{ pubkey: neighborR2, prefix: 'R2', name: 'Repeater-R2', role: 'repeater', count: 80, score: 0.85, ambiguous: false }
|
||||
],
|
||||
total_observations: 180
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/nodes/${nodeB}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: nodeB,
|
||||
neighbors: [
|
||||
{ pubkey: neighborR4, prefix: 'R4', name: 'Repeater-R4', role: 'repeater', count: 60, score: 0.75, ambiguous: false }
|
||||
],
|
||||
total_observations: 60
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Select Node A — should get R1, R2 but NOT R4
|
||||
const resultA = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'NodeA');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, nodeA);
|
||||
assert(resultA.includes(neighborR1), 'Node A should have R1 as neighbor');
|
||||
assert(resultA.includes(neighborR2), 'Node A should have R2 as neighbor');
|
||||
assert(!resultA.includes(neighborR4), 'Node A should NOT have R4 (that belongs to Node B)');
|
||||
|
||||
// Select Node B — should get R4 but NOT R1, R2
|
||||
const resultB = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'NodeB');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, nodeB);
|
||||
assert(resultB.includes(neighborR4), 'Node B should have R4 as neighbor');
|
||||
assert(!resultB.includes(neighborR1), 'Node B should NOT have R1 (that belongs to Node A)');
|
||||
assert(!resultB.includes(neighborR2), 'Node B should NOT have R2 (that belongs to Node A)');
|
||||
|
||||
await page.unroute(`**/api/nodes/${nodeA}/neighbors*`);
|
||||
await page.unroute(`**/api/nodes/${nodeB}/neighbors*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors falls back to path walking when affinity API returns empty', async () => {
|
||||
const testPubkey = 'fallbacktest0000000000000000000000000000000000000000000000000000';
|
||||
const hopBefore = 'aaaa000000000000000000000000000000000000000000000000000000000000';
|
||||
const hopAfter = 'bbbb000000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
let neighborApiCalled = false;
|
||||
let pathsApiCalled = false;
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
neighborApiCalled = true;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ node: testPubkey, neighbors: [], total_observations: 0 })
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/paths*`, route => {
|
||||
pathsApiCalled = true;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
paths: [{
|
||||
hops: [
|
||||
{ pubkey: hopBefore, name: 'HopBefore' },
|
||||
{ pubkey: testPubkey, name: 'Self' },
|
||||
{ pubkey: hopAfter, name: 'HopAfter' }
|
||||
]
|
||||
}]
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const result = await page.evaluate(async (pk) => {
|
||||
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no-function' };
|
||||
await window._mapSelectRefNode(pk, 'FallbackNode');
|
||||
return { neighbors: window._mapGetNeighborPubkeys() };
|
||||
}, testPubkey);
|
||||
|
||||
assert(!result.error, result.error || '');
|
||||
assert(neighborApiCalled, 'Should try neighbor API first');
|
||||
assert(pathsApiCalled, 'Should fall back to paths API when neighbors empty');
|
||||
assert(result.neighbors.includes(hopBefore), 'Fallback should find hopBefore as neighbor');
|
||||
assert(result.neighbors.includes(hopAfter), 'Fallback should find hopAfter as neighbor');
|
||||
assert(result.neighbors.length === 2, `Fallback should find exactly 2 neighbors, got ${result.neighbors.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/paths*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors includes ambiguous candidates in neighborPubkeys', async () => {
|
||||
const testPubkey = 'ambigtest000000000000000000000000000000000000000000000000000000';
|
||||
const candidate1 = 'a3b4c500000000000000000000000000000000000000000000000000000000';
|
||||
const candidate2 = 'a3f0e100000000000000000000000000000000000000000000000000000000';
|
||||
const knownNeighbor = 'b7e8f9a000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: testPubkey,
|
||||
neighbors: [
|
||||
{ pubkey: knownNeighbor, prefix: 'B7', name: 'Known-Neighbor', role: 'repeater', count: 100, score: 0.95, ambiguous: false },
|
||||
{ pubkey: null, prefix: 'A3', name: null, role: null, count: 12, score: 0.08, ambiguous: true,
|
||||
candidates: [
|
||||
{ pubkey: candidate1, name: 'Node-Alpha', role: 'companion' },
|
||||
{ pubkey: candidate2, name: 'Node-Beta', role: 'companion' }
|
||||
]
|
||||
}
|
||||
],
|
||||
total_observations: 112
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const result = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'AmbigNode');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, testPubkey);
|
||||
|
||||
// Should include the known neighbor AND both ambiguous candidates
|
||||
assert(result.includes(knownNeighbor), 'Should include known neighbor');
|
||||
assert(result.includes(candidate1), 'Should include ambiguous candidate 1');
|
||||
assert(result.includes(candidate2), 'Should include ambiguous candidate 2');
|
||||
assert(result.length === 3, `Should have 3 neighbors (1 known + 2 candidates), got ${result.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
const passed = results.filter(r => r.pass).length;
|
||||
const failed = results.filter(r => !r.pass).length;
|
||||
console.log(`\n${passed}/${results.length} tests passed${failed ? `, ${failed} failed` : ''}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user