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(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 contacts) : ContactLog { private readonly List qsos = [.. contacts]; public string ContestName => "CQWW"; public string MyCall => "OM3XYZ"; public IReadOnlyList All => qsos; public IReadOnlyList 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 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", }; }