package autocall import ( "fmt" "testing" "time" ) const me = "F4BPO" // base is a slot boundary, so slot parity in the tests is the real arithmetic. var base = time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC) func at(period int) time.Time { return base.Add(time.Duration(period) * 15 * time.Second) } func cq(call string, need Need, snr int, opts ...func(*Candidate)) Candidate { c := Candidate{ Decode: Decode{Call: call, Band: "20m", Mode: "FT8", SNR: snr, CQ: true, Msg: "CQ " + call + " JN36", TRPeriod: 15, IsNew: true}, Need: need, } for _, o := range opts { o(&c) } return c } func watched(c *Candidate) { c.Watched = true } func worked(c *Candidate) { c.Worked = true } // busy is a station in the middle of an exchange with somebody else. func busy(call string, need Need, snr int) Candidate { c := cq(call, need, snr) c.CQ = false c.Msg = "VP6D " + call + " JN36" return c } // callsMe is a station answering us. func callsMe(call string, need Need, snr int) Candidate { c := cq(call, need, snr) c.CQ = false c.Msg = me + " " + call + " -12" return c } func period(n int, decodes ...Candidate) Period { for i := range decodes { decodes[i].At = at(n) } return Period{Key: fmt.Sprintf("p%d", n), At: at(n), TRPeriod: 15, Decodes: decodes, MyCall: me} } func on() *Engine { return New(Settings{Enabled: true}) } // ── The ladder ──────────────────────────────────────────────────────────── func TestLadderOrder(t *testing.T) { // Every rung, in the order the operator asked for. order := []Candidate{ cq("A", NeedDXCC, 0, watched), cq("B", NeedDXCC, 0), cq("C", NeedBand, 0, watched), cq("D", NeedBand, 0), cq("E", NeedMode, 0, watched), cq("F", NeedMode, 0), cq("G", NeedSlot, 0, watched), cq("H", NeedSlot, 0), cq("I", NeedNone, 0, watched), } for i := 1; i < len(order); i++ { if rank(order[i-1]) <= rank(order[i]) { t.Errorf("%s (%d) does not outrank %s (%d)", order[i-1].Call, rank(order[i-1]), order[i].Call, rank(order[i])) } } // A station with nothing needed and not watched is not called at all. if rank(cq("Z", NeedNone, 0)) != 0 { t.Error("a station with nothing to gain from it ranks above zero") } // And the pick agrees with the ladder, whatever order the period lists them. e := on() a := e.OnPeriod(period(0, order[7], order[3], order[0], order[5])) if a.Kind != DoReply || a.Decode.Call != "A" { t.Fatalf("picked %+v, want the watched new entity", a) } } func TestStrongestWinsBetweenEquals(t *testing.T) { e := on() a := e.OnPeriod(period(0, cq("WEAK", NeedBand, -20), cq("LOUD", NeedBand, -5))) if a.Decode.Call != "LOUD" { t.Errorf("picked %q, want the strongest of two equal needs", a.Decode.Call) } } // ── The busy station ────────────────────────────────────────────────────── func TestBusyStationIsNeverCalled(t *testing.T) { e := on() // The new entity is answering a DXpedition; the new band is calling CQ. a := e.OnPeriod(period(0, busy("RARE", NeedDXCC, -3), cq("DL1XX", NeedBand, -15))) if a.Kind != DoReply || a.Decode.Call != "DL1XX" { t.Fatalf("called %+v — a station in mid-QSO cannot answer and must not be called", a) } // It is not banned: the moment it calls CQ it takes the slot back, once the // QSO in hand is over. e2 := on() if a := e2.OnPeriod(period(0, cq("RARE", NeedDXCC, -3))); a.Decode.Call != "RARE" { t.Errorf("the same station calling CQ was not called: %+v", a) } } func TestFinalFrameIsCallable(t *testing.T) { e := on() c := busy("RARE", NeedDXCC, -3) c.Msg = "IK2AAA RARE RR73" // one frame from being free if a := e.OnPeriod(period(0, c)); a.Kind != DoReply { t.Errorf("a station sending its last frame is free next period: %+v", a) } } // ── The brakes ──────────────────────────────────────────────────────────── func TestAttemptsCapAtSevenAndFifteen(t *testing.T) { for _, tc := range []struct { name string opt func(*Candidate) want int }{{"plain", func(*Candidate) {}, 7}, {"watched", watched, 15}} { e := on() e.OnPeriod(period(0, cq("DX", NeedDXCC, -5, tc.opt))) tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"} for i := 1; i < tc.want; i++ { if a := e.NoteTX(tx); a.Kind != DoNothing { t.Fatalf("%s: gave up at call %d of %d", tc.name, i, tc.want) } } a := e.NoteTX(tx) if a.Kind != DoHalt { t.Errorf("%s: still calling after %d attempts", tc.name, tc.want) } if e.Target() != "" { t.Errorf("%s: target still held after giving up", tc.name) } } } func TestOnlyFreshCallsCount(t *testing.T) { e := on() e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) // The rest of the exchange is not a call: without this the seven were spent // on one QSO in progress. for _, msg := range []string{"DX " + me + " -12", "DX " + me + " R-12", "DX " + me + " RR73"} { if a := e.NoteTX(TXState{Transmitting: true, Msg: msg}); a.Kind != DoNothing { t.Fatalf("%q ended the series", msg) } } if e.Status().Attempts != 0 { t.Errorf("attempts = %d after three QSO frames, want 0", e.Status().Attempts) } // A transmission aimed at somebody else counts for nothing either. e.NoteTX(TXState{Transmitting: true, Msg: "OTHER " + me + " JN36"}) if e.Status().Attempts != 0 { t.Errorf("a call to another station was counted against the target") } } func TestMissesOnlyCountTheStationsOwnPeriods(t *testing.T) { e := on() e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) // learns nothing yet e.OnPeriod(period(2, cq("DX", NeedDXCC, -5))) // seen: it transmits on even periods // Its listening periods say nothing about it. Ten of them must not add up // to a give-up. for _, p := range []int{3, 5, 7, 9, 11} { if a := e.OnPeriod(period(p)); a.Kind != DoNothing { t.Fatalf("gave up during the station's own listening period %d", p) } } if e.Status().Misses != 0 { t.Errorf("misses = %d over five listening periods, want 0", e.Status().Misses) } // Absent from two of its transmit periods: still holding. e.OnPeriod(period(4)) e.OnPeriod(period(6)) if e.Target() == "" { t.Fatal("gave up after two misses, the limit is three") } if a := e.OnPeriod(period(8)); a.Kind != DoHalt { t.Errorf("still holding after three missed transmit periods: %+v", a) } } func TestOneMissPerPeriodEvenIfTheHandlerRunsTwice(t *testing.T) { e := on() e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) e.OnPeriod(period(2, cq("DX", NeedDXCC, -5))) p := period(4) e.OnPeriod(p) e.OnPeriod(p) e.OnPeriod(p) if e.Status().Misses != 1 { t.Errorf("misses = %d after one period handled three times, want 1", e.Status().Misses) } } func TestClockBackstopReleasesAStuckTarget(t *testing.T) { e := on() e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) // Decoded every one of its periods and never answering, with the decoder // never reporting a transmission: no counter can advance. for p := 2; p <= 16; p += 2 { e.OnPeriod(period(p, cq("DX", NeedDXCC, -5))) } if a := e.OnPeriod(period(18, cq("DX", NeedDXCC, -5))); a.Kind != DoHalt { t.Errorf("a target held past MaxHold with no counter moving was never released: %+v", a) } } // ── After a series ──────────────────────────────────────────────────────── func TestAReleasedStationYieldsToAnythingBetter(t *testing.T) { e := on() e.OnPeriod(period(0, cq("DX", NeedBand, -5))) tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"} for i := 0; i < 7; i++ { e.NoteTX(tx) } if e.Target() != "" { t.Fatal("still holding after seven calls") } // It is still there, and so is a new entity: the entity takes the slot. a := e.OnPeriod(period(2, cq("DX", NeedBand, -5), cq("RARE", NeedDXCC, -20))) if a.Decode.Call != "RARE" { t.Errorf("picked %q, want the higher priority over the station just released", a.Decode.Call) } } func TestAReleasedStationIsCalledAgainWhenNothingBetterIsOnTheAir(t *testing.T) { e := on() e.OnPeriod(period(0, cq("DX", NeedBand, -5))) tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"} for i := 0; i < 7; i++ { e.NoteTX(tx) } a := e.OnPeriod(period(2, cq("DX", NeedBand, -5))) if a.Kind != DoReply || a.Decode.Call != "DX" { t.Errorf("nothing better on the air and the station was not called again: %+v", a) } } func TestAStationIsParkedAfterItsRounds(t *testing.T) { e := on() tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"} for round := 1; round <= 3; round++ { a := e.OnPeriod(period(round*2, cq("DX", NeedBand, -5))) if a.Kind != DoReply { t.Fatalf("round %d: not called (%+v)", round, a) } for i := 0; i < 7; i++ { e.NoteTX(tx) } } // Three series of seven is twenty-one calls. That is the end of it for this // session — the whole point of the exercise is that it cannot reach fifty. if a := e.OnPeriod(period(20, cq("DX", NeedBand, -5))); a.Kind != DoNothing { t.Errorf("a fourth series was started: %+v", a) } } // ── Handing over ────────────────────────────────────────────────────────── func TestFinishedQSOMovesToTheNextPriority(t *testing.T) { e := on() e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) done := callsMe("DX", NeedDXCC, -5) done.Msg = me + " DX RR73" a := e.OnPeriod(period(2, done, cq("NEXT", NeedBand, -10))) if a.Kind != DoReply || a.Decode.Call != "NEXT" { t.Errorf("after the QSO ended: %+v, want the next priority in the same period", a) } } func TestFinishedQSOWithNoPriorityAnswersWhoeverIsCallingUs(t *testing.T) { e := on() e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) done := callsMe("DX", NeedDXCC, -5) done.Msg = me + " DX RR73" // Two stations calling us, nothing needed from either: the strongest wins. weak := callsMe("WEAK", NeedNone, -18) weak.Watched = true loud := callsMe("LOUD", NeedNone, -4) loud.Watched = true a := e.OnPeriod(period(2, done, weak, loud)) if a.Kind != DoReply || a.Decode.Call != "LOUD" { t.Errorf("answered %+v, want the strongest of the stations calling us", a) } } func TestAlreadyWorkedIsNeverCalled(t *testing.T) { e := on() a := e.OnPeriod(period(0, cq("DX", NeedDXCC, -5, worked))) if a.Kind != DoNothing { t.Errorf("called a station already in the log on this band and mode: %+v", a) } } func TestReplayedHistoryIsNeverCalled(t *testing.T) { e := on() old := cq("DX", NeedDXCC, -5) old.IsNew = false if a := e.OnPeriod(period(0, old)); a.Kind != DoNothing { t.Errorf("answered a replayed decode: %+v", a) } } func TestNoCallStartsOverATransmissionInProgress(t *testing.T) { e := on() p := period(0, cq("DX", NeedDXCC, -5)) p.TX = TXState{Transmitting: true} if a := e.OnPeriod(p); a.Kind != DoNothing { t.Errorf("started a call while the decoder was transmitting: %+v", a) } } // ── The "call this station" field ───────────────────────────────────────── func TestOnlyCallsThatStation(t *testing.T) { e := New(Settings{Enabled: true, Only: "VP6D"}) a := e.OnPeriod(period(0, cq("RARE", NeedDXCC, -5), cq("VP6D", NeedSlot, -20))) if a.Kind != DoReply || a.Decode.Call != "VP6D" { t.Fatalf("picked %+v, want the station named in the field", a) } // Same brakes, then a hard stop: there is nothing else it was asked to do. tx := TXState{Transmitting: true, Msg: "VP6D " + me + " JN36"} for i := 0; i < 7; i++ { e.NoteTX(tx) } if !e.Status().Stopped { t.Error("an explicit target ran out of attempts and the feature did not stop") } if a := e.OnPeriod(period(2, cq("VP6D", NeedSlot, -20))); a.Kind != DoNothing { t.Errorf("kept calling after the stop: %+v", a) } e.Reset() if a := e.OnPeriod(period(4, cq("VP6D", NeedSlot, -20))); a.Kind != DoReply { t.Errorf("the operator restarted it and nothing happened: %+v", a) } } func TestDisabledDoesNothingAtAll(t *testing.T) { e := New(Settings{}) if a := e.OnPeriod(period(0, cq("DX", NeedDXCC, 0))); a.Kind != DoNothing { t.Errorf("switched off and still calling: %+v", a) } if a := e.NoteTX(TXState{Transmitting: true, Msg: "DX " + me + " JN36"}); a.Kind != DoNothing { t.Errorf("switched off and still counting: %+v", a) } } func TestOnlyStopsOnceThatStationIsWorked(t *testing.T) { e := New(Settings{Enabled: true, Only: "VP6D"}) e.OnPeriod(period(0, cq("VP6D", NeedDXCC, -10))) done := callsMe("VP6D", NeedDXCC, -10) done.Msg = me + " VP6D RR73" e.OnPeriod(period(2, done)) // The station is still on the air calling CQ. It has been worked: an // explicit request is for one QSO, not for the whole evening. if a := e.OnPeriod(period(4, cq("VP6D", NeedDXCC, -10))); a.Kind != DoNothing { t.Errorf("called the named station again after working it: %+v", a) } } func TestAStationJustWorkedIsNotCalledBackWhileTheLogCatchesUp(t *testing.T) { e := on() e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) done := callsMe("DX", NeedDXCC, -5) done.Msg = me + " DX RR73" e.OnPeriod(period(2, done)) // Still flagged as a new entity — the QSO is not in the log yet — and still // calling CQ. It must not be answered again. if a := e.OnPeriod(period(4, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing { t.Errorf("called back a station worked two periods ago: %+v", a) } } func TestChaseListTakesSeveralCallsigns(t *testing.T) { // Typed the way an operator types a list: commas, spaces, or both. for _, field := range []string{"VP6D 3Y0J", "vp6d,3y0j", "VP6D, 3Y0J", " VP6D ;3Y0J "} { if got := onlyList(field); len(got) != 2 || got[0] != "VP6D" || got[1] != "3Y0J" { t.Errorf("%q parsed as %v, want [VP6D 3Y0J]", field, got) } } e := New(Settings{Enabled: true, Only: "VP6D, 3Y0J"}) // Nothing off the list is called, however rare it is. if a := e.OnPeriod(period(0, cq("RARE", NeedDXCC, -1))); a.Kind != DoNothing { t.Errorf("called a station that is not on the chase list: %+v", a) } // Between two listed stations the ladder still decides: the new entity over // the new slot, whatever their order in the period. a := e.OnPeriod(period(2, cq("3Y0J", NeedSlot, -1), cq("VP6D", NeedDXCC, -22))) if a.Kind != DoReply || a.Decode.Call != "VP6D" { t.Fatalf("picked %+v, want the new entity of the two listed", a) } // Working one of them leaves the other callable — the list is a hunt, not a // single request. done := callsMe("VP6D", NeedDXCC, -22) done.Msg = me + " VP6D RR73" a = e.OnPeriod(period(4, done, cq("3Y0J", NeedSlot, -1))) if a.Kind != DoReply || a.Decode.Call != "3Y0J" { t.Errorf("after working VP6D: %+v, want the other station on the list", a) } } // ── Two decoders at once (the split view) ───────────────────────────────── func onInst(inst string, p Period) Period { p.Instance = inst for i := range p.Decodes { p.Decodes[i].Instance = inst } return p } func TestTheOtherReceiverCannotBreakTheQSOInProgress(t *testing.T) { e := on() // Calling a new band on receiver A. if a := e.OnPeriod(onInst("A", period(0, cq("DL1XX", NeedBand, -10)))); a.Decode.Call != "DL1XX" { t.Fatalf("first call went to %+v", a) } // Receiver B now hears a new ENTITY — a better catch by every rule. It must // still not be called: one station at a time, and the QSO in hand is the // one already under way. if a := e.OnPeriod(onInst("B", period(1, cq("RARE", NeedDXCC, -1)))); a.Kind != DoNothing { t.Errorf("the other receiver started a second QSO: %+v", a) } // And B's periods count no misses against A's target: the station is not // absent from B, it was never on that band. e.OnPeriod(onInst("B", period(3, cq("RARE", NeedDXCC, -1)))) e.OnPeriod(onInst("B", period(5, cq("RARE", NeedDXCC, -1)))) e.OnPeriod(onInst("B", period(7, cq("RARE", NeedDXCC, -1)))) if e.Status().Misses != 0 || e.Target() != "DL1XX" { t.Errorf("misses = %d, target = %q — the other receiver's periods were counted", e.Status().Misses, e.Target()) } // B transmitting its own QSO is not us calling DL1XX either. for i := 0; i < 9; i++ { e.NoteTX(TXState{Transmitting: true, Instance: "B", Msg: "DL1XX " + me + " JN36"}) } if e.Status().Attempts != 0 { t.Errorf("attempts = %d from the other receiver's transmissions, want 0", e.Status().Attempts) } // A's own transmissions do count. e.NoteTX(TXState{Transmitting: true, Instance: "A", Msg: "DL1XX " + me + " JN36"}) if e.Status().Attempts != 1 { t.Errorf("attempts = %d after one call from the calling receiver, want 1", e.Status().Attempts) } // Once the QSO is over, the other receiver's station is free to be taken. done := callsMe("DL1XX", NeedBand, -10) done.Msg = me + " DL1XX RR73" e.OnPeriod(onInst("A", period(9, done))) if a := e.OnPeriod(onInst("B", period(11, cq("RARE", NeedDXCC, -1)))); a.Decode.Call != "RARE" { t.Errorf("after the QSO ended, the other receiver was still locked out: %+v", a) } }