mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 00:28:00 +00:00
fix: Scope Adoption by Area crashed the whole Scopes tab on a real 0
NodesMatchingArea had omitempty, so a genuine 0 count (the meaningful case this feature exists to surface -- "linked region, zero adoption") dropped out of the JSON entirely. The frontend read undefined off the missing key and called .toLocaleString() on it, throwing and blanking Region Utilization/Repeaters by Region/Bridge Repeaters along with it since they render later in the same updateData pass. Drop omitempty, add a defensive `|| 0` on the client, and a regression test that decodes into a raw map (not the typed struct, which hides this by zero-valuing the field regardless of whether the key was present). Caught live on stg immediately after deploying the feature -- verified via browser before/after, not just the unit test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8da5f8312e
commit
bb65e0893b
@@ -4835,6 +4835,65 @@ func TestHandleScopeStats_ScopeAdoptionByArea(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleScopeStats_ScopeAdoptionByArea_ZeroMatchKeyPresent is a
|
||||
// regression test: NodesMatchingArea must NOT have `omitempty`, or a
|
||||
// genuine 0 count (a real, meaningful value — "this area has a linked
|
||||
// region but nobody uses it") silently disappears from the raw JSON. The
|
||||
// frontend reads a.nodesMatchingArea directly and calls .toLocaleString()
|
||||
// on it — an absent key deserializes to `undefined` in JS, not 0, which
|
||||
// throws and breaks the whole Scopes tab render. Decodes into a raw map
|
||||
// (not the typed struct, which would hide this by defaulting to the zero
|
||||
// value regardless of whether the key was present).
|
||||
func TestHandleScopeStats_ScopeAdoptionByArea_ZeroMatchKeyPresent(t *testing.T) {
|
||||
srv, _ := setupTestServer(t)
|
||||
if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil {
|
||||
t.Fatalf("add scope_name column: %v", err)
|
||||
}
|
||||
srv.db.hasScopeName = true
|
||||
if !srv.db.hasDefaultScope {
|
||||
if _, err := srv.db.conn.Exec(`ALTER TABLE nodes ADD COLUMN default_scope TEXT DEFAULT NULL`); err != nil {
|
||||
t.Fatalf("add default_scope column: %v", err)
|
||||
}
|
||||
srv.db.hasDefaultScope = true
|
||||
}
|
||||
f := func(v float64) *float64 { return &v }
|
||||
srv.cfg.Areas = map[string]AreaEntry{
|
||||
"AAR": {Label: "Aarhus by", RegionScope: "dk-aarhus", LatMin: f(56.05), LatMax: f(56.25), LonMin: f(9.95), LonMax: f(10.35)},
|
||||
}
|
||||
// A node with a scope, but NOT the area's own region — NodesMatchingArea
|
||||
// must come out as a real, present 0, same as the live #dk-vs-#dk-aarhus
|
||||
// case this feature was built to surface.
|
||||
if _, err := srv.db.conn.Exec(
|
||||
`INSERT INTO nodes (public_key, name, role, default_scope, lat, lon) VALUES ('aar00000001', 'AarhusNode', 'repeater', '#dk', 56.15, 10.15)`,
|
||||
); err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/scope-stats?window=24h", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleScopeStats(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&raw); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
areas, ok := raw["scopeAdoptionByArea"].([]interface{})
|
||||
if !ok || len(areas) != 1 {
|
||||
t.Fatalf("scopeAdoptionByArea = %v, want 1 entry", raw["scopeAdoptionByArea"])
|
||||
}
|
||||
area := areas[0].(map[string]interface{})
|
||||
val, present := area["nodesMatchingArea"]
|
||||
if !present {
|
||||
t.Fatal("nodesMatchingArea key is missing from the JSON entirely — omitempty regression, this crashes the frontend")
|
||||
}
|
||||
if val != float64(0) {
|
||||
t.Errorf("nodesMatchingArea = %v, want 0", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleScopeStatsInvalidWindow(t *testing.T) {
|
||||
srv, _ := setupTestServer(t)
|
||||
if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil {
|
||||
|
||||
+4
-2
@@ -216,8 +216,10 @@ type AreaScopeAdoption struct {
|
||||
// NodesMatchingArea is the subset of NodesWithAnyScope whose scope
|
||||
// matches this area's own RegionScope specifically. Only meaningful
|
||||
// when RegionScope is set — 0 otherwise (not the same as "0 of them
|
||||
// match", there's simply nothing configured to match against).
|
||||
NodesMatchingArea int `json:"nodesMatchingArea,omitempty"`
|
||||
// match", there's simply nothing configured to match against). No
|
||||
// omitempty: a real 0 count must still serialize, or the frontend has
|
||||
// no way to distinguish it from "field absent".
|
||||
NodesMatchingArea int `json:"nodesMatchingArea"`
|
||||
}
|
||||
|
||||
type RepeaterRef struct {
|
||||
|
||||
+2
-1
@@ -5079,8 +5079,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
|
||||
if (byArea.length > 0) {
|
||||
var areaRows = byArea.map(function(a) {
|
||||
var withScope = a.nodesWithAnyScope.toLocaleString() + ' (' + pct(a.nodesWithAnyScope, a.totalNodes) + ')';
|
||||
var matchCount = a.nodesMatchingArea || 0;
|
||||
var matching = a.regionScope
|
||||
? a.nodesMatchingArea.toLocaleString() + ' (' + pct(a.nodesMatchingArea, a.totalNodes) + ')' +
|
||||
? matchCount.toLocaleString() + ' (' + pct(matchCount, a.totalNodes) + ')' +
|
||||
' <span class="text-muted" style="font-size:0.85em">of <code>#' + esc(a.regionScope) + '</code></span>'
|
||||
: '<span class="text-muted" style="font-size:0.85em">no region linked to this area</span>';
|
||||
return '<tr><td>' + esc(a.label) + '</td>' +
|
||||
|
||||
Reference in New Issue
Block a user