Files
meshcore-analyzer/cmd/server/node_reach_test.go
T
47f85f6c4c feat(nodes): per-node Reach page + GET /api/nodes/{pubkey}/reach (directional link quality) (#1625)
## What

Adds a per-node **Reach** view that answers "how well does this specific
node hear, and get heard by, its neighbours?" — both as a standalone
page (`#/nodes/{pubkey}/reach`) and as a section on the node detail
page.

New endpoint: **`GET /api/nodes/{pubkey}/reach`**.

## What it measures

For the target node it derives, from raw `path_json` adjacency (a path
travels origin→observer, so in `[A,B]` B received A directly):

- **Directional link counts** per neighbour: `we_hear` (how often we
received them) vs `they_hear` (how often they received us).
- **Bidirectional / bottleneck**: a link is two-way stable when both
directions > 0; the weaker direction is the bottleneck and rates real
two-way reliability.
- **Importance**: neighbour degree + rank, relay-observation volume,
bidirectional-link count, direct-observer count.
- **Direct observers**: who received the node at 0 hops, with SNR.

Reliability rule: a neighbour is only attributed when its pubkey
**prefix is unique** at the path's byte length (collisions are skipped,
never misattributed).

## UI

- Standalone Reach page + node-detail section.
- Reusable bidirectional link map (OSM) with links coloured by
bottleneck.
- Incoming/outgoing toggles to isolate each direction.

## Naming note (deliberate, no collision)

This is distinct from the existing **per-observer reachability** in
topology analytics (`ReachNode` / `ObserverReach` / `perObserverReach`).
This PR adds its own `NodeReach*` response structs in a new
`node_reach.go` and a new `/api/nodes/{pubkey}/reach` route — there are
no symbol or route collisions (verified: `go build ./...` clean). Happy
to rename to disambiguate further (e.g. "Link Quality") if you'd prefer
to reserve "Reach" for the per-observer feature.

## Testing

- `cmd/server`: endpoint shape/404/limit-clamp + unit tests for token
derivation and directional attribution, plus a scan benchmark — all
pass.
- Frontend: helper tests + Reach-page E2E (`test-node-reach-e2e.js`),
standalone route + incoming/outgoing toggles.
- `go build ./...` and `eslint public/*.js` (no-undef) clean.

## Docs

Design spec, implementation plan, and the `GET
/api/nodes/{pubkey}/reach` API contract are included under `docs/`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:11:06 +02:00

99 lines
3.6 KiB
Go

package main
import "testing"
// resolver that only resolves the exact tokens it's told are unique.
func testResolver(unique map[string]string) func(string) string {
return func(tok string) string {
if pk, ok := unique[tok]; ok {
return pk
}
return "" // ambiguous / unknown → skip
}
}
func TestAttributeDirections_PredecessorAndSuccessor(t *testing.T) {
// path A(aa) -> N(01fa) -> B(bb): we hear A, B hears us.
unique := map[string]string{"AA": "aa00", "BB": "bb00"}
rows := []pathRow{{
observerPK: "obs1", payloadType: 5,
path: []string{"AA", "01FA", "BB"},
}}
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b", testResolver(unique))
if d.we["aa00"] != 1 {
t.Fatalf("we_hear[aa00]=%d want 1", d.we["aa00"])
}
if d.they["bb00"] != 1 {
t.Fatalf("they_hear[bb00]=%d want 1", d.they["bb00"])
}
if d.relay != 1 {
t.Fatalf("relay=%d want 1", d.relay)
}
}
func TestAttributeDirections_LastHopObserverAndAdvertFirstHop(t *testing.T) {
snr := 4.0
rows := []pathRow{
// N is last hop → observer heard us directly (+snr).
{observerPK: "obsx", payloadType: 5, path: []string{"AA", "01FA"}, snr: &snr},
// N is first hop of an ADVERT (type 4) → we heard the originator.
{observerPK: "obsy", payloadType: 4, fromPubkey: "origin1", path: []string{"01FA", "CC"}},
}
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b",
testResolver(map[string]string{"CC": "cc00"}))
if d.obs["obsx"] == nil || d.obs["obsx"].count != 1 {
t.Fatalf("observer obsx not counted")
}
if d.obs["obsx"].snrN != 1 || d.obs["obsx"].snrSum != 4.0 {
t.Fatalf("observer snr not aggregated")
}
if d.they["obsx"] != 1 {
t.Fatalf("they_hear[obsx]=%d want 1", d.they["obsx"])
}
if d.we["origin1"] != 1 {
t.Fatalf("we_hear[origin1]=%d want 1 (advert first-hop)", d.we["origin1"])
}
if d.they["cc00"] != 1 {
t.Fatalf("they_hear[cc00]=%d want 1 (successor)", d.they["cc00"])
}
}
func TestAttributeDirections_AmbiguousSkippedAndSelfIgnored(t *testing.T) {
// No observer, so the last-hop observer branch can't fire — this isolates
// the resolve logic. ZZ is unresolved (ambiguous → skipped); the trailing
// 01FA resolves to self (ourPK) and must be ignored as a successor.
rows := []pathRow{{observerPK: "", payloadType: 5, path: []string{"ZZ", "01FA", "01FA"}}}
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b",
testResolver(map[string]string{"01FA": "01fa326b"}))
if len(d.we) != 0 || len(d.they) != 0 {
t.Fatalf("ambiguous/self should yield no edges, got we=%v they=%v", d.we, d.they)
}
}
func TestAttributeDirections_LastHopWithObserverCountsObserver(t *testing.T) {
// Guards the case the previous test deliberately excludes: when our token is
// the last hop AND an observer is present, that observer heard us directly.
rows := []pathRow{{observerPK: "obs1", payloadType: 5, path: []string{"ZZ", "01FA"}}}
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b",
testResolver(map[string]string{}))
if d.they["obs1"] != 1 || d.obs["obs1"] == nil || d.obs["obs1"].count != 1 {
t.Fatalf("last-hop observer should be counted, got they=%v", d.they)
}
}
func TestReliableTokens(t *testing.T) {
// pm where "01fa" is unique but "01" is shared (collision).
nodes := []nodeInfo{
{PublicKey: "01fa326b0000", Role: "repeater"},
{PublicKey: "0188aaaa0000", Role: "repeater"},
}
pm := buildPrefixMap(nodes)
toks := reliableTokens("01fa326b0000", pm)
if !toks["01FA"] {
t.Fatalf("expected 01FA reliable, got %v", toks)
}
if toks["01"] {
t.Fatalf("1-byte 01 must be excluded (collision), got %v", toks)
}
}