Seven tabs now, in N1MM's order, and the messages behind them. **Options** holds what there is something behind: Log QSOs at all Stations, which decides whether the contacts the others log are kept here; the message trace, moved off the Messages tab where N1MM does not keep it; and Run1/Run2, which used to be guessed from the radio number and is now the operator's choice, sent in every `STATUS` and every `XMIT`. The transmit interlocks stay out: this program does not block a transmitter, and a station that claimed it did would be waited on for nothing. **Call Stack Targets.** A callsign heard at one station and handed to the position that will work it — `STACKANOTHERCALL` with the call alone, `STACKCALL` with the contact behind it. The target is picked from the tab or from the station list, and what arrives goes on the call stack of the radio the operator is on, sorted by whether it is a multiplier here. **The master** is the station the entry follows for cluster spots and the time. `MASTER` names it to everybody, so the column agrees everywhere. **The clock.** `TIME` carries a station's clock and Force Time Sync sends it. N1MM sets the clock from it when it is running as an administrator and says how far out it is when it is not; this program always does the second, because setting the clock needs root and a contest logger has no business asking for it. Anything over two seconds is called out. **Serial numbers.** Reset Serial Numbers at all Stations sends `RESETQSONRS` and every station starts again at one. The contacts already logged keep the numbers they were sent with. **What the entry agrees on** is now checked rather than assumed: | What | How | |---|---| | the contest | `CONTESTNAME` as soon as a connection opens, and again in every `CHECKSUM` | | the country file | its issue date — the publisher's `=VER20260826` — rides in `STATUS` | | the computer name | longer than N1MM's fifteen characters is named at startup; a second computer answering to this one's name is named when its beacon arrives | | the station number | two stations on the same number are named when the second says so | `CountryFile` reads its issue date, which nothing had asked it for before. Left for later: the master relaying its cluster spots, and the shared serial-number pool where stations claim numbers in advance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
615 lines
26 KiB
C#
615 lines
26 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",
|
|
};
|
|
}
|