Take serial numbers from a pool the whole entry shares
Each station worked out its next number as the highest in its log plus one, so two operators typing at the same moment both sent the same number and one of the two contacts was wrong in the sponsor's log. SerialNumberPool holds the highest number taken per band, which is N1MM's QSONumbers array. A number is taken at the first character of the callsign, as N1MM takes it, and ReserveNr tells the other stations before the contact is worked. A number at or below one this station has used is answered with the whole pool, N1MM's QSONRS, and the station that is behind counts on from there. The pool also goes out when a contest is opened and when the numbers are started again. Contest.NumbersByBand says whether the numbers run per band or as one sequence for the station. CQ WPX runs one per band for a multi-two and a multi-multi; every other contest here runs one sequence. The band index in the message is N1MM's, so the array lines up with theirs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NZkFxHde1iifAQemFTHxf
This commit is contained in:
@@ -78,4 +78,23 @@ public class CqWpxTests
|
||||
Assert.Equal(0, verdict.Points);
|
||||
Assert.Single(verdict.NewMultipliers);
|
||||
}
|
||||
|
||||
/// A multi-two and a multi-multi run a serial number sequence per band; a
|
||||
/// single operator and a multi-one run one sequence. N1MM asks its
|
||||
/// contests the same question before it takes a number.
|
||||
[Theory]
|
||||
[InlineData("SINGLE-OP", "ONE", false)]
|
||||
[InlineData("MULTI-ONE", "ONE", false)]
|
||||
[InlineData("MULTI-TWO", "TWO", true)]
|
||||
[InlineData("MULTI-MULTI", "UNLIMITED", true)]
|
||||
[InlineData("MULTI-OP", "TWO", true)]
|
||||
[InlineData("MULTI-OP", "UNLIMITED", true)]
|
||||
public void OnlyAMultiTwoOrMultiMultiNumbersPerBand(string operators, string transmitters, bool byBand) =>
|
||||
Assert.Equal(
|
||||
byBand,
|
||||
new CqWpx(ModeCategory.Cw).NumbersByBand(new ContestEntry
|
||||
{
|
||||
OperatorCategory = operators,
|
||||
TransmitterCategory = transmitters,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -611,4 +611,50 @@ public class StationLinkTests
|
||||
SentNumber = 41,
|
||||
StationName = "RUN-PC",
|
||||
};
|
||||
|
||||
/// The number one station has taken for the contact it is working reaches
|
||||
/// the other, with the band it was taken on.
|
||||
[Fact]
|
||||
public async Task AReservedSerialNumberReachesTheOtherStation()
|
||||
{
|
||||
(int onePort, int twoPort) = SparePorts();
|
||||
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||
one.Start();
|
||||
two.Start();
|
||||
ReservedNumberMessage? taken = null;
|
||||
two.NumberReserved += (_, number) => taken = number;
|
||||
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
|
||||
|
||||
await one.SendReservedNumberAsync(41, Frequency.FromKilohertz(14_025));
|
||||
|
||||
await WaitForAsync(() => taken is not null);
|
||||
Assert.Equal(41, taken!.Number);
|
||||
Assert.Equal(Bands.Band20M, Bands.ForFrequency(taken.Frequency));
|
||||
}
|
||||
|
||||
/// The answer to a number that clashes: the whole pool, one number per
|
||||
/// band, so the station that is behind catches up.
|
||||
[Fact]
|
||||
public async Task TheWholeSerialNumberPoolCanBeSent()
|
||||
{
|
||||
(int onePort, int twoPort) = SparePorts();
|
||||
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||
one.Start();
|
||||
two.Start();
|
||||
IReadOnlyList<int>? numbers = null;
|
||||
two.SerialNumbersArrived += (_, sent) => numbers = sent;
|
||||
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
|
||||
int[] pool = new int[N1mmBandIndex.Slots];
|
||||
pool[N1mmBandIndex.Of(Bands.Band20M)] = 12;
|
||||
|
||||
await one.SendSerialNumbersAsync(pool);
|
||||
|
||||
await WaitForAsync(() => numbers is not null);
|
||||
Assert.Equal(12, numbers![N1mmBandIndex.Of(Bands.Band20M)]);
|
||||
Assert.Equal(N1mmBandIndex.Slots, numbers.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,11 +190,11 @@ public class NetworkedContactsTests
|
||||
(ContestSession session, _) = Station();
|
||||
session.Add(Arriving("JA1XYZ", 1) with { Id = Qso.NewId(), SentNumber = 1 });
|
||||
session.Add(Arriving("JA2XYZ", 2) with { Id = Qso.NewId(), SentNumber = 2 });
|
||||
Assert.Equal(3, session.SentNumber);
|
||||
Assert.Equal(3, session.NextNumberOn(null));
|
||||
|
||||
session.SerialNumbersFrom = DateTime.UtcNow;
|
||||
|
||||
Assert.Equal(1, session.SentNumber);
|
||||
Assert.Equal(1, session.NextNumberOn(null));
|
||||
Assert.Equal([1, 2], session.Log.Qsos.Select(q => q.SentNumber));
|
||||
}
|
||||
|
||||
|
||||
@@ -727,4 +727,64 @@ public class OperatingPositionTests
|
||||
Assert.True(session.StackAnother());
|
||||
Assert.False(session.RestoreWiped());
|
||||
}
|
||||
|
||||
/// N1MM takes the serial number at the first character of the callsign, not
|
||||
/// at logging, so the number is on the network while the contact is still
|
||||
/// being worked. A second position takes the next one.
|
||||
[Fact]
|
||||
public void TypingACallTakesTheSerialNumber()
|
||||
{
|
||||
ContestSession shared = Shared(new FakeLogStore(), new CqWpx(ModeCategory.Cw));
|
||||
OperatingPosition run = new(shared, 1);
|
||||
OperatingPosition mult = new(shared, 2);
|
||||
|
||||
run.Entry.Call = "J";
|
||||
|
||||
Assert.Equal(1, run.SentNumber);
|
||||
Assert.Equal(2, mult.SentNumber);
|
||||
}
|
||||
|
||||
/// Without the pool both positions showed the same number until one of them
|
||||
/// logged. The number the other station takes counts here as well.
|
||||
[Fact]
|
||||
public void ANumberAnotherStationTookIsNotSentHere()
|
||||
{
|
||||
OperatingPosition session = Session(new CqWpx(ModeCategory.Cw));
|
||||
|
||||
session.Session.Numbers.Note(5, Bands.Band20M);
|
||||
|
||||
Assert.Equal(6, session.SentNumber);
|
||||
}
|
||||
|
||||
/// The number is held until the contact is logged, so a wipe keeps it and
|
||||
/// the next contact takes a fresh one.
|
||||
[Fact]
|
||||
public void TheNumberIsHeldUntilTheContactIsLogged()
|
||||
{
|
||||
OperatingPosition session = Session(new CqWpx(ModeCategory.Cw));
|
||||
session.Entry.Call = "JA1XYZ";
|
||||
Assert.Equal(1, session.SentNumber);
|
||||
|
||||
session.Wipe();
|
||||
Assert.Equal(1, session.SentNumber);
|
||||
|
||||
session.Entry.Call = "JA2XYZ";
|
||||
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
|
||||
session.Entry.Set(ExchangeSlot.SerialNumber, "12");
|
||||
Assert.Equal(1, session.LogContact().SentNumber);
|
||||
|
||||
session.Entry.Call = "JA3XYZ";
|
||||
Assert.Equal(2, session.SentNumber);
|
||||
}
|
||||
|
||||
/// A contest without serial numbers has nothing to take.
|
||||
[Fact]
|
||||
public void NothingIsTakenInAContestThatSendsNoNumbers()
|
||||
{
|
||||
OperatingPosition session = Session();
|
||||
|
||||
session.Entry.Call = "JA1XYZ";
|
||||
|
||||
Assert.Equal(1, session.Session.Numbers.Next(Bands.Band20M));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Session.Tests;
|
||||
|
||||
public class SerialNumberPoolTests
|
||||
{
|
||||
private static Qso Worked(Band band, int number) => new()
|
||||
{
|
||||
Id = Qso.NewId(),
|
||||
TimestampUtc = new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc),
|
||||
ContestName = "CQWPX",
|
||||
Call = Callsign.Parse("JA1XYZ"),
|
||||
Frequency = band.Low,
|
||||
Mode = Modes.Cw,
|
||||
SentNumber = number,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void TheFirstNumberIsOne() =>
|
||||
Assert.Equal(1, new SerialNumberPool().Next(Bands.Band20M));
|
||||
|
||||
[Fact]
|
||||
public void TakingANumberMovesTheNextOneOn()
|
||||
{
|
||||
SerialNumberPool pool = new();
|
||||
|
||||
Assert.Equal(1, pool.Take(Bands.Band20M));
|
||||
Assert.Equal(2, pool.Next(Bands.Band20M));
|
||||
}
|
||||
|
||||
/// One sequence for the whole station: a number taken on one band is not
|
||||
/// sent again on another.
|
||||
[Fact]
|
||||
public void OneSequenceCountsAcrossTheBands()
|
||||
{
|
||||
SerialNumberPool pool = new();
|
||||
pool.Take(Bands.Band20M);
|
||||
pool.Take(Bands.Band20M);
|
||||
|
||||
Assert.Equal(3, pool.Next(Bands.Band40M));
|
||||
}
|
||||
|
||||
/// CQ WPX multi-two and multi-multi count per band, so each band starts
|
||||
/// again at one.
|
||||
[Fact]
|
||||
public void ByBandEachBandHasItsOwnSequence()
|
||||
{
|
||||
SerialNumberPool pool = new() { ByBand = true };
|
||||
pool.Take(Bands.Band20M);
|
||||
pool.Take(Bands.Band20M);
|
||||
|
||||
Assert.Equal(1, pool.Next(Bands.Band40M));
|
||||
Assert.Equal(3, pool.Next(Bands.Band20M));
|
||||
}
|
||||
|
||||
/// The point of the pool: the other station says which number it has taken
|
||||
/// before it works the contact, so this one counts past it.
|
||||
[Fact]
|
||||
public void ANumberAnotherStationTookIsNotSentAgain()
|
||||
{
|
||||
SerialNumberPool pool = new();
|
||||
pool.Take(Bands.Band20M);
|
||||
|
||||
Assert.True(pool.Note(2, Bands.Band40M));
|
||||
Assert.Equal(3, pool.Next(Bands.Band20M));
|
||||
}
|
||||
|
||||
/// A station that has fallen behind sends a number this one has used. The
|
||||
/// clash is reported so the caller answers with the whole pool.
|
||||
[Fact]
|
||||
public void ANumberAlreadyUsedIsRefused()
|
||||
{
|
||||
SerialNumberPool pool = new();
|
||||
pool.Take(Bands.Band20M);
|
||||
pool.Take(Bands.Band20M);
|
||||
|
||||
Assert.False(pool.Note(1, Bands.Band40M));
|
||||
}
|
||||
|
||||
/// The same number back from the station it went to is the message going
|
||||
/// round the entry, not two stations on one number.
|
||||
[Fact]
|
||||
public void TheNumberThisStationTookComingBackIsNoClash()
|
||||
{
|
||||
SerialNumberPool pool = new();
|
||||
int mine = pool.Take(Bands.Band20M);
|
||||
|
||||
Assert.True(pool.Note(mine, Bands.Band20M));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByBandAClashIsPerBand()
|
||||
{
|
||||
SerialNumberPool pool = new() { ByBand = true };
|
||||
pool.Take(Bands.Band20M);
|
||||
|
||||
Assert.True(pool.Note(1, Bands.Band40M));
|
||||
Assert.False(pool.Note(1, Bands.Band20M));
|
||||
}
|
||||
|
||||
/// The whole pool from another station: the higher of the two is kept for
|
||||
/// each band, so a station that was away cannot pull the numbers down.
|
||||
[Fact]
|
||||
public void MergingKeepsTheHigherNumberOfEachBand()
|
||||
{
|
||||
SerialNumberPool pool = new() { ByBand = true };
|
||||
pool.Take(Bands.Band20M);
|
||||
IReadOnlyList<int> theirs = new SerialNumberPool { ByBand = true }.Snapshot();
|
||||
|
||||
pool.Merge([.. theirs.Select((_, at) => at == N1mmBandIndex.Of(Bands.Band40M) ? 7 : 0)]);
|
||||
|
||||
Assert.Equal(2, pool.Next(Bands.Band20M));
|
||||
Assert.Equal(8, pool.Next(Bands.Band40M));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheLogSaysWhereTheNumbersHaveGot()
|
||||
{
|
||||
SerialNumberPool pool = new();
|
||||
|
||||
pool.SeedFrom([Worked(Bands.Band20M, 4), Worked(Bands.Band40M, 9)]);
|
||||
|
||||
Assert.Equal(10, pool.Next(Bands.Band20M));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TakingANumberTellsTheOtherStations()
|
||||
{
|
||||
SerialNumberPool pool = new();
|
||||
List<ReservedNumber> taken = [];
|
||||
pool.Reserved += (_, number) => taken.Add(number);
|
||||
|
||||
pool.Take(Bands.Band20M);
|
||||
|
||||
Assert.Equal([new ReservedNumber(1, Bands.Band20M)], taken);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user