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: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 []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) } } }