Files
OpsLog/internal/autocall/autocall_test.go
T
rouggy 8b59954ce0 fix: a watched callsign is never parked; refuse uploads to unconfigured services
ZD8GB — watch-listed, six streams a period, two of them RR73 — was
refused as "parked" for the rest of the session. Parking answers "it
will not answer, stop wasting the evening on it", which is a fair verdict
about a station the LOG picked out and the wrong one about a station the
OPERATOR named: a DXpedition running a pileup takes more than two series
of calls to get through to, which is exactly why it is on the list. The
rest between series still applies, so it cannot monopolise the
transmitter — it simply never becomes ineligible.

Send to (right-click) now refuses a service with no credentials and says
which are missing. The upload runs on its own goroutine and reports into
the QSL Manager's console, which is not open when the command came from
the QSO list, so an upload to an unconfigured service looked exactly like
one that worked. The toast is also raised only once the backend has
accepted the request, and Cloudlog / Wavelog and HamQTH name themselves
in it.
2026-09-06 13:45:35 +02:00

949 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 + " <HK0/PY8WW> -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 + " <HK0/PY8WW> 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)
}
}
// Parking answers "it will not answer, stop wasting the evening on it" — which
// is a verdict about a station the LOG picked out, not about one the OPERATOR
// did. From the air: a watched ZD8GB, six streams a period, refused for the
// rest of the session after two series of unanswered calls.
func TestAWatchedCallsignIsNeverParked(t *testing.T) {
spent := func(c Candidate) *Engine {
e := New(Settings{Enabled: true, MaxRounds: 2, Rest: 0})
for i := 0; i < 2; i++ {
e.giveUp(strings.ToUpper(c.Call), c) // a series ended by a brake
}
return e
}
plain := cq("PLAIN", NeedDXCC, 0)
if a := spent(plain).OnPeriod(period(0, plain)); a.Kind == DoReply {
t.Errorf("%+v — a station whose series are spent was called again", a)
}
dx := cq("ZD8GB", NeedDXCC, 0, watched)
if a := spent(dx).OnPeriod(period(0, dx)); a.Kind != DoReply {
t.Errorf("%+v — a watched DXpedition was parked for the session", a)
}
}