Fill in the rest of N1MM's network status window
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
This commit is contained in:
@@ -18,6 +18,19 @@ public class CountryFileTests
|
||||
|
||||
private static readonly CountryFile File = CountryFile.Parse(Sample);
|
||||
|
||||
/// The publisher writes the issue date in as a callsign of its own. The
|
||||
/// stations of a multi-operator entry compare it: two issues score the same
|
||||
/// contact differently.
|
||||
[Fact]
|
||||
public void TheIssueDateIsRead() =>
|
||||
Assert.Equal(
|
||||
"20260826",
|
||||
CountryFile.Parse(Sample.Replace("3D2;", "3D2,=VER20260826;")).Version);
|
||||
|
||||
[Fact]
|
||||
public void AFileWithNoIssueDateHasNoVersion() =>
|
||||
Assert.Equal("", File.Version);
|
||||
|
||||
[Fact]
|
||||
public void LongestPrefixWins() =>
|
||||
Assert.Equal("IT9", File.Find("IT9ABC")?.Entity.PrimaryPrefix);
|
||||
|
||||
@@ -365,10 +365,14 @@ public class StationLinkTests
|
||||
{
|
||||
private readonly List<Qso> qsos = [.. contacts];
|
||||
|
||||
public string ContestName => "CQWW";
|
||||
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) =>
|
||||
@@ -400,6 +404,173 @@ public class StationLinkTests
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -182,6 +182,22 @@ public class NetworkedContactsTests
|
||||
Assert.Equal(2, store.Qsos(session.Instance.ContestNumber).Count);
|
||||
}
|
||||
|
||||
/// Reset Serial Numbers at all Stations starts the numbers again at one.
|
||||
/// The contacts already logged keep the numbers they were sent with.
|
||||
[Fact]
|
||||
public void SerialNumbersCanBeStartedAgainAtOne()
|
||||
{
|
||||
(ContestSession session, _) = Station();
|
||||
session.Add(Arriving("JA1XYZ", 1) with { Id = Qso.NewId(), SentNumber = 1 });
|
||||
session.Add(Arriving("JA2XYZ", 2) with { Id = Qso.NewId(), SentNumber = 2 });
|
||||
Assert.Equal(3, session.SentNumber);
|
||||
|
||||
session.SerialNumbersFrom = DateTime.UtcNow;
|
||||
|
||||
Assert.Equal(1, session.SentNumber);
|
||||
Assert.Equal([1, 2], session.Log.Qsos.Select(q => q.SentNumber));
|
||||
}
|
||||
|
||||
/// Every contact logged here carries the computer that made it, which is
|
||||
/// what a resync asks for and what N1MM stamps into `NetBiosName`.
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user