Three of the messages this program sends were its own invention or in the wrong units, so an N1MM beside it read them wrongly or not at all. | Message | N1MM | What was sent | |---|---|---| | `XMIT` | transmitting, the function key's caption, Run2, F1 pressed, stop sending | transmitting, the radio number | | `PASSFREQ` | the frequency in hundredths of a kilohertz, and nothing else | the frequency in kilohertz, and a callsign | | `FREQ` | the frequency in hundredths of a kilohertz | `BANDMAP`, which N1MM has no such message for | So N1MM showed a radio number where the other operators read what a station is sending, read a pass on 14205.5 as 140.25, and never saw this station move at all. `BANDMAP` is gone: the frequency goes out in `FREQ` when the radio moves and the mode, run and the rest ride in the `STATUS` that follows it, which is where N1MM carries them. The `Msg` column now shows what a station is sending, which is what the caption is for. `PassCall` and `RadioNumber` are gone from the station: neither is on the wire, so neither was ever anything but this program talking to itself. **A station that reconnects resynchronises by itself.** N1MM sends `LASTQAT` — when I last worked anybody — as soon as a connection opens, and a station holding nothing that late asks for the hours in between. An empty log asks for nothing: a program that has just been started has no business pulling the whole contest across, and N1MM suppresses it the same way. The answer goes over the connection this station opens, which is not always up when the message arrives, so a resync that has nothing to ask over waits for it rather than being lost — and one that still cannot send stops and says so instead of leaving the row resynchronising for the rest of the contest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
444 lines
18 KiB
C#
444 lines
18 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 => "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 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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",
|
|
};
|
|
}
|