package wshub import ( "math" "testing" ) // dump returns the ring's contents oldest-first, which is what every reader // sees through slice() but is easier to assert on directly. func dump(rb *sigRing) ([]float64, []float64) { return rb.slice(math.Inf(-1), math.Inf(1)) } func TestRingBucketStoresMinMaxPairsInTimeOrder(t *testing.T) { rb := newSigRing(100) rb.setBucket(4) // Two buckets. In the first the minimum comes before the maximum, in the // second the order is reversed, so the emitted pairs must not be sorted by // value — a ring whose timestamps are not monotonic breaks slice()'s // binary search. ts := []float64{0, 1, 2, 3, 4, 5, 6, 7} vs := []float64{-5, 0, 0, 9, 9, 0, 0, -5} rb.write(ts, vs) gotT, gotV := dump(rb) wantT := []float64{0, 3, 4, 7} wantV := []float64{-5, 9, 9, -5} if len(gotT) != len(wantT) { t.Fatalf("stored %d points, want %d", len(gotT), len(wantT)) } for i := range wantT { if gotT[i] != wantT[i] || gotV[i] != wantV[i] { t.Fatalf("point %d = (%v,%v), want (%v,%v)", i, gotT[i], gotV[i], wantT[i], wantV[i]) } } } func TestRingBucketExtendsTheSpanAFixedCapacityCovers(t *testing.T) { const cap = 200 // 2000 samples at 1 kHz is 2 s, ten times what the capacity holds verbatim. ts := make([]float64, 2000) vs := make([]float64, 2000) for i := range ts { ts[i] = float64(i) * 1e-3 vs[i] = math.Sin(float64(i)) } full := newSigRing(cap) full.write(ts, vs) if _, span := full.stats(); span > 0.25 { t.Fatalf("full-rate ring spans %.3f s, expected ~0.2 s", span) } // bucket 20 turns 20 samples into 2 points, so the same capacity reaches // 10x further: 200/2*20 = 2000 samples = 2 s. bucketed := newSigRing(cap) bucketed.setBucket(20) bucketed.write(ts, vs) count, span := bucketed.stats() if count != cap { t.Fatalf("bucketed ring holds %d points, want the full %d", count, cap) } if span < 1.9 { t.Fatalf("bucketed ring spans %.3f s, want the whole ~2 s", span) } } func TestRingSourceRateIsUnaffectedByBucketing(t *testing.T) { rb := newSigRing(1000) rb.setBucket(50) ts := make([]float64, 5000) vs := make([]float64, 5000) for i := range ts { ts[i] = float64(i) * 1e-4 // 10 kHz } rb.write(ts, vs) got := rb.sourceRate() if math.Abs(got-10000) > 10 { t.Fatalf("sourceRate = %.1f, want ~10000", got) } } func TestSetBucketFlushesThePartialBucket(t *testing.T) { rb := newSigRing(100) rb.setBucket(10) // Three samples: not enough to close a bucket of 10, so nothing is stored // yet and they would be silently dropped by a re-bucket that just reset the // accumulator. rb.write([]float64{0, 1, 2}, []float64{7, -7, 0}) if n, _ := rb.stats(); n != 0 { t.Fatalf("partial bucket already emitted %d points", n) } rb.setBucket(2) gotT, gotV := dump(rb) if len(gotT) != 2 || gotT[0] != 0 || gotV[0] != 7 || gotT[1] != 1 || gotV[1] != -7 { t.Fatalf("flushed pair = %v/%v, want t=[0 1] v=[7 -7]", gotT, gotV) } } func TestActiveWindowSecFallsBackToTheDefault(t *testing.T) { h := NewHub() if got := h.activeWindowSec(); got != defaultLiveWindowSec { t.Fatalf("activeWindowSec with no clients = %v, want %v", got, defaultLiveWindowSec) } } // Clients disagree about how far back they are plotting, and a buffer sized for // the narrowest one leaves the others with nothing to zoom into. func TestActiveWindowSecTakesTheWidestClientWindow(t *testing.T) { h := NewHub() narrow, wide, silent := &wsClient{}, &wsClient{}, &wsClient{} narrow.setDisplayWindowSec(1) wide.setDisplayWindowSec(120) h.clients[narrow], h.clients[wide], h.clients[silent] = true, true, true if got := h.activeWindowSec(); got != 120 { t.Fatalf("activeWindowSec = %v, want the widest 120", got) } } // An armed trigger owns the window: its pre-window has to be in the buffer // before the trigger fires or the capture has nothing to back-fill from. func TestActiveWindowSecPrefersTheArmedTrigger(t *testing.T) { h := NewHub() c := &wsClient{} c.setDisplayWindowSec(1) h.clients[c] = true h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 45, mode: "normal"}) if got := h.activeWindowSec(); got != 45 { t.Fatalf("activeWindowSec = %v, want the trigger's 45", got) } } func TestSetBucketToOneRestoresVerbatimStorage(t *testing.T) { rb := newSigRing(100) rb.setBucket(4) rb.setBucket(1) ts := []float64{0, 1, 2, 3} vs := []float64{1, 2, 3, 4} rb.write(ts, vs) gotT, _ := dump(rb) if len(gotT) != 4 { t.Fatalf("stored %d points, want all 4", len(gotT)) } }