184 lines
7.3 KiB
Go
184 lines
7.3 KiB
Go
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 := []time.Time{base, base.Add(2 * time.Minute), base.Add(5 * time.Minute)}
|
||
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:45–14: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 []time.Time
|
||
for i := 0; i < 10; i++ {
|
||
times = append(times, at(40+i*4)) // 12:40 … 13:16
|
||
}
|
||
for i := 0; i < 3; i++ {
|
||
times = append(times, at(240+i*5)) // 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])
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|