Files
OpsLog/internal/qso/stats_test.go
T

229 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package qso
import (
"testing"
"time"
)
// Bands must read in BAND-PLAN order (160m → 70cm), never by count and never
// alphabetically — the order of that chart IS the information.
func TestBandPlanOrder(t *testing.T) {
counts := map[string]int{"20m": 9312, "160m": 77, "70cm": 3, "40m": 5196, "10m": 3401, "80m": 2332}
got := sortedBuckets(counts, func(a, b string) bool {
oa, ob := bandOrder[a], bandOrder[b]
if oa == 0 {
oa = 99
}
if ob == 0 {
ob = 99
}
if oa != ob {
return oa < ob
}
return a < b
})
want := []string{"160m", "80m", "40m", "20m", "10m", "70cm"}
if len(got) != len(want) {
t.Fatalf("got %d buckets, want %d", len(got), len(want))
}
for i := range want {
if got[i].Key != want[i] {
t.Errorf("position %d = %q, want %q (full: %v)", i, got[i].Key, want[i], got)
}
}
}
// A nil Go slice marshals to JSON `null`, not `[]` — and the UI then calls
// .length/.map on null, which unmounts the whole React tree and leaves a WHITE
// SCREEN. It bites in the innocent cases: a contest with no break ≥ 30 min (Gaps
// nil), or a window too long for the hourly chart (Rate nil). Every slice the
// dashboard reads must therefore come back non-nil, even when empty.
func TestStatsNoNilSlices(t *testing.T) {
var s Stats // the worst case: nothing computed at all
s.ensureNonNil()
checks := map[string]bool{
"ByMode": s.ByMode == nil, "ByBand": s.ByBand == nil, "ByOperator": s.ByOperator == nil,
"ByStation": s.ByStation == nil, "ByContinent": s.ByContinent == nil,
"TopEntities": s.TopEntities == nil, "ByYear": s.ByYear == nil, "ByMonth": s.ByMonth == nil,
"Rate": s.Rate == nil, "Gaps": s.Gaps == nil,
}
for name, isNil := range checks {
if isNil {
t.Errorf("%s is nil → marshals to JSON null → white screen in the UI", name)
}
}
// The realistic trigger: a short, gap-free run. No silence ≥ 30 min and a
// window that yields no hourly chart must still produce [] and not null.
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
times := []entry{{t: base, op: "A"}, {t: base.Add(2 * time.Minute), op: "A"}, {t: base.Add(5 * time.Minute), op: "B"}}
var s2 Stats
s2.periodMetrics(times, time.Time{}, time.Time{}, base, base.Add(5*time.Minute))
s2.ensureNonNil()
if s2.Gaps == nil {
t.Error("Gaps nil for a gap-free run — this is exactly the contest that white-screened")
}
if len(s2.Gaps) != 0 {
t.Errorf("Gaps = %v, want empty (no silence ≥ 30 min in this run)", s2.Gaps)
}
}
// Contest metrics over a window. The two traps:
// 1. "Best hour" must be the best ROLLING 60 minutes, not the best clock hour —
// a run straddling 13:4514:45 is invisible to clock-hour bucketing, and the
// rolling figure is the one contesters quote.
// 2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included)
// AND QSOs ÷ hours actually operated. Quoting only the latter is how an
// 8-hour effort gets sold as a 48-hour score.
func TestContestPeriodMetrics(t *testing.T) {
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
// A run straddling the clock hour: 10 QSOs from 12:40 to 13:20 (within 60 min),
// then a 2-hour silence, then 3 more.
var times []entry
for i := 0; i < 10; i++ {
times = append(times, entry{t: at(40 + i*4), op: "F4BPO"}) // 12:40 … 13:16
}
for i := 0; i < 3; i++ {
times = append(times, entry{t: at(240 + i*5), op: "F5XYZ"}) // 16:00 …
}
from := base // 12:00
to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window
var s Stats
s.periodMetrics(times, from, to, time.Time{}, time.Time{})
if s.WindowHours != 6 {
t.Errorf("window = %.1f h, want 6", s.WindowHours)
}
// 13 QSOs over a 6 h window.
if got := s.AvgPerHour; got < 2.16 || got > 2.17 {
t.Errorf("avg/h over the window = %.3f, want ~2.167 (13÷6)", got)
}
// The rolling hour must find the straddling run of 10 — a clock-hour bucket
// would only ever see part of it.
if s.Best60 != 10 {
t.Errorf("best rolling 60 min = %d, want 10 (the 12:40→13:16 run)", s.Best60)
}
if s.PeakHourCount >= 10 {
t.Errorf("peak CLOCK hour = %d — it should be < 10, which is exactly why the rolling figure exists", s.PeakHourCount)
}
// THE INVARIANT: on-air + off-air must close on the window. The first version
// counted "clock hours containing a QSO" as on-air, which on a real 45 h contest
// reported 39 h on air AND 16 h 43 off air — 56 h inside 45 h. Two numbers on
// incompatible bases; the operator believed neither, and was right.
if got := s.OnAirMinutes + s.OffAirMinutes; got != int(s.WindowHours*60) {
t.Errorf("on-air (%d) + off-air (%d) = %d min, but the window is %d min — the budget must close",
s.OnAirMinutes, s.OffAirMinutes, got, int(s.WindowHours*60))
}
// Off air = lead-in (12:00→12:40 = 40 min) + the 13:16→16:00 silence (164) +
// the tail (16:10→18:00 = 110). Silences ≥ 30 min all count, wherever they sit:
// ignoring the lead-in and tail is what broke the budget.
if s.OffAirMinutes != 40+164+110 {
t.Errorf("off-air = %d min, want %d (lead-in + gap + tail)", s.OffAirMinutes, 40+164+110)
}
if len(s.Gaps) != 3 {
t.Fatalf("gaps = %+v, want 3 (lead-in, the silence, the tail)", s.Gaps)
}
if s.AvgPerActive <= s.AvgPerHour {
t.Errorf("avg/on-air (%.2f) must exceed avg/window (%.2f) when there are breaks", s.AvgPerActive, s.AvgPerHour)
}
// The rate timeline covers EVERY hour of the window, silences as zeros.
if len(s.Rate) != 7 { // 12,13,14,15,16,17,18
t.Fatalf("rate timeline = %d hours, want 7 (every hour of the window)", len(s.Rate))
}
if s.Rate[2].Count != 0 || s.Rate[3].Count != 0 {
t.Errorf("the 14:00/15:00 silence must show as zeros, got %+v %+v", s.Rate[2], s.Rate[3])
}
}
// The contest RATE SHEET: hour by hour, who made the QSOs.
//
// The invariant that matters: for EVERY hour, the per-operator numbers must sum to
// that hour's total. Derive the two separately and a rate sheet quietly stops
// adding up to its own total row — the sort of error nobody spots until someone
// checks the score by hand.
func TestRateSheetSumsToHourTotal(t *testing.T) {
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
times := []entry{
{t: at(5), op: "F4BPO"}, {t: at(10), op: "F4BPO"}, {t: at(20), op: "F5XYZ"}, // hour 12: 3
{t: at(70), op: "F5XYZ"}, {t: at(80), op: "F5XYZ"}, // hour 13: 2
// hour 14 silent
{t: at(185), op: "F4BPO"}, // hour 15: 1
}
var s Stats
s.periodMetrics(times, base, base.Add(4*time.Hour), time.Time{}, time.Time{})
if len(s.Rate) != len(s.RateByOp) {
t.Fatalf("rate rows (%d) and rate-sheet rows (%d) must align 1:1", len(s.Rate), len(s.RateByOp))
}
// Both operators present, busiest first (they tie at 3 → alphabetical).
if len(s.RateOps) != 2 || s.RateOps[0] != "F4BPO" {
t.Fatalf("rate ops = %v, want [F4BPO F5XYZ]", s.RateOps)
}
for h := range s.Rate {
sum := 0
for _, n := range s.RateByOp[h] {
sum += n
}
if sum != s.Rate[h].Count {
t.Errorf("hour %s: operators sum to %d but the hour total is %d — the rate sheet doesn't add up",
s.Rate[h].Key, sum, s.Rate[h].Count)
}
if len(s.RateByOp[h]) != len(s.RateOps) {
t.Errorf("hour %s: row has %d columns, want %d (one per operator)", s.Rate[h].Key, len(s.RateByOp[h]), len(s.RateOps))
}
}
// The silent hour is a row of zeros, not a missing row.
if s.Rate[2].Count != 0 || s.RateByOp[2][0] != 0 || s.RateByOp[2][1] != 0 {
t.Errorf("the silent 14:00 hour must be zeros, got total=%d row=%v", s.Rate[2].Count, s.RateByOp[2])
}
}
// A quiet decade must appear on the trend as a decade AT ZERO. Emitting only the
// months that have QSOs would put 2012 next to 2022 as if consecutive — the chart
// would invent activity that never happened.
func TestTimeAxisIsContinuous(t *testing.T) {
first := time.Date(2009, 5, 30, 0, 0, 0, 0, time.UTC)
last := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
years := fillYears(map[string]int{"2009": 79, "2012": 1187, "2026": 14415}, first, last)
if len(years) != 18 { // 2009..2026 inclusive
t.Fatalf("years = %d, want 18 (2009→2026 with no holes)", len(years))
}
byKey := map[string]int{}
for _, b := range years {
byKey[b.Key] = b.Count
}
if byKey["2010"] != 0 || byKey["2018"] != 0 {
t.Errorf("silent years must be present as zero, got 2010=%d 2018=%d", byKey["2010"], byKey["2018"])
}
if byKey["2012"] != 1187 || byKey["2026"] != 14415 {
t.Errorf("real counts lost: 2012=%d 2026=%d", byKey["2012"], byKey["2026"])
}
months := fillMonths(map[string]int{"2009-05": 49, "2026-07": 1}, first, last)
// May 2009 → July 2026 inclusive = 17 years * 12 + 3 = 207 months.
if len(months) != 207 {
t.Fatalf("months = %d, want 207 (continuous)", len(months))
}
if months[0].Key != "2009-05" || months[0].Count != 49 {
t.Errorf("first month = %+v, want 2009-05 / 49", months[0])
}
if months[len(months)-1].Key != "2026-07" {
t.Errorf("last month = %q, want 2026-07", months[len(months)-1].Key)
}
// Every step is exactly one month — no jumps.
for i := 1; i < len(months); i++ {
prev, _ := time.Parse("2006-01", months[i-1].Key)
cur, _ := time.Parse("2006-01", months[i].Key)
if !prev.AddDate(0, 1, 0).Equal(cur) {
t.Fatalf("gap in the time axis between %q and %q", months[i-1].Key, months[i].Key)
}
}
}