package autocall import ( "fmt" "strings" "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. The watched ones come FIRST, ordered among themselves by what // is needed — the list is the operator's answer, not a tie-breaker. order := []Candidate{ cq("A", NeedDXCC, 0, watched), cq("C", NeedBand, 0, watched), cq("E", NeedMode, 0, watched), cq("G", NeedSlot, 0, watched), cq("I", NeedNone, 0, watched), cq("B", NeedDXCC, 0), cq("D", NeedBand, 0), cq("F", NeedMode, 0), cq("H", NeedSlot, 0), // The orthogonal markers sit at the foot of the ladder: worth a call // when nothing better is on the air, never worth leaving a band for. cq("J", NeedExtra, 0), } 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[8], order[5], order[0], order[6])) if a.Kind != DoReply || a.Decode.Call != "A" { t.Fatalf("picked %+v, want the watched new entity", a) } // A watched station with NOTHING needed still beats a new entity that is not // watched — the case that sent an operator hunting: a watched DXpedition sat // on the band all evening while the engine worked what the log wanted. e = on() if a := e.OnPeriod(period(2, cq("RARE", NeedDXCC, 0), cq("WATCHED", NeedNone, -20, watched))); a.Decode.Call != "WATCHED" { t.Errorf("picked %q, want the watched callsign", a.Decode.Call) } } 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 TestAReleasedStationRestsBeforeItIsCalledAgain(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) } // Still the only thing on the air, and still resting: seven calls, a halt, // and the same station called again four seconds later is not a rest. for _, n := range []int{2, 4, 6} { // all inside the two minutes if a := e.OnPeriod(period(n, cq("DX", NeedBand, -5))); a.Kind != DoNothing { t.Fatalf("period %d: called again during the rest: %+v", n, a) } } // Two minutes later (period 8 × 15 s = 2 min past the give-up) it may go // again — the station is still there and nothing better is. if a := e.OnPeriod(period(9, cq("DX", NeedBand, -5))); a.Kind != DoReply { t.Errorf("after the rest: %+v, want the station called again", a) } } func TestTheAttemptsCapLetsTheOverFinish(t *testing.T) { e := on() e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"} var a Action for i := 0; i < 7; i++ { a = e.NoteTX(tx) } if a.Kind != DoHalt { t.Fatalf("no halt after seven calls: %+v", a) } // The cap trips WHILE the seventh call is going out — cutting the carrier // there sends half a call. It asks the decoder to finish the over first. if !a.Soft { t.Error("the attempts cap cut the transmission that counted it") } } func TestAStationIsParkedAfterItsRounds(t *testing.T) { e := on() tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"} // Rounds are spaced past the rest (2 min = 8 periods) or the station would // simply be resting rather than parked. for round := 1; round <= 3; round++ { a := e.OnPeriod(period(round*10, 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(60, 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" // The period the QSO ends in belongs to our own 73 — nothing else is called. if a := e.OnPeriod(period(2, done, cq("NEXT", NeedBand, -10))); a.Kind != DoNothing { t.Fatalf("took the slot our 73 goes out in: %+v", a) } a := e.OnPeriod(period(4, cq("NEXT", NeedBand, -10))) if a.Kind != DoReply || a.Decode.Call != "NEXT" { t.Errorf("next period: %+v, want the next priority", 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 — // on the period after the one our 73 goes out in. weak := callsMe("WEAK", NeedNone, -18) weak.Watched = true loud := callsMe("LOUD", NeedNone, -4) loud.Watched = true e.OnPeriod(period(2, done)) a := e.OnPeriod(period(4, 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" e.OnPeriod(period(4, done, cq("3Y0J", NeedSlot, -1))) a = e.OnPeriod(period(6, 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) } } // The one reported from the air: his RRR arrives, we are sending our 73, and // the engine picked the next station in the same instant. WSJT-X acts on a // reply at once — it dropped the exchange and started calling the new station, // cutting the 73 a second in. func TestNothingIsCalledOverOurOwnTransmission(t *testing.T) { e := on() e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) done := callsMe("DX", NeedDXCC, -5) done.Msg = me + " DX RRR" p := period(2, done, cq("NEXT", NeedBand, -10)) p.TX = TXState{Transmitting: true, Msg: "DX " + me + " 73"} if a := e.OnPeriod(p); a.Kind != DoNothing { t.Fatalf("called %+v while our own over was still going out", a) } // The QSO is still released — only the next call waits. if e.Target() != "" { t.Errorf("target = %q after the QSO finished, want none", e.Target()) } // Next period, carrier down: now it may take the next station. if a := e.OnPeriod(period(4, cq("NEXT", NeedBand, -10))); a.Kind != DoReply || a.Decode.Call != "NEXT" { t.Errorf("once the transmission ended: %+v, want the next station called", a) } } func TestTheHoldClockRestartsWhenTheStationAnswers(t *testing.T) { e := on() e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) // Called for a long time — nearly the backstop — and then he answers. for p := 2; p <= 14; p += 2 { e.OnPeriod(period(p, cq("DX", NeedDXCC, -5))) } e.OnPeriod(period(16, callsMe("DX", NeedDXCC, -5))) // The exchange must not be halted by a backstop that was counting the wait. for _, p := range []int{18, 20} { if a := e.OnPeriod(period(p, callsMe("DX", NeedDXCC, -5))); a.Kind == DoHalt { t.Fatalf("period %d: halted an exchange in progress (%s)", p, a.Reason) } } } func TestARealNeedOutranksAnUnconfirmedOne(t *testing.T) { real := cq("REAL", NeedBand, -20) unconf := cq("UNCONF", NeedBand, -2) unconf.Unconfirmed = true // Same need, and the unconfirmed one is 18 dB louder. The band never worked // is still the catch: the other is a QSL to chase, not a QSO to make. e := on() if a := e.OnPeriod(period(0, unconf, real)); a.Decode.Call != "REAL" { t.Errorf("picked %q, want the band never worked", a.Decode.Call) } // Watched changes that, and is meant to: the list is the operator's answer. unconf.Watched = true e = on() if a := e.OnPeriod(period(0, unconf, real)); a.Decode.Call != "UNCONF" { t.Errorf("picked %q, want the watched station", a.Decode.Call) } unconf.Watched = false // Between two unwatched stations, an unconfirmed BAND still beats a real // SLOT: the band is the bigger prize whatever state it is in. slot := cq("SLOT", NeedSlot, 0) e = on() if a := e.OnPeriod(period(0, slot, unconf)); a.Decode.Call != "UNCONF" { t.Errorf("picked %q, want the unconfirmed band over a real slot", a.Decode.Call) } } // ── The operator's Halt ─────────────────────────────────────────────────── func TestHaltSetsTheStationAsideForTheSession(t *testing.T) { e := on() if a := e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))); a.Decode.Call != "DX" { t.Fatalf("not called: %+v", a) } // The operator stops it mid-call. if got := e.Halt(); got != "DX" { t.Fatalf("Halt returned %q, want the station being called", got) } if e.Target() != "" { t.Error("the target survived a halt") } // Still the loudest new entity on the air, period after period. It is the // operator's verdict, so it is not called again — not next period, not in // ten minutes. for _, n := range []int{2, 4, 40} { if a := e.OnPeriod(period(n, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing { t.Errorf("period %d: called a station the operator had stopped: %+v", n, a) } } // Naming it explicitly does not override the operator either. e.SetSettings(Settings{Enabled: true, Only: "DX"}) if a := e.OnPeriod(period(42, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing { t.Errorf("the chase list overrode a halt: %+v", a) } // Switching auto-call off and on is what starts over. e.Reset() e.SetSettings(Settings{Enabled: true}) if a := e.OnPeriod(period(44, cq("DX", NeedDXCC, -5))); a.Kind != DoReply { t.Errorf("after a restart the station is fair game again: %+v", a) } } func TestHaltWithNothingBeingCalledIsJustAHalt(t *testing.T) { e := on() if got := e.Halt(); got != "" { t.Errorf("Halt returned %q with no target", got) } if e.Greylisted() != 0 { t.Errorf("greylisted %d stations from an idle halt", e.Greylisted()) } } func TestTraceSaysWhyNobodyWasCalled(t *testing.T) { var lines []string e := on() e.SetTrace(func(f string, args ...any) { lines = append(lines, fmt.Sprintf(f, args...)) }) worked := cq("A", NeedDXCC, -5, worked) nothing := cq("B", NeedNone, -5) busyOne := busy("C", NeedBand, -5) e.Halt() // nothing held: no-op, keeps the set empty e.OnPeriod(period(0, worked, nothing, busyOne)) if len(lines) == 0 { t.Fatal("tracing on and nothing was written") } got := lines[len(lines)-1] for _, want := range []string{"decodes=3", "callable=0", "worked=1", "nothing-needed=1", "busy=1"} { if !strings.Contains(got, want) { t.Errorf("trace %q does not say %q", got, want) } } // And when it DOES call, the line names the candidates it ranked. lines = nil e.OnPeriod(period(2, cq("DX", NeedBand, -9, watched))) if len(lines) == 0 || !strings.Contains(lines[0], "DX(watched band,-9 dB,r118)") { t.Errorf("trace %v does not describe the station it called", lines) } } func TestAStationCallingUsIsAnsweredEvenWithNothingToGain(t *testing.T) { e := on() // A QSO has just ended and two stations are calling us. Neither is worth // anything to the log — and both are worth answering: they have heard us, // they are waiting, and the contact is one over away. weak := callsMe("WEAK", NeedNone, -18) loud := callsMe("LOUD", NeedNone, -3) a := e.OnPeriod(period(0, weak, loud)) if a.Kind != DoReply || a.Decode.Call != "LOUD" { t.Fatalf("answered %+v, want the strongest of the stations calling us", a) } // A caller already worked on this band and mode is a duplicate, not a QSO. e = on() done := callsMe("DUPE", NeedNone, -1) done.Worked = true if a := e.OnPeriod(period(2, done)); a.Kind != DoNothing { t.Errorf("answered a station already in the log on this band and mode: %+v", a) } // And one the operator halted stays halted, however politely it calls. e = on() e.OnPeriod(period(4, cq("STOP", NeedDXCC, -5))) e.Halt() if a := e.OnPeriod(period(6, callsMe("STOP", NeedDXCC, -5))); a.Kind != DoNothing { t.Errorf("answered a station the operator had stopped: %+v", a) } // A needed station still comes first: a caller with nothing to gain is // answered when nothing better is on the air, which is what was asked for. e = on() if a := e.OnPeriod(period(8, callsMe("CALLER", NeedNone, 0), cq("RARE", NeedDXCC, -20))); a.Decode.Call != "RARE" { t.Errorf("picked %q, want the new entity ahead of a caller with nothing needed", a.Decode.Call) } } // TestAStationWorkingSomebodyElseIsNotCHOSEN — the test above is about a target // already being called; this is about picking one. A reply to a decode in // mid-exchange is a reply WSJT-X and JTDX may refuse outright, so it never // starts a call there. func TestAStationWorkingSomebodyElseIsNotCHOSEN(t *testing.T) { e := on() busyNow := busy("ON7GB", NeedSlot, +5) busyNow.Msg = "PY2SAD ON7GB JO21" if a := e.OnPeriod(period(0, busyNow)); a.Kind == DoReply { t.Fatalf("called %+v — it is in a QSO with PY2SAD", a) } if e.Target() != "" { t.Errorf("target is %q", e.Target()) } // Its final frame is different: one period from free is the best moment // there is to be calling it. last := busy("ON7GB", NeedSlot, +5) last.Msg = "PY2SAD ON7GB RR73" if a := e.OnPeriod(period(2, last)); a.Kind != DoReply { t.Errorf("%+v — a station on its last frame was not called", a) } } func TestAFinalFrameToSomebodyElseIsNotABusyTarget(t *testing.T) { e := on() e.OnPeriod(period(0, cq("DX", NeedSlot, 0))) // Sending RR73 to another station: one period from being free, and the best // moment there is to be calling it. wrap := busy("DX", NeedSlot, 0) wrap.Msg = "PY2SAD DX RR73" if a := e.OnPeriod(period(2, wrap)); a.Kind != DoNothing || e.Target() != "DX" { t.Errorf("dropped a station that was finishing: %+v (target %q)", a, e.Target()) } } func TestNothingHiddenByThePanelIsCalled(t *testing.T) { e := New(Settings{Enabled: true, OnScreenOnly: true}) // The operator has filtered the list down to CQs: what is not on the screen // is not called, whatever the log makes of it. hidden := cq("RARE", NeedDXCC, 0) hidden.Hidden = true shown := cq("PLAIN", NeedSlot, -20) if a := e.OnPeriod(period(0, hidden, shown)); a.Decode.Call != "PLAIN" { t.Errorf("picked %q, want the station the panel is showing", a.Decode.Call) } // With the panel publishing nothing, nothing is hidden and the ladder // decides alone. e = New(Settings{Enabled: true, OnScreenOnly: true}) free := cq("RARE", NeedDXCC, 0) if a := e.OnPeriod(period(2, free, shown)); a.Decode.Call != "RARE" { t.Errorf("picked %q with no filtering in force, want the new entity", a.Decode.Call) } } // MSHV answers two stations in one transmission and the decoder prints them as // one line. Reported from the air: the DX was answering us in the second half // and the engine, reading only the first, dropped it as busy. func TestAMultiAnswerLineIsReadWhole(t *testing.T) { fox := cq("HK0/PY8WW", NeedDXCC, -17, watched) fox.CQ = false fox.Msg = "YV5ALI RR73; " + me + " -08" if !callingUs(fox, me) { t.Error("the second half is a report to us and was not read as one") } if !callable(fox, me) { t.Error("a station answering us was judged uncallable") } // Held as the target, that line must not release it — it is the answer we // were waiting for, not a station working somebody else. e := on() e.OnPeriod(period(0, cq("HK0/PY8WW", NeedDXCC, -17, watched))) if a := e.OnPeriod(period(2, fox)); a.Kind != DoNothing || e.Target() != "HK0/PY8WW" { t.Errorf("dropped the DX as it answered us: %+v (target %q)", a, e.Target()) } // The exchange is under way, so the attempt counter has done its job. if e.Status().Attempts != 0 { t.Errorf("attempts = %d while in QSO, want 0", e.Status().Attempts) } // And its final frame to us, in the same shape, ends the QSO. done := fox done.Msg = "IK2ABC RR73; " + me + " RR73" if !finished([]Candidate{done}, "HK0/PY8WW", me) { t.Error("a 73 to us inside a multi-answer line did not end the QSO") } } // Reported from the air twice: the DX sends RR73, our own 73 is about to go // out, and the engine answered somebody else in that very slot — two // transmissions in one period, and the station at the other end never got the // frame that closes the contact. func TestTheSlotAfterAQSOBelongsToOur73(t *testing.T) { e := on() e.OnPeriod(period(0, cq("HP/WE9G", NeedDXCC, -6))) bye := callsMe("HP/WE9G", NeedDXCC, -6) bye.Msg = "<" + me + "> HP/WE9G RR73" // A new band is decoding in the same period and is not called. a := e.OnPeriod(period(2, bye, cq("GW8DX", NeedBand, +2))) if a.Kind != DoNothing { t.Fatalf("called %+v in the slot our 73 goes out in", a) } if !strings.Contains(a.Reason, "73") { t.Errorf("reason %q does not say why the slot was left alone", a.Reason) } // It is still there fifteen seconds later, which is the whole cost. if a := e.OnPeriod(period(4, cq("GW8DX", NeedBand, +2))); a.Kind != DoReply || a.Decode.Call != "GW8DX" { t.Errorf("next period: %+v, want the new band called", a) } } func TestABetterStationTakesOverBeforeAnybodyHasAnswered(t *testing.T) { e := on() e.OnPeriod(period(0, cq("PLAIN", NeedSlot, +10))) // A watched station comes on the air: nothing has answered us yet, so the // call in progress is worth less than the one now possible. a := e.OnPeriod(period(2, cq("PLAIN", NeedSlot, +10), cq("WATCHED", NeedNone, -15, watched))) if a.Kind != DoReply || a.Decode.Call != "WATCHED" { t.Fatalf("%+v — a better station did not take over", a) } // Equal rungs do NOT take over while the station being called is on the air. e = on() e.OnPeriod(period(4, cq("A", NeedBand, 0))) if a := e.OnPeriod(period(6, cq("A", NeedBand, 0), cq("B", NeedBand, +20))); a.Kind != DoNothing { t.Errorf("%+v — swapped between two stations of equal value", a) } // But they do when it is absent: seven calls into the void while a station // of the same value is calling CQ is what the operator was watching. if a := e.OnPeriod(period(8, cq("B", NeedBand, +20))); a.Kind != DoReply || a.Decode.Call != "B" { t.Errorf("%+v — kept calling a station that was not on the air", a) } // Once the station has answered, nothing takes its place. e = on() e.OnPeriod(period(10, cq("DX", NeedSlot, 0))) e.OnPeriod(period(12, callsMe("DX", NeedSlot, 0))) if a := e.OnPeriod(period(14, callsMe("DX", NeedSlot, 0), cq("RARE", NeedDXCC, 0, watched))); a.Kind != DoNothing { t.Errorf("%+v — abandoned an exchange in progress", a) } } // TestAnExchangeInProgressIsNeverAbandoned is the shack report: mid-QSO with // V31MA — its report decoded, our RR73 going out — and a station of a higher // rung called us from the other side of the screen. The engine switched. func TestAnExchangeInProgressIsNeverAbandoned(t *testing.T) { e := on() // V31MA is worth nothing on the ladder: worked before, nothing needed. if a := e.OnPeriod(period(0, callsMe("V31MA", NeedNone, -12))); a.Kind != DoReply { t.Fatalf("%+v — a station calling us was not answered", a) } // Its report arrives in the same period a better station calls us. That // period used to be judged before the reply was taken into account. a := e.OnPeriod(period(2, callsMe("V31MA", NeedNone, -12), callsMe("F5NNN", NeedSlot, -10))) if a.Kind != DoNothing { t.Fatalf("%+v — left V31MA mid-exchange", a) } if e.target == nil || e.target.Call != "V31MA" { t.Fatalf("target is %v — the QSO in progress lost its place", e.target) } // Our own transmission settles it too: a report is not an opening call, so // even a QSO the operator started by hand is protected. e = on() e.OnPeriod(period(4, cq("V31MA", NeedNone, -12, watched))) e.NoteTX(TXState{Transmitting: true, Msg: "V31MA F4BPO RR73"}) if !e.answered { t.Error("sending a report did not count as being inside the exchange") } if a := e.OnPeriod(period(6, cq("V31MA", NeedNone, -12, watched), callsMe("F5NNN", NeedDXCC, -10))); a.Kind != DoNothing { t.Errorf("%+v — abandoned a QSO we were in the middle of", a) } if e.attempts != 0 { t.Errorf("attempts=%d — an exchange frame was counted as a call", e.attempts) } } // TestAPileupIsWorkedByCallingThroughIt is the shack report: D44TWO finishing a // contact, the call started, the DX coming back to somebody else — and the // engine giving up on a station that was one period from being free. func TestAPileupIsWorkedByCallingThroughIt(t *testing.T) { e := on() if a := e.OnPeriod(period(0, cq("D44TWO", NeedDXCC, -7))); a.Kind != DoReply { t.Fatalf("%+v", a) } // It answers three other callers in a row — which is what a DX with a queue // does, and calling through it is how the queue is joined. does := func(n int) Action { return e.OnPeriod(period(n, busy("D44TWO", NeedDXCC, -7))) } for _, n := range []int{2, 4, 6} { if a := does(n); a.Kind != DoNothing { t.Fatalf("%+v — stopped calling a station working the pileup", a) } if e.Target() != "D44TWO" { t.Fatalf("target is %q — the station was released while it worked others", e.Target()) } } // And when it comes to us, the exchange starts as usual. if a := e.OnPeriod(period(8, callsMe("D44TWO", NeedDXCC, -7))); a.Kind != DoNothing { t.Errorf("%+v", a) } if !e.answered { t.Error("the reply was not taken as the start of the exchange") } // A BETTER station may still take the slot while it is busy: nothing has // been answered, so nothing is lost by moving. e = on() e.OnPeriod(period(10, cq("D44TWO", NeedSlot, -7))) if a := e.OnPeriod(period(12, busy("D44TWO", NeedSlot, -7), cq("RARE", NeedDXCC, -20, watched))); a.Kind != DoReply || a.Decode.Call != "RARE" { t.Errorf("%+v — a watched new one did not take the slot from a busy station", a) } } // TestTheFirstCallStartsWithNoMisses is the shack report: a CQ answered for the // first time, and the toolbar already reading two misses out of three. func TestTheFirstCallStartsWithNoMisses(t *testing.T) { e := on() // It calls CQ on an odd slot; we answer. if a := e.OnPeriod(period(1, cq("ER1CW", NeedBand, -2))); a.Kind != DoReply { t.Fatalf("%+v", a) } // The next period is OURS: we are transmitting to it, and of course it is // not decoded. That is not a miss. pp := period(2) pp.TX = TXState{Transmitting: true} e.OnPeriod(pp) if e.misses != 0 { t.Fatalf("misses=%d after our own transmit period", e.misses) } // Nor is a period of the wrong parity with nothing in it. e.OnPeriod(period(4)) if e.misses != 0 { t.Fatalf("misses=%d — counted a period the station never transmits in", e.misses) } // ITS period, and it is not there: that is a miss. e.OnPeriod(period(3)) if e.misses != 1 { t.Fatalf("misses=%d — the station's own silent period was not counted", e.misses) } }