Files
Nonemm/tests/Nonemm.Network.Tests/StationLinkTests.cs
T
erikbrockoandClaude Opus 5 2a21c772ad Resynchronize a log with the other stations, and rescore it
A station that was away — a laptop that lost the network, one that was
switched off — comes back missing whatever the others logged while it was
gone. N1MM fills that in by walking the log an hour at a time, and this is
that walk, on the same messages:

| Message | What it says |
|---|---|
| `CHECKSUM` | here is what I hold for your contacts in this hour, and the contest and callsign I am logging |
| `DELETEQS` | drop your copies of my contacts in that hour, I am sending them again |
| `RESYNCQSO` | one of those contacts |
| `CONFIRMED` | here is what the hour hashes to at my end — which also asks for the next hour |

Each station is the authority on the contacts it made, so an hour that
disagrees is replaced rather than merged. The answer to one block asks for
the next, and the walk ends when the block starts after the end of the
period. The Resynchronize tab counts the hours that agreed, the hours that
were sent again and the hours that still disagreed, which are N1MM's
columns.

**`LogChecksum`** is N1MM's: a CRC32 over the `DXLOG` columns of the block,
ordered by time and then callsign, leaving out what each station works out
for itself — points, the three multiplier flags, the country prefix, the
continent, the WPX prefix and `IsOriginal`. Two stations may disagree about
those and still hold the same log. A checksum that disagrees for any other
reason costs one block of traffic, which is what a resync is for.

**The link still holds no log.** It asks one through `ContactLog` for the
contacts of a computer in a block of time, and `SessionContactLog` answers
on the window thread, because that is where the log is touched.

**Every contact logged here now carries the computer that made it.** N1MM
stamps `NetBiosName` and `NetworkedCompNr` on every row and this program
did not, so its own contacts belonged to no station and a resync could not
ask for them.

**Rescore** writes the points and multiplier flags of a period back to the
database. The log in memory scores itself whenever it changes; the stored
rows do not, and N1MM reads them rather than working them out. A contact
that arrives from another station after the one that took a multiplier
leaves those columns behind. A station can also ask the others to rescore
when its resync finishes, which is the flag on the end of `CHECKSUM`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
2026-09-03 21:52:09 +00:00

352 lines
14 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.
[Fact]
public async Task WhereAStationIsReachesTheOthers()
{
(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.SendBandAsync(Frequency.FromKilohertz(21_025), Modes.Cw, running: true, radioNumber: 1);
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("CW", seen?.Mode?.Name);
Assert.True(seen?.IsRunning);
}
/// 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 => "CQWW";
public string MyCall => "OM3XYZ";
public IReadOnlyList<Qso> All => qsos;
public IReadOnlyList<Qso> Contacts(string computerName, DateTime fromUtc, TimeSpan length) =>
LogChecksum.Block(qsos, computerName, fromUtc, length);
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;
}
}
}
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",
};
}