Files
Nonemm/tests/Nonemm.Network.Tests/StationLinkTests.cs
T
erikbrockoandClaude Opus 5 68b29d722a Give the network status window N1MM's tabs and columns
The window was one flat table of columns this program made up. N1MM's has
tabs and a fixed set of columns, and an operator who knows one has to be
able to read the other.

**The window** now has Stations, Actions and Messages. Stations is a grid
with N1MM's headers in N1MM's order — Computer, IP Address, Pass, Run,
10, 100, Freq, Op, Msg, Send, Receive, Band, R/M, Mode — and a row per
station, this computer included. Right-click a row to pass the contact
being worked to that station, to talk, to show the connection log, or to
close and open its connection. Actions holds the talk line, the pass
frequency, the echo and the networking switch; Messages holds the chat,
the connection failures, the echo round trips and a trace of every
message that arrives.

**`STATUS`** is what fills those columns. N1MM sends it every few seconds
whether anything has changed or not — the pass frequency, the frequency,
run or search and pounce, the operator, the rates over the last ten and
the last hundred contacts, the mode, the categories and the version — so
a station sitting on one frequency for an hour still has a current row.
`BANDMAP` only says a station moved. The five transmit-interlock flags
are sent as false: this program does not block a transmitter.

**`Rates.PerHour`** reads the two rate columns out of the log. Count back
over the last N contacts, stop at a gap longer than the contest's minimum
off time, and treat a span under a minute as a minute, so three contacts
in five seconds read as 180 an hour rather than 2160.

A connection closed by hand stays closed. Without that flag the next
beacon opens it again within ten seconds, which makes the menu item look
broken.

Left out, because there is nothing behind them: N1MM's Resynchronize and
Rescore tabs, the transmit interlocks of its Options tab, the Master
column and the call-stacking target tab.

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

249 lines
9.8 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()));
}
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,
};
}