Files
Nonemm/tests/Nonemm.Network.Tests/StationLinkTests.cs
T
erikbrockoandClaude Opus 5 7ba0b44fc1 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
2026-09-10 11:56:49 +00:00

661 lines
28 KiB
C#

using System.Net;
using System.Net.Sockets;
using Nonemm.Core;
namespace Nonemm.Network.Tests;
/// Two links talking to each other over loopback. The stations are named by
/// hand rather than found by a beacon: a broadcast test would depend on the
/// machine's interfaces, and what is being tested here is what happens after
/// two stations have found each other.
public class StationLinkTests
{
private const string Version = "1.0.11364";
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(10);
[Fact]
public async Task AContactReachesTheOtherStation()
{
(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();
ContactUpdate? arrived = null;
two.UpdateArrived += (_, update) => arrived = update;
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
await one.SendLoggedAsync(Contact());
await WaitForAsync(() => arrived is not null);
ContactLogged logged = Assert.IsType<ContactLogged>(arrived);
Assert.Equal("G3XYZ", logged.Qso.Call.Text);
Assert.Equal("RUN-PC", logged.Qso.StationName);
Assert.Equal(1, logged.Qso.NetworkedComputerNumber);
}
/// The first message on a new connection says which station of the entry
/// the sender is, so the other end can show its number.
[Fact]
public async Task ConnectingSaysWhichStationNumberThisIs()
{
(int onePort, int twoPort) = SparePorts();
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
using StationLink two = new("MULT-PC", Version, stationNumber: 7, port: twoPort);
one.Start();
two.Start();
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() =>
two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.StationNumber == 1);
NetworkedStation? seen = two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC");
Assert.Equal(1, seen?.StationNumber);
Assert.Equal("IAM", seen?.LastMessage);
}
/// The band a station is on is what the status window shows and what the
/// band-change rule counts.
/// N1MM carries the frequency in hundredths of a kilohertz, in its own
/// `FREQ` message. A tenth of a hertz survives the trip.
[Fact]
public async Task WhereAStationIsTunedReachesTheOthers()
{
(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();
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
await one.SendFrequencyAsync(Frequency.FromKilohertz(21_025.25));
await WaitForAsync(() =>
two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.Band?.Name == "15M");
NetworkedStation? seen = two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC");
Assert.Equal("15M", seen?.Band?.Name);
Assert.Equal(21_025.25, seen?.Frequency.Kilohertz ?? 0, 2);
}
/// What a station is sending is what the other operators read in the `Msg`
/// column, and N1MM carries it in the transmit message.
[Fact]
public async Task WhatAStationIsSendingReachesTheOthers()
{
(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();
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
await one.SendTransmittingAsync(true, "F1 CQ", isRun2: true);
await WaitForAsync(() =>
two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.IsTransmitting == true);
NetworkedStation? seen = two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC");
Assert.Equal("F1 CQ", seen?.Message);
Assert.True(seen?.IsRun2);
await one.SendTransmittingAsync(false);
await WaitForAsync(() =>
two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.IsTransmitting == false);
Assert.Equal("", two.Stations.First(s => s.ComputerName == "RUN-PC").Message);
}
/// A pass frequency is the frequency and nothing else, in hundredths of a
/// kilohertz.
[Fact]
public async Task APassFrequencyReachesTheOthers()
{
(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();
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
await one.SendPassAsync(Frequency.FromKilohertz(14_205.5));
await WaitForAsync(() =>
two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.PassFrequency.Hertz > 0);
Assert.Equal(
14_205.5,
two.Stations.First(s => s.ComputerName == "RUN-PC").PassFrequency.Kilohertz,
1);
}
/// A station that reconnects is told when the other one last worked
/// somebody, and asks for what it is missing without anybody pressing a
/// button.
[Fact]
public async Task AStationThatIsBehindResynchronisesOnItsOwn()
{
(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);
DateTime hour = DateTime.UtcNow.AddMinutes(-30);
Qso first = Contact() with { TimestampUtc = hour, Id = "first" };
Qso later = Contact() with
{
TimestampUtc = hour.AddMinutes(10),
Call = Callsign.Parse("DL1ABC"),
Id = "later",
};
one.Log = new FakeLog([first, later]);
// MULT-PC has the first contact and missed the one after it
FakeLog multLog = new([first]);
two.Log = multLog;
two.UpdateArrived += (_, update) => multLog.Take(update);
one.Start();
two.Start();
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
two.AddStation("RUN-PC", "127.0.0.1", onePort);
await WaitForAsync(() => multLog.All.Count == 2);
Assert.Equal(["DL1ABC", "G3XYZ"], multLog.All.Select(q => q.Call.Text).Order());
}
/// The status message carries what the network status window shows: the
/// operator, the rates, the pass frequency and which radio is running.
[Fact]
public async Task AStationSaysHowItIsDoing()
{
(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();
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
await one.SendStatusAsync(new StationStatus(
Frequency.FromKilohertz(14_205.5),
Frequency.FromKilohertz(21_025.1),
IsRunning: true,
"OM3RM",
Modes.Rtty,
Last10: 120,
Last100: 84,
"MULTI-ONE",
"TWO",
IsRun2: true));
await WaitForAsync(() =>
two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.Last10 == 120);
NetworkedStation? seen = two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC");
Assert.Equal(120, seen?.Last10);
Assert.Equal(84, seen?.Last100);
Assert.Equal("OM3RM", seen?.Operator);
Assert.Equal("RTTY", seen?.Mode?.Name);
Assert.Equal(21_025.1, seen?.Frequency.Kilohertz ?? 0, 1);
Assert.Equal(14_205.5, seen?.PassFrequency.Kilohertz ?? 0, 1);
Assert.True(seen?.IsRunning);
Assert.True(seen?.IsRun2);
}
/// A connection the operator closed stays closed: a beacon from that
/// station would otherwise open it again within ten seconds.
[Fact]
public async Task AClosedConnectionStaysClosedUntilItIsOpenedAgain()
{
(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();
NetworkedStation station = one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => station.IsConnected);
one.Close(station);
Assert.False(station.IsConnected);
Assert.Equal(0, await one.SendTalkAsync("anybody there"));
one.Open(station);
await WaitForAsync(() => station.IsConnected);
Assert.True(station.IsConnected);
}
[Fact]
public async Task ChatReachesTheOtherOperator()
{
(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();
string? said = null;
two.TalkArrived += (_, text) => said = text;
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
await one.SendTalkAsync("qsy 20");
await WaitForAsync(() => said is not null);
Assert.Equal("[RUN-PC] qsy 20", said);
}
/// An echo request is answered without anybody asking, which is how N1MM
/// measures the round trip to each station.
[Fact]
public async Task AnEchoRequestIsAnswered()
{
(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();
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
two.AddStation("RUN-PC", "127.0.0.1", onePort);
await WaitForAsync(() =>
one.Stations.Any(s => s is { IsMine: false, IsConnected: true })
&& two.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
await one.SendEchoRequestAsync();
await WaitForAsync(() =>
one.Stations.FirstOrDefault(s => s.ComputerName == "MULT-PC")?.EchoTime is not null);
Assert.NotNull(one.Stations.FirstOrDefault(s => s.ComputerName == "MULT-PC")?.EchoTime);
}
/// This computer is in its own list, the way N1MM shows it, so an operator
/// can read its station number and version off the same window.
[Fact]
public void ThisComputerIsInItsOwnStationList()
{
using StationLink link = new("RUN-PC", Version, stationNumber: 3, port: SparePorts().One);
NetworkedStation mine = Assert.Single(link.Stations);
Assert.True(mine.IsMine);
Assert.Equal("RUN-PC", mine.ComputerName);
Assert.Equal(3, mine.StationNumber);
Assert.Equal(Version, mine.Version);
}
/// A message that reached nobody says so, rather than looking as though it
/// went out.
[Fact]
public async Task SendingWithNoStationsConnectedReachesNobody()
{
using StationLink link = new("RUN-PC", Version, port: SparePorts().One);
link.Start();
Assert.Equal(0, await link.SendLoggedAsync(Contact()));
}
/// A station that missed an hour of the contest asks for it and gets it.
/// The other station is the authority on the contacts it made: it tells the
/// asker to drop what it holds for that hour and sends the hour again.
[Fact]
public async Task AResyncFillsInWhatAStationMissed()
{
(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);
DateTime hour = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
FakeLog runLog = new([Contact() with { TimestampUtc = hour }, Contact() with { TimestampUtc = hour.AddMinutes(20), Call = Callsign.Parse("DL1ABC"), Id = Qso.NewId() }]);
FakeLog multLog = new([]);
one.Log = runLog;
two.Log = multLog;
two.UpdateArrived += (_, update) => multLog.Take(update);
one.Start();
two.Start();
// each station opens its own connection, which is N1MM's arrangement:
// an answer goes back over the one this end opened
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
two.AddStation("RUN-PC", "127.0.0.1", onePort);
await WaitForAsync(() =>
one.Stations.Any(s => s is { IsMine: false, IsConnected: true })
&& two.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
NetworkedStation runStation = two.Stations.First(s => s.ComputerName == "RUN-PC");
two.StartResync(runStation, hour, hour.AddHours(1));
await WaitForAsync(() => !runStation.IsResyncing && multLog.All.Count == 2);
Assert.Equal(["DL1ABC", "G3XYZ"], multLog.All.Select(q => q.Call.Text).Order());
Assert.Equal("Resync complete", runStation.ResyncStatus);
Assert.True(runStation.HoursOk > 0, "the hour that was sent again should now agree");
}
/// A station whose log already agrees is told nothing but the answer.
[Fact]
public async Task AResyncOfAnHourThatAgreesSendsNoContacts()
{
(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);
DateTime hour = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
Qso shared = Contact() with { TimestampUtc = hour };
one.Log = new FakeLog([shared]);
FakeLog multLog = new([shared]);
two.Log = multLog;
int arrived = 0;
two.UpdateArrived += (_, update) =>
{
arrived++;
multLog.Take(update);
};
one.Start();
two.Start();
// each station opens its own connection, which is N1MM's arrangement:
// an answer goes back over the one this end opened
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
two.AddStation("RUN-PC", "127.0.0.1", onePort);
await WaitForAsync(() =>
one.Stations.Any(s => s is { IsMine: false, IsConnected: true })
&& two.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
NetworkedStation runStation = two.Stations.First(s => s.ComputerName == "RUN-PC");
two.StartResync(runStation, hour, hour.AddHours(1));
await WaitForAsync(() => !runStation.IsResyncing);
Assert.Equal(0, arrived);
Assert.Equal(0, runStation.HoursFailed);
Assert.Single(multLog.All);
}
/// A log a resync can ask about, and that takes what a resync sends it.
private sealed class FakeLog(IEnumerable<Qso> contacts) : ContactLog
{
private readonly List<Qso> qsos = [.. contacts];
public string ContestName { get; set; } = "CQWW";
public string MyCall => "OM3XYZ";
public string ContestSubType { get; set; } = "";
public string CountryFileVersion { get; set; } = "";
public IReadOnlyList<Qso> All => qsos;
public IReadOnlyList<Qso> Contacts(string computerName, DateTime fromUtc, TimeSpan length) =>
LogChecksum.Block(qsos, computerName, fromUtc, length);
public DateTime LastContactUtc(string computerName) =>
qsos.Where(q => string.Equals(q.StationName, computerName, StringComparison.OrdinalIgnoreCase))
.Select(q => q.TimestampUtc)
.DefaultIfEmpty(default)
.Max();
public DateTime FirstContactUtc =>
qsos.Select(q => q.TimestampUtc).DefaultIfEmpty(default).Min();
public void Take(ContactUpdate update)
{
switch (update)
{
case ContactLogged logged when !qsos.Any(q => q.Id == logged.Qso.Id):
qsos.Add(logged.Qso);
break;
case ContactsCleared cleared:
qsos.RemoveAll(q =>
string.Equals(q.StationName, cleared.StationName, StringComparison.OrdinalIgnoreCase)
&& q.TimestampUtc >= cleared.FromUtc
&& q.TimestampUtc < cleared.FromUtc + cleared.Length);
break;
}
}
}
/// The master is the station the whole entry follows for cluster spots and
/// the time. Every station is told which one it is, so the window agrees
/// everywhere.
[Fact]
public async Task TheMasterIsTheSameStationEverywhere()
{
(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();
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
await one.SendMasterAsync(one.Stations.First(s => s.ComputerName == "MULT-PC"));
await WaitForAsync(() => two.Me.IsMaster);
Assert.True(two.Me.IsMaster);
Assert.True(one.Stations.First(s => s.ComputerName == "MULT-PC").IsMaster);
Assert.False(one.Me.IsMaster);
}
/// Two clocks that disagree cannot both be right, and the sponsor matches
/// contacts by time. Each station says what its clock reads and the other
/// works out the difference.
[Fact]
public async Task AStationSaysWhatItsClockReads()
{
(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();
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
await one.SendTimeAsync();
await WaitForAsync(() =>
two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.ClockOffset is not null);
TimeSpan? offset = two.Stations.First(s => s.ComputerName == "RUN-PC").ClockOffset;
Assert.NotNull(offset);
Assert.True(offset.Value.Duration() < TimeSpan.FromSeconds(2), $"two clocks of one machine differ by {offset}");
}
[Fact]
public async Task SerialNumbersCanBeStartedAgainAtEveryStation()
{
(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();
bool asked = false;
two.SerialNumbersReset += (_, _) => asked = true;
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
await one.SendResetSerialNumbersAsync();
await WaitForAsync(() => asked);
Assert.True(asked);
}
/// A callsign handed to another station's call stack, with the contact
/// behind it when there is one.
[Fact]
public async Task ACallCanBeHandedToAnotherStationsStack()
{
(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();
List<StackedCall> stacked = [];
two.CallStacked += (_, call) => stacked.Add(call);
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
NetworkedStation target = one.Stations.First(s => s.ComputerName == "MULT-PC");
await one.SendStackedAsync(target, "G3XYZ");
await one.SendStackedAsync(target, "DL1ABC", Contact() with { Call = Callsign.Parse("DL1ABC") });
await WaitForAsync(() => stacked.Count == 2);
Assert.Equal("G3XYZ", stacked[0].Call);
Assert.Null(stacked[0].Contact);
Assert.Equal("DL1ABC", stacked[1].Call);
Assert.Equal(14_025, stacked[1].Contact?.Frequency.Kilohertz ?? 0);
Assert.Equal("RUN-PC", stacked[1].FromComputerName);
}
/// Two stations logging different contests would resynchronise into each
/// other's logs. Each says which contest it is on as soon as it connects.
[Fact]
public async Task AStationOnAnotherContestIsNamed()
{
(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.Log = new FakeLog([]) { ContestName = "CQWPX" };
two.Log = new FakeLog([]) { ContestName = "CQWW" };
List<string> saidAtTwo = [];
two.Failed += (_, why) => saidAtTwo.Add(why);
one.Start();
two.Start();
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => saidAtTwo.Any(w => w.Contains("CQWPX")));
Assert.Contains(saidAtTwo, w => w.Contains("RUN-PC") && w.Contains("CQWPX") && w.Contains("CQWW"));
}
/// Two issues of the country file score the same contact differently, so a
/// station on another one is named — once, not with every status.
[Fact]
public async Task AStationWithAnotherCountryFileIsNamedOnce()
{
(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.Log = new FakeLog([]) { CountryFileVersion = "20260826" };
two.Log = new FakeLog([]) { CountryFileVersion = "20260101" };
List<string> saidAtTwo = [];
two.Failed += (_, why) => saidAtTwo.Add(why);
one.Start();
two.Start();
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
await one.SendStatusAsync(Status());
await one.SendStatusAsync(Status());
await WaitForAsync(() => saidAtTwo.Any(w => w.Contains("20260826")));
Assert.Single(saidAtTwo, w => w.Contains("20260826") && w.Contains("20260101"));
}
/// Every station of an entry needs its own number: two stations on the same
/// one stamp their contacts as made by the same position.
[Fact]
public async Task TwoStationsOnTheSameNumberAreNamed()
{
(int onePort, int twoPort) = SparePorts();
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
using StationLink two = new("MULT-PC", Version, stationNumber: 1, port: twoPort);
List<string> saidAtTwo = [];
two.Failed += (_, why) => saidAtTwo.Add(why);
one.Start();
two.Start();
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => saidAtTwo.Any(w => w.Contains("station 1")));
Assert.Contains(saidAtTwo, w => w.Contains("RUN-PC") && w.Contains("station 1"));
}
private static StationStatus Status() => new(
Frequency.Zero,
Frequency.FromKilohertz(14_025),
IsRunning: true,
"OM3XYZ",
Modes.Cw,
Last10: 60,
Last100: 55,
"MULTI-ONE",
"TWO",
"20260826");
private static async Task WaitForAsync(Func<bool> ready)
{
DateTime giveUp = DateTime.UtcNow + Patience;
while (!ready() && DateTime.UtcNow < giveUp)
{
await Task.Delay(5);
}
}
/// Two ports nothing else is on, so the tests do not fight each other or
/// the N1MM the machine may be running.
///
/// Both are taken before either is let go. Asking twice in a row does not
/// work: the second ask often gets the port the first one has just given
/// back, and two links on one port leaves the second without a listener.
private static (int One, int Two) SparePorts()
{
TcpListener first = new(IPAddress.Loopback, 0);
TcpListener second = new(IPAddress.Loopback, 0);
first.Start();
second.Start();
int one = ((IPEndPoint)first.LocalEndpoint).Port;
int two = ((IPEndPoint)second.LocalEndpoint).Port;
first.Stop();
second.Stop();
return (one, two);
}
private static Qso Contact() => new()
{
Id = Qso.NewId(),
TimestampUtc = new DateTime(2026, 9, 3, 12, 34, 56, DateTimeKind.Utc),
Call = Callsign.Parse("G3XYZ"),
Frequency = Frequency.FromKilohertz(14_025),
Mode = Modes.Cw,
ContestName = "CQWW",
ContestNumber = 2,
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);
}
}