From 8db5c298824038c738f53689fb670678cab1d6c9 Mon Sep 17 00:00:00 2001 From: flamingcow Date: Tue, 4 Aug 2026 15:46:12 -0700 Subject: [PATCH] Cover the loss window, rate window, error taxonomy and formatters with tests --- loss_test.go | 91 +++++++++++++++++++++++++++++++++++++++++ main_test.go | 64 ++++++++++++++++++++++++++++- probe_test.go | 25 ++++++++++++ render_test.go | 108 +++++++++++++++++++++++++++++++++++++++++++++++++ ui_test.go | 28 +++++++++++++ 5 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 loss_test.go create mode 100644 probe_test.go create mode 100644 render_test.go create mode 100644 ui_test.go diff --git a/loss_test.go b/loss_test.go new file mode 100644 index 0000000..b88616b --- /dev/null +++ b/loss_test.go @@ -0,0 +1,91 @@ +package main + +import "testing" + +func newWindow() *lossWindow { + w := newLossWindows(1) + return &w[0] +} + +func TestLossWindowContiguousLosesNothing(t *testing.T) { + w := newWindow() + for seq := uint64(0); seq < lossSlots+1000; seq++ { + w.observe(seq) + } + if got := w.lost.Load(); got != 0 { + t.Errorf("lost = %d, want 0", got) + } + if got := w.late.Load(); got != 0 { + t.Errorf("late = %d, want 0", got) + } +} + +func TestLossWindowCountsGapOnceEvicted(t *testing.T) { + w := newWindow() + for seq := uint64(0); seq < lossSlots+1000; seq++ { + if seq == 100 { + continue + } + w.observe(seq) + } + if got := w.lost.Load(); got != 1 { + t.Errorf("lost = %d, want 1", got) + } +} + +// Arriving out of order inside the window is not loss: a sequence is only +// judged once it falls out the far end. +func TestLossWindowOutOfOrderIsNotLoss(t *testing.T) { + w := newWindow() + for seq := uint64(99); ; seq-- { + w.observe(seq) + if seq == 0 { + break + } + } + for seq := uint64(100); seq < lossSlots+1000; seq++ { + w.observe(seq) + } + if got := w.lost.Load(); got != 0 { + t.Errorf("lost = %d, want 0", got) + } +} + +// The other branch of evict: a jump past a whole window writes off everything +// the window held plus the sequences that never landed in it at all. +func TestLossWindowJumpBeyondWindow(t *testing.T) { + w := newWindow() + w.observe(0) + w.observe(200000) + + // Everything below the new base except the one sequence that was seen. + want := uint64(200000 - lossSlots + 1 - 1) + if got := w.lost.Load(); got != want { + t.Errorf("lost = %d, want %d", got, want) + } + if got := w.late.Load(); got != 0 { + t.Errorf("late = %d, want 0", got) + } +} + +func TestLossWindowBelowBaseIsLate(t *testing.T) { + w := newWindow() + w.observe(100000) + w.observe(1000) + if got := w.late.Load(); got != 1 { + t.Errorf("late = %d, want 1", got) + } + if got := w.lost.Load(); got != 0 { + t.Errorf("lost = %d, want 0", got) + } +} + +// The first sequence seen starts the window half a span below it, so frames +// another worker is still holding land inside rather than arriving late. +func TestLossWindowStartsHalfAWindowBack(t *testing.T) { + w := newWindow() + w.observe(100000) + if w.base != 100000-lossSlots/2 { + t.Errorf("base = %d, want %d", w.base, 100000-lossSlots/2) + } +} diff --git a/main_test.go b/main_test.go index 07edfe4..1f009ec 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,68 @@ package main -import "testing" +import ( + "testing" + "time" +) + +func TestRateWindowLatestNeedsTwoBuckets(t *testing.T) { + w := newRateWindow(4) + if got := w.latest(rxRatePPS); got != 0 { + t.Errorf("empty ring gave %v, want 0", got) + } + w.push(counterSet{t: time.Now(), s: sample{rxFrames: 100}}) + if got := w.latest(rxRatePPS); got != 0 { + t.Errorf("one bucket gave %v, want 0", got) + } +} + +// The newest pair alone, so a step in the rate shows at once instead of being +// averaged against everything still in the ring. +func TestRateWindowLatestUsesNewestPair(t *testing.T) { + w := newRateWindow(4) + t0 := time.Now() + w.push(counterSet{t: t0, s: sample{rxFrames: 100}}) + w.push(counterSet{t: t0.Add(time.Second), s: sample{rxFrames: 300}}) + if got := w.latest(rxRatePPS); got != 200 { + t.Errorf("rate = %v, want 200", got) + } + w.push(counterSet{t: t0.Add(2 * time.Second), s: sample{rxFrames: 400}}) + if got := w.latest(rxRatePPS); got != 100 { + t.Errorf("rate = %v, want 100 rather than the mean of the ring", got) + } +} + +func TestRateWindowLatestIgnoresZeroSpan(t *testing.T) { + w := newRateWindow(4) + t0 := time.Now() + w.push(counterSet{t: t0, s: sample{rxFrames: 100}}) + w.push(counterSet{t: t0, s: sample{rxFrames: 300}}) + if got := w.latest(rxRatePPS); got != 0 { + t.Errorf("rate = %v, want 0", got) + } +} + +// Which counter feeds which bucket is the whole taxonomy the panel reports, so +// it is pinned here rather than left to whoever reads errsBetween next. +func TestErrsBetweenBuckets(t *testing.T) { + n := counterSet{ + s: sample{ + lost: 1, late: 7, + crcErr: 2, badMagic: 3, badLen: 4, + txErrs: 6, rxErrs: 5, + }, + drops: 9, + nic: 8, + } + got := errsBetween(counterSet{}, n) + want := errs{lost: 1, corrupt: 2 + 3 + 4, link: 8 + 5, internal: 9 + 7 + 6} + if got != want { + t.Errorf("errsBetween = %+v, want %+v", got, want) + } + if got.total() != 45 { + t.Errorf("total = %d, want 45", got.total()) + } +} // A reset re-bases from a fresh capture while the ring still holds buckets from // just before it, so the newest bucket must not be left behind the new origin. diff --git a/probe_test.go b/probe_test.go new file mode 100644 index 0000000..72156b7 --- /dev/null +++ b/probe_test.go @@ -0,0 +1,25 @@ +package main + +import "testing" + +func stamped(min, floor int64) view { + return view{cable: cableView{min: min, floor: floor, ok: true}} +} + +// Averaged across directions, since one direction alone carries a phy asymmetry +// that swamps the cable. +func TestCableMetresAveragesDirections(t *testing.T) { + m, ok := cableMetres([]view{stamped(1000, 900), stamped(1100, 900)}, 5) + if !ok || m != 30 { + t.Errorf("cableMetres = %v, %v; want 30, true", m, ok) + } +} + +func TestCableMetresNeedsEveryDirection(t *testing.T) { + if _, ok := cableMetres(nil, 5); ok { + t.Error("no views should not yield a length") + } + if _, ok := cableMetres([]view{stamped(1000, 900), {}}, 5); ok { + t.Error("a direction with no stamp yet should not yield a length") + } +} diff --git a/render_test.go b/render_test.go new file mode 100644 index 0000000..3d16f29 --- /dev/null +++ b/render_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "testing" + "time" +) + +func TestScaleSI(t *testing.T) { + for _, c := range []struct { + in float64 + want string + }{ + {0, "0.00"}, + {999, "999.00"}, + {1000, "1.00 k"}, + {1234567, "1.23 M"}, + {1e12, "1.00 T"}, + {1e15, "1.00 P"}, + } { + if got := scaleSI(c.in); got != c.want { + t.Errorf("scaleSI(%v) = %q, want %q", c.in, got, c.want) + } + } +} + +// Below a thousand a count is the count, since two decimals on a quantity that +// cannot have them read as precision that is not there. +func TestScaleCount(t *testing.T) { + for _, c := range []struct { + in uint64 + want string + }{ + {0, "0"}, + {999, "999"}, + {1000, "1.00 k"}, + } { + if got := scaleCount(c.in); got != c.want { + t.Errorf("scaleCount(%d) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestScaleTime(t *testing.T) { + for _, c := range []struct { + in time.Duration + want string + }{ + {1500 * time.Millisecond, "1.50 s"}, + {90 * time.Second, "1.50 m"}, + {90 * time.Minute, "1.50 h"}, + {36 * time.Hour, "1.50 d"}, + } { + if got := scaleTime(c.in); got != c.want { + t.Errorf("scaleTime(%v) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestCommas(t *testing.T) { + for _, c := range []struct { + in uint64 + want string + }{ + {0, "0"}, + {999, "999"}, + {1000, "1,000"}, + {1234567, "1,234,567"}, + } { + if got := commas(c.in); got != c.want { + t.Errorf("commas(%d) = %q, want %q", c.in, got, c.want) + } + } +} + +// The table only lines up if colour codes are not counted as width. +func TestPadIgnoresColour(t *testing.T) { + if got := visWidth(paint("ok", cGreen)); got != 2 { + t.Errorf("visWidth = %d, want 2", got) + } + if got := visWidth(pad(paint("ok", cGreen), 6, true)); got != 6 { + t.Errorf("padded width = %d, want 6", got) + } +} + +// The break between the two halves of the row has to be the same width on the +// header, the rule and every value line, or the columns drift apart. +func TestStreamTableBreakAligns(t *testing.T) { + tbl := &streamTable{cols: intervalCols} + lines := tbl.headerLines() + row := tbl.emit(make([]string, len(intervalCols))) + widths := map[string]int{} + for _, l := range append(lines, row...) { + widths[l] = visWidth(l) + } + var first int + for _, w := range widths { + if first == 0 { + first = w + continue + } + if w != first { + t.Fatalf("header and value lines disagree on width: %v", widths) + } + } + if first != tbl.width() { + t.Errorf("lines are %d wide, width() reports %d", first, tbl.width()) + } +} diff --git a/ui_test.go b/ui_test.go new file mode 100644 index 0000000..b1d2e23 --- /dev/null +++ b/ui_test.go @@ -0,0 +1,28 @@ +package main + +import "testing" + +func TestGridCellFullRow(t *testing.T) { + x0, w0 := gridCell(0, 2, 0, 100) + x1, w1 := gridCell(1, 2, 0, 100) + if w0 != w1 { + t.Errorf("cells differ in width: %d vs %d", w0, w1) + } + if x0 != 0 { + t.Errorf("first cell x = %d, want 0", x0) + } + if x1+w1 != 100 { + t.Errorf("row ends at %d, want 100", x1+w1) + } + if got := x1 - (x0 + w0); got != chipGap { + t.Errorf("gap between cells = %d, want %d", got, chipGap) + } +} + +// A last row that does not fill the grid is centred. +func TestGridCellShortLastRow(t *testing.T) { + cx, cw := gridCell(2, 3, 0, 100) + if left, right := cx, 100-(cx+cw); left != right { + t.Errorf("lone cell has %d left and %d right, want centred", left, right) + } +}