463 lines
17 KiB
Go
463 lines
17 KiB
Go
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)
|
|
}
|
|
}
|