package pskrtgt import ( "testing" "time" ) // feed builds a watcher with a window already populated, so the analysis can be // pinned without a broker. func feed(target string, spots ...spot) *Watcher { w := New(Config{MyCall: "F4BPO", MyGrid: "JN36BQ"}) w.target, w.mode, w.dialHz = target, "FT8", 14_074_000 w.spots = spots return w } func rep(tx, txGrid, rx, rxGrid string, snr int, offset int, ago time.Duration) spot { return spot{ TxCall: tx, TxGrid: txGrid, RxCall: rx, RxGrid: rxGrid, SNR: snr, Freq: 14_074_000 + int64(offset), at: time.Now().Add(-ago), } } func TestHeardYouIsTheOperatorsOwnCallOnly(t *testing.T) { w := feed("YI5RLS", rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 20*time.Second), rep("F4XYZ", "JN36", "YI5RLS", "LM43", -8, 900, 30*time.Second), ) a := w.Snapshot() if !a.HeMe { t.Fatal("the DX decoded the operator and the panel says he did not") } if a.HeMeSNR != -14 || a.HeMeOffset != 1200 { t.Errorf("he_me = %d dB @ %d Hz, want -14 dB @ 1200 Hz", a.HeMeSNR, a.HeMeOffset) } // The operator is not part of the pileup he is calling into: counting // yourself as competition is how a "1 caller" band looks contested. if a.PileupCount != 1 { t.Errorf("pileup = %d, want 1 (the other station only)", a.PileupCount) } // F4XYZ shares the operator's Maidenhead field, so the path from this // region is demonstrably open. if !a.PathOpen || a.FromMyAreaCount != 1 { t.Errorf("from my area = %d (open=%v), want 1 open", a.FromMyAreaCount, a.PathOpen) } } func TestNearHimNeedsHisSquareAndTheOperatorsCall(t *testing.T) { // He transmits (so his square is known), and a station in that square hears // the operator. He himself has decoded nobody. w := feed("YI5RLS", rep("YI5RLS", "LM43", "OH5CX", "KP30", -3, 0, 40*time.Second), rep("F4BPO", "JN36", "YI9XY", "LM43CC", -19, 1500, 25*time.Second), ) a := w.Snapshot() if a.TargetGrid != "LM43" { t.Fatalf("his square = %q, want LM43", a.TargetGrid) } if a.NearHimCount != 1 || len(a.NearHimTop) != 1 || a.NearHimTop[0].Call != "YI9XY" { t.Errorf("near him = %d %v, want the one receiver in his square", a.NearHimCount, a.NearHimTop) } // He uploads nothing: the panel must be able to say so, or an operator // reads an empty panel as a closed band. if a.TargetUploads { t.Error("he received nothing in the window, yet the panel claims he uploads") } if a.HeMe { t.Error("nobody reported HIM decoding the operator") } } func TestWindowDropsWhatIsTooOld(t *testing.T) { // Inside the window: a report from six minutes ago is still evidence. Five // minutes was too short — measured against DXHunter on the same station at // the same moment, it hid a third of the decodes and a co-area station. w := feed("YI5RLS", rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 6*time.Minute), ) if a := w.Snapshot(); !a.HeMe { t.Errorf("a six-minute-old report was dropped from a ten-minute window: %+v", a) } // Past it, it goes. w = feed("YI5RLS", rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 11*time.Minute), ) if a := w.Snapshot(); a.HeMe || a.Spots != 0 { t.Errorf("an eleven-minute-old report survived: %+v", a) } } func TestSuggestOffsetAvoidsTheOccupiedBinsAndTheCeiling(t *testing.T) { // Busy from 1000 to 1500 Hz, empty from 1560 to 2400, ceiling 2400. bins := []Bin{} for hz := 1020; hz <= 1500; hz += binHz { bins = append(bins, Bin{OffsetHz: hz, Count: 3}) } bins = append(bins, Bin{OffsetHz: 2400, Count: 1}) got := suggestOffset(bins, 2400) if got < 1620 || got > 2340 { t.Errorf("suggested %d Hz, want somewhere in the empty 1560-2400 run", got) } // A passband with no gap at all still gets an answer — the quietest slot, // which is what an operator would look for by eye. What it must never do is // advise ABOVE the ceiling: transmitting past the DX's filter is the one // outcome worse than picking a busy slot. if got := suggestOffset(bins, 1500); got <= 1000 || got > 1500 { t.Errorf("suggested %d Hz with a full 1500 Hz passband, want a slot inside it", got) } if got := suggestOffset(nil, 0); got != 0 { t.Errorf("suggested %d Hz with no data at all, want none", got) } } func TestTopicsFollowTheScope(t *testing.T) { w := New(Config{MyCall: "F4BPO", Scope: ScopeTarget}) w.target, w.mode, w.band = "YI5RLS", "FT8", "20m" got := w.topicsLocked() want := []string{ "pskr/filter/v2/+/FT8/YI5RLS/#", "pskr/filter/v2/+/FT8/+/YI5RLS/#", "pskr/filter/v2/+/FT8/F4BPO/#", } if len(got) != len(want) { t.Fatalf("narrow scope = %v, want %v", got, want) } for i := range want { if got[i] != want[i] { t.Errorf("filter %d = %q, want %q", i, got[i], want[i]) } } w.cfg.Scope = ScopeBand if got := w.topicsLocked(); len(got) != 1 || got[0] != "pskr/filter/v2/20m/FT8/#" { t.Errorf("band scope = %v, want the one band-wide filter", got) } } // The case reported from the air: a busy DX, 103 decodes across a 2805 Hz // passband, and the panel drew the whole histogram with no advice under it. func TestACrowdedPassbandStillGetsAnAnswer(t *testing.T) { // Every bin from 1020 to 2800 occupied — no clear run anywhere, and the // quietest slot is the answer. bins := []Bin{} for hz := 1020; hz <= 2760; hz += binHz { n := 5 if hz == 2400 { // one slot noticeably quieter than the rest n = 1 } bins = append(bins, Bin{OffsetHz: hz, Count: n}) } got := suggestOffset(bins, 2805) if got == 0 { t.Fatal("no advice on a full passband — this is exactly when it is wanted") } if got < 2400 || got > 2460 { t.Errorf("suggested %d Hz, want the quietest slot around 2400", got) } }