151 lines
4.6 KiB
Go
151 lines
4.6 KiB
Go
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. The
|
|
// buffers must reach back past the window itself, because the capture is read
|
|
// out a margin and a tick after its last sample lands.
|
|
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+captureLagSec {
|
|
t.Fatalf("activeWindowSec = %v, want the trigger's 45 plus the %v harvest lag",
|
|
got, captureLagSec)
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|