package main import ( "testing" "time" ) // The panel must not become one station repeated. PSK Reporter re-reports a // calling operator every cycle, and dozens of receivers report the same // transmission, so a station arrives many times a minute. func TestChaseNewStoreDeduplicates(t *testing.T) { s := newChaseNewStore() t0 := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) row := ChaseNewSpot{Call: "VK9XX", Band: "20m", Mode: "FT8", At: t0.Format(time.RFC3339)} if !s.put(row, t0) { t.Fatal("the first sighting was rejected") } if s.put(row, t0.Add(2*time.Minute)) { t.Error("the same station on the same band and mode was listed twice") } // A different band is a different opportunity — a new-band slot is exactly // what an operator is watching for. other := row other.Band = "15m" if !s.put(other, t0.Add(2*time.Minute)) { t.Error("the same station on another band was suppressed") } // Once the window has passed it is worth showing again: the station is still // there, and the row it had has aged out of the list. if !s.put(row, t0.Add(chaseSeenTTL+time.Minute)) { t.Error("the station never came back after the de-duplication window") } } // The list is aged with the operator's own spot lifetime, so a station heard an // hour ago is not offered as something to chase now. func TestChaseNewStoreAgesOut(t *testing.T) { s := newChaseNewStore() t0 := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) s.put(ChaseNewSpot{Call: "OLD", Band: "20m", Mode: "FT8", At: t0.Format(time.RFC3339)}, t0) s.put(ChaseNewSpot{Call: "NEW", Band: "20m", Mode: "FT8", At: t0.Add(20 * time.Minute).Format(time.RFC3339)}, t0.Add(20*time.Minute)) got := s.list(15*time.Minute, t0.Add(21*time.Minute)) if len(got) != 1 || got[0].Call != "NEW" { t.Fatalf("got %+v, want only NEW", got) } // Newest first: an operator reads the top of this list and nothing else. s.put(ChaseNewSpot{Call: "NEWEST", Band: "20m", Mode: "FT8", At: t0.Add(25 * time.Minute).Format(time.RFC3339)}, t0.Add(25*time.Minute)) got = s.list(15*time.Minute, t0.Add(26*time.Minute)) if len(got) != 2 || got[0].Call != "NEWEST" { t.Fatalf("got %+v, want NEWEST first", got) } } // The list is bounded. A widget that grows without limit under a 6 m opening // costs memory for rows nobody will ever scroll to. func TestChaseNewStoreIsBounded(t *testing.T) { s := newChaseNewStore() t0 := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) for i := 0; i < chaseNewMax+50; i++ { at := t0.Add(time.Duration(i) * time.Second) s.put(ChaseNewSpot{ Call: "S" + time.Duration(i).String(), Band: "20m", Mode: "FT8", At: at.Format(time.RFC3339), }, at) } if got := len(s.list(0, t0.Add(time.Hour))); got != chaseNewMax { t.Errorf("kept %d rows, want the %d cap", got, chaseNewMax) } } // The panel is for stations an operator can call. WSPR is a beacon — nobody // answers one — and on a quiet band it would be most of the list. func TestChaseModesExcludeBeaconsAndOddities(t *testing.T) { for _, m := range []string{"FT8", "FT4", "FT2", "PSK31", "RTTY"} { if !chaseModes[m] { t.Errorf("%s should be listed", m) } } for _, m := range []string{"WSPR", "JT65", "JT9", "FST4W", "Q65", "JS8", "MSK144", "OLIVIA", ""} { if chaseModes[m] { t.Errorf("%s should not be listed", m) } } } // A decode on a band the operator does not have is not an opportunity, it is a // row in the way — PSK Reporter carries microwave segments nobody in the region // is equipped for. func TestChaseBandAllowed(t *testing.T) { a := &App{} // Nothing loaded yet: show rather than swallow, or the panel would look // broken for the first seconds after it is switched on. if !a.chaseBandAllowed("13cm") { t.Error("with no band list loaded, nothing should be filtered") } a.chaseBands = map[string]bool{"20m": true, "40m": true, "6m": true} for _, b := range []string{"20m", "40m", "6m", " 20M "} { if !a.chaseBandAllowed(b) { t.Errorf("%q is in the operator's list and was dropped", b) } } for _, b := range []string{"13cm", "23cm", "2m", "630m", ""} { if a.chaseBandAllowed(b) { t.Errorf("%q is not in the operator's list and was kept", b) } } }