Files
meshcore-analyzer/cmd/server/observer_analytics_test.go
T
d60188e481 refactor(1828): split handleObserverAnalytics into 5 helpers + byTxID fast-path (#1839)
## Summary

Phase A of #1828: extract the 5 aggregate builders in
`handleObserverAnalytics` into pure helpers in a new
`cmd/server/observer_analytics.go`. Handler becomes a snapshot + filter
+ 5 composed calls.

Also adopts the `byTxID` direct-read in `buildPacketTypes` (issue body's
core observation): the payload-type histogram no longer allocates a full
`enrichObs` map + interface-boxed fields just to read `tx.PayloadType`.
That's the ~90% perf win the triage called out.

Scope is exactly Phase A per the second triage comment. Phase B
(sub-endpoints, caching, SQL migration) is deferred to a follow-up.

## Byte-identical output

- Timeline / NodesTimeline: same key set, same sort, same labels.
- PacketTypes: same keys/counts. Both legacy
(`enriched["payload_type"].(int)`) and new (`tx.PayloadType == nil`
guard) skip obs whose tx is missing or `PayloadType` is `nil`.
- SnrDistribution: same 2-unit floor bucketing (negative-side rounding
preserved), same ascending sort.
- RecentPackets: still the first 20 enriched observations (`enrichObs`
kept only here, where the extra fields are actually needed).

## TDD

- Red commit: `9dc62f43` — 7 unit tests fail on assertions (not build
errors) against stubs.
- Green commit: `8d41011d` — implementations + handler rewire. All new
tests + existing `TestObserverAnalytics*` handler tests pass.

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ clean (all 8 hard gates + 3 warnings pass).

## Non-goals

- No new endpoints.
- No SQL migration.
- No public API signature change.
- Snapshot count unchanged (still one under RLock, per #1481 P0-2).

Fixes #1828.

---------

Co-authored-by: fix-1828-bot <bot@corescope.local>
Co-authored-by: clawbot <bot@corescope>
2026-07-09 19:14:44 -07:00

219 lines
7.4 KiB
Go

package main
import (
"sync"
"testing"
"time"
)
// TestObserverAnalyticsBucketDur pins the bucket-size table copied from the
// legacy handler (#1828 Phase A).
func TestObserverAnalyticsBucketDur(t *testing.T) {
cases := []struct {
days int
want time.Duration
}{
{1, time.Hour},
{7, 4 * time.Hour},
{8, 24 * time.Hour},
{30, 24 * time.Hour},
}
for _, c := range cases {
if got := observerAnalyticsBucketDur(c.days); got != c.want {
t.Errorf("bucketDur(%d) = %v, want %v", c.days, got, c.want)
}
}
}
// TestObserverAnalyticsFormatLabel pins the label formatting per day range.
func TestObserverAnalyticsFormatLabel(t *testing.T) {
ts := time.Date(2026, 3, 4, 15, 30, 0, 0, time.UTC)
if got := observerAnalyticsFormatLabel(1)(ts); got != "15:30" {
t.Errorf("days=1 label = %q, want %q", got, "15:30")
}
if got := observerAnalyticsFormatLabel(7)(ts); got != "Wed 15:30" {
t.Errorf("days=7 label = %q, want %q", got, "Wed 15:30")
}
if got := observerAnalyticsFormatLabel(30)(ts); got != "Mar 04" {
t.Errorf("days=30 label = %q, want %q", got, "Mar 04")
}
}
// buildObsForTest constructs a StoreObs with the parsed-time cache primed so
// tests don't depend on time.Parse in the helpers.
func buildObsForTest(txID int, ts time.Time, snr *float64, pathJSON string) *StoreObs {
o := &StoreObs{
TransmissionID: txID,
Timestamp: ts.UTC().Format(time.RFC3339Nano),
SNR: snr,
PathJSON: pathJSON,
}
// Prime the cache by calling ParsedTime once.
o.ParsedTime()
return o
}
func newStoreForAnalyticsTest() *PacketStore {
return &PacketStore{
mu: sync.RWMutex{},
byTxID: map[int]*StoreTx{},
}
}
// TestBuildPacketTypesDirectRead verifies the packet-type histogram builds
// from store.byTxID, and skips obs whose tx or tx.PayloadType is missing.
func TestBuildPacketTypesDirectRead(t *testing.T) {
store := newStoreForAnalyticsTest()
pt3, pt5 := 3, 5
store.byTxID[1] = &StoreTx{ID: 1, PayloadType: &pt3}
store.byTxID[2] = &StoreTx{ID: 2, PayloadType: &pt5}
store.byTxID[3] = &StoreTx{ID: 3, PayloadType: nil} // no type → skip
// tx=4 not in map → skip
now := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
filtered := []*StoreObs{
buildObsForTest(1, now, nil, "[]"),
buildObsForTest(1, now, nil, "[]"),
buildObsForTest(2, now, nil, "[]"),
buildObsForTest(3, now, nil, "[]"),
buildObsForTest(4, now, nil, "[]"),
}
got := buildPacketTypes(store, filtered)
if got["3"] != 2 {
t.Errorf("packetTypes[3] = %d, want 2", got["3"])
}
if got["5"] != 1 {
t.Errorf("packetTypes[5] = %d, want 1", got["5"])
}
if _, ok := got["0"]; ok {
t.Errorf("packetTypes should not contain missing-type entries: %v", got)
}
if len(got) != 2 {
t.Errorf("packetTypes has %d keys, want 2 (got %v)", len(got), got)
}
}
// TestBuildTimelineBuckets asserts that the timeline aggregates by bucketDur
// and returns entries sorted by bucket time.
func TestBuildTimelineBuckets(t *testing.T) {
base := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
filtered := []*StoreObs{
buildObsForTest(1, base, nil, "[]"),
buildObsForTest(1, base.Add(30*time.Minute), nil, "[]"),
buildObsForTest(1, base.Add(2*time.Hour), nil, "[]"),
}
// days=1 → 1-hour buckets → two buckets, counts 2 and 1
got := buildTimeline(filtered, 1)
if len(got) != 2 {
t.Fatalf("timeline entries = %d, want 2 (got %+v)", len(got), got)
}
if got[0].Count != 2 || got[1].Count != 1 {
t.Errorf("timeline counts = [%d, %d], want [2, 1]", got[0].Count, got[1].Count)
}
}
// TestBuildSnrDistribution asserts 2-unit floor bucketing over SNR values.
func TestBuildSnrDistribution(t *testing.T) {
f := func(v float64) *float64 { return &v }
now := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
filtered := []*StoreObs{
buildObsForTest(1, now, f(12.5), "[]"), // bucket 12
buildObsForTest(1, now, f(13.9), "[]"), // bucket 12
buildObsForTest(1, now, f(-1.0), "[]"), // bucket -2
buildObsForTest(1, now, nil, "[]"), // no SNR → skip
}
got := buildSnrDistribution(filtered)
if len(got) != 2 {
t.Fatalf("snr entries = %d, want 2 (got %+v)", len(got), got)
}
// sorted ascending by bucket
if got[0].Range != "-2 to 0" || got[0].Count != 1 {
t.Errorf("snr[0] = %+v, want {Range:'-2 to 0', Count:1}", got[0])
}
if got[1].Range != "12 to 14" || got[1].Count != 2 {
t.Errorf("snr[1] = %+v, want {Range:'12 to 14', Count:2}", got[1])
}
}
// TestBuildRecentPacketsLimit asserts that recentPackets returns at most
// `limit` entries taken from the head of `filtered`.
func TestBuildRecentPacketsLimit(t *testing.T) {
store := newStoreForAnalyticsTest()
pt := 3
store.byTxID[1] = &StoreTx{ID: 1, PayloadType: &pt}
base := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
filtered := make([]*StoreObs, 0, 25)
for i := 0; i < 25; i++ {
filtered = append(filtered, buildObsForTest(1, base.Add(-time.Duration(i)*time.Minute), nil, "[]"))
}
got := buildRecentPackets(store, filtered, 20)
if len(got) != 20 {
t.Errorf("recentPackets len = %d, want 20", len(got))
}
}
// TestBuildRecentPacketsSkipsUnparseableTimestamp asserts obs with an
// unparseable Timestamp are dropped BEFORE the top-N slice — matching the
// legacy pre-refactor loop (routes.go pre-#1828: `if !ok { continue }` sits
// above the `i < 20` gate). Regression guard for #1839 MAJOR.
func TestBuildRecentPacketsSkipsUnparseableTimestamp(t *testing.T) {
store := newStoreForAnalyticsTest()
pt := 3
store.byTxID[1] = &StoreTx{ID: 1, PayloadType: &pt}
base := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
filtered := make([]*StoreObs, 0, 23)
// 3 head observations with an unparseable timestamp — legacy skipped them
// before the index-gate, so they must NOT consume slots in the top-20.
for i := 0; i < 3; i++ {
filtered = append(filtered, &StoreObs{
TransmissionID: 1,
Timestamp: "not-a-timestamp",
PathJSON: "[]",
})
}
// 20 good observations after them.
for i := 0; i < 20; i++ {
filtered = append(filtered, buildObsForTest(1, base.Add(-time.Duration(i)*time.Minute), nil, "[]"))
}
got := buildRecentPackets(store, filtered, 20)
// Legacy loop: index 0-2 skipped (bad ts), index 3-19 appended under the
// i<20 gate (17 entries), index 20-22 dropped (i>=20). Result len = 17.
if len(got) != 17 {
t.Errorf("recentPackets len = %d, want 17 (unparseable-ts obs at head must be skipped before top-N gate)", len(got))
}
// Sanity: no entry should carry the bad Timestamp string.
for i, e := range got {
if ts, _ := e["timestamp"].(string); ts == "not-a-timestamp" {
t.Errorf("recentPackets[%d] contains unparseable-ts obs (timestamp=%q)", i, ts)
}
}
}
// TestBuildNodesTimelineDistinct asserts that nodes-timeline counts distinct
// nodes per bucket (path hops + decoded pubKey/srcHash/destHash).
func TestBuildNodesTimelineDistinct(t *testing.T) {
store := newStoreForAnalyticsTest()
pt := 4
// tx=1 has decoded_json with a pubKey
store.byTxID[1] = &StoreTx{
ID: 1,
PayloadType: &pt,
DecodedJSON: `{"pubKey":"aaaa"}`,
}
base := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
filtered := []*StoreObs{
buildObsForTest(1, base, nil, `["bb","cc"]`), // bucket A: nodes {aaaa, bb, cc}
buildObsForTest(1, base.Add(10*time.Minute), nil, `["bb"]`), // same bucket: dedup
}
got := buildNodesTimeline(store, filtered, 1)
if len(got) != 1 {
t.Fatalf("nodes timeline entries = %d, want 1 (got %+v)", len(got), got)
}
if got[0].Count != 3 {
t.Errorf("nodes timeline count = %d, want 3 (distinct: aaaa, bb, cc)", got[0].Count)
}
}