feat: added extra stats for contests

This commit is contained in:
2026-07-13 16:53:37 +02:00
parent 68982e9a85
commit ae60d58893
5 changed files with 313 additions and 32 deletions
+49 -4
View File
@@ -57,7 +57,7 @@ func TestStatsNoNilSlices(t *testing.T) {
// 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)}
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()
@@ -82,12 +82,12 @@ func TestContestPeriodMetrics(t *testing.T) {
// 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
var times []entry
for i := 0; i < 10; i++ {
times = append(times, at(40+i*4)) // 12:40 … 13:16
times = append(times, entry{t: at(40 + i*4), op: "F4BPO"}) // 12:40 … 13:16
}
for i := 0; i < 3; i++ {
times = append(times, at(240+i*5)) // 16:00 …
times = append(times, entry{t: at(240 + i*5), op: "F5XYZ"}) // 16:00 …
}
from := base // 12:00
@@ -139,6 +139,51 @@ func TestContestPeriodMetrics(t *testing.T) {
}
}
// 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.