Give the telnet window the rest of what N1MM's has

The packet window showed traffic and took a command line, and everything else
about the cluster lived in a dialog under Config. It is now the window N1MM has,
with the same five tabs.

Telnet shows the traffic with spot lines in green, what went out in blue and
lines from a preferred spotter in bold. Double clicking a spot line, or "Jump to
this spot", puts the radio there with the call in the entry window. Scrolling
stops while the pointer is over the traffic. The client keeps the last two
hundred lines, so a window opened mid-contest is not blank.

Clusters keeps the operator's nodes with their ports, passwords and after-login
commands, connects and disconnects, and holds the logon settings. Download
fetches the published list of telnet nodes from NG3K — around fifty, with the
sysop's call and a note about each — and clicking one fills the boxes in. N1MM
downloads its list from its own web service, which asks the operator to opt in
to data collection and is N1MM's to run, so this reads a public page instead.
ClusterList takes any page with telnet:// links in a table; a page it cannot
read leaves the stored list alone.

Filters decide which spots reach the bandmap: bands, modes, beacons, busted
calls, stations outside the call history file, blacklisted spotters and calls,
spots from outside your country, continent or a list of prefixes, and how long a
spot stays on the map. A busted spot is a call the callsign database has never
heard that is one character away from one it knows; a call nothing resembles is
kept, because that is what a new station looks like. Nothing is filtered out of
the traffic itself — the operator sees everything the node sends.

Buttons edits the twelve command buttons. A button takes what N1MM's takes: the
message macros, several commands separated by semicolons, or {CONN} and the name
of a favourite, which connects to that node instead of sending anything. The
label takes the macros too. Right-clicking a button opens the editor.

Config ▸ Cluster now opens this window on the Clusters tab rather than a dialog
of its own, which is where N1MM keeps those settings.

Three things that could take the program down while a cluster was connected:

Settings.Load read a null where the property is not nullable. A file that names
a key with a null value — one written before the property existed and then
edited — put that null straight through, because the property's own default only
runs when the key is missing. Opening the telnet window then threw on the first
list it touched. Every null is now put back to the default the property
declares, walking into the stored records and the lists of them.

Bandmap was written from the cluster's thread and read from the window's, so a
dictionary could be modified while a window enumerated it. Every method locks
now.

ClusterClient disposed its token source while its own loop still used it, and
the retry delay sat outside the catch, so a disconnect faulted the loop task.

Along the way the message macros were checked against N1MM's function-key
documentation, and several were wrong. {LOGGEDCALL} is N1MM's {LASTCALL}, the
serial is #, and there is no {MYZONE}; {NAME} and {GRIDSQUARE} stand for the
other station's name and grid, not ours; {OTHERMHZ} is the radio the operator is
not on. The single-character macros * and ! were missing. Added from the same
table: {LASTCALL}, {PREVNR}, {NAMEANDSPACE}, {CHNAME}, {GRID}, the two grid
bearings and the grid distance, {FREQ}, {FREQROUND}, the other-radio
frequencies, {TIMESTAMP} and {TIME2}. Frequencies are formatted the way N1MM
formats them, with R for the decimal point on CW. The macros that pass a station
to the other band take the second radio as a new argument, and stand for nothing
at a one-radio station.

Left out, and written down: saving spots to a database, which N1MM keeps in its
admin database rather than in the log file the two programs share; the
special-calls list; the two-character busted check, which is a few hundred
thousand lookups per spot against a few hundred for one character; and N1MM's
action macros, which need a different shape than an expander that returns a
string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 07:33:55 +00:00
parent 648da1918a
commit d9f9d880f2
40 changed files with 2278 additions and 272 deletions

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Nonemm.App\Nonemm.App.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,67 @@
using Nonemm.App.Configuration;
namespace Nonemm.App.Tests;
public class SettingsTests : IDisposable
{
private readonly string path = Path.Combine(Path.GetTempPath(), $"nonemm-{Guid.NewGuid():N}.json");
public void Dispose() => File.Delete(path);
private Settings Load(string json)
{
File.WriteAllText(path, json);
return Settings.Load(path);
}
[Fact]
public void AFileWithNullsInItReadsAsTheDefaults()
{
Settings settings = Load(
"""
{
"ClusterHost": null,
"ClusterCommands": null,
"ClusterNodes": null,
"TelnetButtons": null,
"SpotFilter": null,
"Radios": null,
"Station": null,
"CwMessages": null
}
""");
Assert.Equal("", settings.ClusterHost);
Assert.Empty(settings.ClusterCommands);
Assert.Empty(settings.ClusterNodes);
Assert.Empty(settings.TelnetButtons);
Assert.Empty(settings.SpotFilter.Bands);
Assert.Empty(settings.Radios);
Assert.Equal("", settings.Station.Callsign);
Assert.Empty(settings.CwMessages);
}
[Fact]
public void ANullInsideAStoredNodeIsFilledInToo()
{
Settings settings = Load(
"""
{
"ClusterNodes": [ { "Name": null, "Host": "dxc.example.net", "Commands": null } ]
}
""");
StoredClusterNode node = Assert.Single(settings.ClusterNodes);
Assert.Equal("", node.Name);
Assert.Empty(node.Commands);
}
[Fact]
public void WhatTheFileSaysIsKept()
{
Settings settings = Load("""{ "ClusterHost": "dxc.example.net", "ClusterPort": 7300 }""");
Assert.Equal("dxc.example.net", settings.ClusterHost);
Assert.Equal(7300, settings.ClusterPort);
}
}

View File

@@ -58,4 +58,30 @@ public class BandPlanTests
Assert.True(segments.HasDigital);
}
[Fact]
public void TheModeOfAFrequencyComesFromTheSegments()
{
Assert.Equal(ModeCategory.Cw, BandPlan.Default.ModeAt(Frequency.FromKilohertz(14_025)));
Assert.Equal(ModeCategory.Phone, BandPlan.Default.ModeAt(Frequency.FromKilohertz(14_250)));
}
[Fact]
public void ABandWithNoSegmentsHasNoMode()
{
Assert.Null(BandPlan.Default.ModeAt(Frequency.FromKilohertz(5_300)));
}
[Fact]
public void TheDigitalPartWinsOverTheCwPart()
{
BandPlan plan = BandPlan.Default.With(new BandSegments(
Bands.Band20M,
Frequency.FromKilohertz(14_150),
Frequency.FromKilohertz(14_070),
Frequency.FromKilohertz(14_099)));
Assert.Equal(ModeCategory.Digital, plan.ModeAt(Frequency.FromKilohertz(14_080)));
Assert.Equal(ModeCategory.Cw, plan.ModeAt(Frequency.FromKilohertz(14_025)));
}
}

View File

@@ -1,13 +1,14 @@
using Nonemm.Contests;
using Nonemm.Contests.Rules;
using Nonemm.Core;
using Nonemm.Core.Calls;
using Nonemm.Storage;
namespace Nonemm.Session.Tests;
public class MessageExpanderTests
{
private static RadioPosition Session()
private static RadioPosition Session(CallHistory? history = null, int radioNumber = 1)
{
FakeLogStore store = new();
ContestInstance instance = store.AddContest(new ContestInstance
@@ -16,12 +17,15 @@ public class MessageExpanderTests
ContestName = "CQWW",
SentExchange = "14",
});
return new RadioPosition(new ContestSession(
store,
new CqWorldWide(ModeCategory.Cw),
instance,
new StationInfo { Callsign = "DL1ABC", CqZone = 14, Name = "Erik" },
null));
return new RadioPosition(
new ContestSession(
store,
new CqWorldWide(ModeCategory.Cw),
instance,
new StationInfo { Callsign = "DL1ABC", CqZone = 14, Name = "Erik", GridSquare = "JN88" },
null,
history),
radioNumber);
}
[Fact]
@@ -51,4 +55,66 @@ public class MessageExpanderTests
[Fact]
public void AnUnclosedMacroIsLeftAsTyped() =>
Assert.Equal("TU {MYCALL", MessageExpander.Expand("TU {MYCALL", Session()));
[Fact]
public void TheSingleCharacterMacrosAreTheirCallMyCallAndTheSerial()
{
RadioPosition session = Session();
session.Entry.Call = "JA1XYZ";
Assert.Equal("JA1XYZ 599 1 DE DL1ABC", MessageExpander.Expand("! 599 # DE *", session));
}
[Fact]
public void TheirCallFallsBackToTheLastOneLogged()
{
RadioPosition session = Session();
session.Entry.Call = "JA1XYZ";
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
session.Entry.Set(ExchangeSlot.Zone, "25");
session.LogContact();
Assert.Equal("JA1XYZ JA1XYZ", MessageExpander.Expand("{CALL} {LASTCALL}", session));
}
[Fact]
public void TheFrequencySendsRForTheDecimalPointOnCw()
{
RadioPosition session = Session();
session.Tune(Frequency.FromKilohertz(14_025.5));
Assert.Equal("14025R5 14026", MessageExpander.Expand("{FREQ} {FREQROUND}", session));
}
[Fact]
public void TheOtherRadioFillsInTheOtherMacros()
{
RadioPosition here = Session();
RadioPosition there = Session(radioNumber: 2);
there.Tune(Frequency.FromKilohertz(3_525));
Assert.Equal("3525R0 3R5 80M", MessageExpander.Expand("{OTHERFREQ} {OTHERMHZ} {OTHERBAND}", here, there));
}
[Fact]
public void WithOneRadioTheOtherMacrosStandForNothing() =>
Assert.Equal("", MessageExpander.Expand("{OTHERFREQ}{OTHERMHZ}{OTHERBAND}", Session()));
[Fact]
public void TheNameComesFromTheCallHistoryWhenTheContestHasNoNameBox()
{
RadioPosition session = Session(CallHistory.Parse("!!Order!!,Call,Name\nJA1XYZ,Ken"));
session.Entry.Call = "JA1XYZ";
Assert.Equal("TU Ken GL", MessageExpander.Expand("TU {NAMEANDSPACE}GL", session));
}
[Fact]
public void MyGridAndTheirsAreDifferentMacros()
{
RadioPosition session = Session(CallHistory.Parse("!!Order!!,Call,LOC1\nJA1XYZ,PM95"));
session.Entry.Call = "JA1XYZ";
Assert.Equal("JN88 PM95", MessageExpander.Expand("{GRID} {GRIDSQUARE}", session));
}
}

View File

@@ -66,6 +66,21 @@ public class ClusterClientTests : IDisposable
Assert.Equal("DL1ABC\r\n", Encoding.Latin1.GetString(sent[3..]));
}
[Fact]
public async Task NothingIsSentAtTheLoginPromptWhenLoggingOnByHand()
{
using ClusterClient cluster = new(
"127.0.0.1", Port, "DL1ABC", retryInterval: TimeSpan.FromMinutes(1), autoLogon: false);
cluster.Start();
using TcpClient node = await listener.AcceptTcpClientAsync().WaitAsync(Patience);
NetworkStream stream = node.GetStream();
await stream.WriteAsync(Encoding.Latin1.GetBytes("Please enter your call: "));
await cluster.SendAsync("DL1XYZ");
Assert.Equal("DL1XYZ\r\n", await ReadTextAsync(stream, 8).WaitAsync(Patience));
}
[Fact]
public async Task TheCommandsGoOutAfterTheLogin()
{

View File

@@ -0,0 +1,48 @@
namespace Nonemm.Spotting.Tests;
public class ClusterListTests
{
/// Four rows in the shape the NG3K page uses, one of each thing that has to
/// be read: a port after a colon, a port after a space, no port at all, and
/// the same node listed twice.
private const string Page =
"""
<table>
<tr align="center"><td><a href="http://www.k1ttt.net">K1TTT</a></td>
<td><a href="telnet://k1ttt.net:7373">k1ttt.net:7373</a><br>(dynamically assigned)</td>
<td>call</td><td>&nbsp;</td><td align="left">YCCC AR-Cluster (Peru, MA)</td></tr>
<tr align="center"><td>W4MYA</td>
<td><a href="telnet://dxc.w4mya.us 7373">dxc.w4mya.us 7373</a></td>
<td>call</td><td align="left">W4MYA CC-Cluster (Bumpass, VA)</td></tr>
<tr align="center"><td>W3LPL</td>
<td><a href="telnet://dxc.w3lpl.net">dxc.w3lpl.net</a></td>
<td>call</td><td align="left">W3LPL AR-Cluster, v.4</td></tr>
<tr align="center"><td>K1TTT again</td>
<td><a href="telnet://k1ttt.net:7373">k1ttt.net:7373</a></td></tr>
</table>
""";
[Fact]
public void EveryNodeOnThePageIsRead()
{
IReadOnlyList<ClusterNode> nodes = ClusterList.Parse(Page);
Assert.Equal(["k1ttt.net:7373", "dxc.w4mya.us:7373", "dxc.w3lpl.net:23"],
nodes.Select(n => n.Address));
}
[Fact]
public void TheNameAndTheNoteComeFromTheRow()
{
ClusterNode node = ClusterList.Parse(Page)[0];
Assert.Equal("K1TTT", node.Name);
Assert.Equal("YCCC AR-Cluster (Peru, MA)", node.Description);
}
[Fact]
public void APageWithNoNodesIsRefused()
{
Assert.Throws<FormatException>(() => ClusterList.Parse("<html><body>down for maintenance</body></html>"));
}
}

View File

@@ -0,0 +1,171 @@
using Nonemm.Core;
using Nonemm.Core.Calls;
using Nonemm.Core.Country;
namespace Nonemm.Spotting.Tests;
public class SpotFilterTests
{
private static readonly DateTime Now = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
/// Enough of a country file to place the spotters these tests use.
private static readonly CountryFile Countries = CountryFile.Parse(
"""
United States: 05: 08: NA: 37.53: 91.67: 5.0: K:
K,W,N,AA;
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
DL,DK,DJ;
Japan: 25: 45: AS: 36.00: -138.00: -9.0: JA:
JA,JH,JR;
""");
private static Spot From(string spotter, string call = "OM5M", double kilohertz = 14_025, string comment = "") =>
new(Callsign.Parse(call), Frequency.FromKilohertz(kilohertz), Now, SpotSource.Cluster, spotter, comment);
[Fact]
public void AFilterNobodyHasTouchedTakesEverything()
{
Assert.True(new SpotFilter().Accepts(From("W3LPL")));
}
[Fact]
public void OnlyTheChosenBandsGetThrough()
{
SpotFilter filter = new() { Bands = [Bands.Band20M] };
Assert.True(filter.Accepts(From("W3LPL", kilohertz: 14_025)));
Assert.False(filter.Accepts(From("W3LPL", kilohertz: 21_025)));
}
[Fact]
public void TheModeComesFromTheBandPlan()
{
SpotFilter filter = new() { Modes = [ModeCategory.Cw] };
Assert.True(filter.Accepts(From("W3LPL", kilohertz: 14_025)));
Assert.False(filter.Accepts(From("W3LPL", kilohertz: 14_250)));
}
[Fact]
public void BeaconsAreDroppedWhenTheyAreTurnedOff()
{
SpotFilter filter = new() { ShowBeacons = false };
Assert.False(filter.Accepts(From("W3LPL", comment: "NCDXF BCN")));
Assert.True(filter.Accepts(From("W3LPL", comment: "CQ contest")));
}
[Fact]
public void ABlacklistedSpotterAndABlacklistedCallAreDropped()
{
SpotFilter filter = new() { BlockedSpotters = ["W3LPL"], BlockedCalls = ["OM5M"] };
Assert.False(filter.Accepts(From("w3lpl", call: "DL1ABC")));
Assert.False(filter.Accepts(From("K1TTT", call: "OM5M")));
Assert.True(filter.Accepts(From("K1TTT", call: "DL1ABC")));
}
[Fact]
public void MyContinentOnlyKeepsTheSpottersFromIt()
{
SpotFilter filter = new()
{
Countries = Countries,
MyContinent = "EU",
MyContinentOnly = true,
};
Assert.True(filter.Accepts(From("DL1ABC")));
Assert.False(filter.Accepts(From("JA1XYZ")));
}
[Fact]
public void MyCountryOnlyKeepsTheSpottersFromIt()
{
SpotFilter filter = new()
{
Countries = Countries,
MyCountry = "K",
MyCountryOnly = true,
};
Assert.True(filter.Accepts(From("W3LPL")));
Assert.False(filter.Accepts(From("DL1ABC")));
}
[Fact]
public void ASpotterMatchingAnyOfTheChoicesGetsThrough()
{
SpotFilter filter = new()
{
Countries = Countries,
MyCountry = "K",
MyCountryOnly = true,
CallAreas = ["JA1"],
};
Assert.True(filter.Accepts(From("W3LPL")));
Assert.True(filter.Accepts(From("JA1XYZ")));
Assert.False(filter.Accepts(From("DL1ABC")));
}
[Fact]
public void WithoutACountryFileTheCountryFiltersLetSpotsThrough()
{
SpotFilter filter = new() { MyContinent = "EU", MyContinentOnly = true };
Assert.True(filter.Accepts(From("JA1XYZ")));
}
[Fact]
public void WhatTheOperatorSpottedIsNeverFiltered()
{
SpotFilter filter = new() { Bands = [Bands.Band20M] };
Spot mine = new(
Callsign.Parse("OM5M"),
Frequency.FromKilohertz(21_025),
Now,
SpotSource.Operator);
Assert.True(filter.Accepts(mine));
}
[Fact]
public void ACallOneCharacterOffAKnownCallIsABust()
{
SpotFilter filter = new()
{
Calls = CallDatabase.Parse("OM5M\nDL1ABC\nJA1XYZ"),
RemoveBustedSpots = true,
};
Assert.True(filter.Accepts(From("W3LPL", call: "OM5M")));
Assert.False(filter.Accepts(From("W3LPL", call: "OM5N")));
Assert.False(filter.Accepts(From("W3LPL", call: "DL1AB")));
Assert.False(filter.Accepts(From("W3LPL", call: "DL1ABCD")));
}
[Fact]
public void ACallNothingResemblesIsKept()
{
SpotFilter filter = new()
{
Calls = CallDatabase.Parse("OM5M\nDL1ABC"),
RemoveBustedSpots = true,
};
Assert.True(filter.Accepts(From("W3LPL", call: "VK9XY")));
}
[Fact]
public void WithoutACallsignDatabaseNothingIsCalledBusted()
{
SpotFilter filter = new() { RemoveBustedSpots = true };
Assert.True(filter.Accepts(From("W3LPL", call: "OM5N")));
}
[Fact]
public void OnlyTheStationsInTheCallHistoryGetThrough()
{
SpotFilter filter = new()
{
History = CallHistory.Parse("!!Order!!,Call,Name\nOM5M,Erik"),
OnlyInCallHistory = true,
};
Assert.True(filter.Accepts(From("W3LPL", call: "OM5M")));
Assert.False(filter.Accepts(From("W3LPL", call: "DL1ABC")));
}
}

View File

@@ -0,0 +1,28 @@
using Nonemm.Core;
namespace Nonemm.Spotting.Tests;
public class SpotJitterTests
{
private static Spot At(double kilohertz) =>
new(Callsign.Parse("OM5M"), Frequency.FromKilohertz(kilohertz), DateTime.UtcNow, SpotSource.Cluster);
[Fact]
public void ACwSpotMovesByThirtyOrSixtyHertz()
{
Random random = new(1);
for (int run = 0; run < 20; run++)
{
Spot moved = SpotJitter.Shifted(At(14_025), BandPlan.Default, random);
long offset = moved.Frequency.Hertz - 14_025_000;
Assert.Contains(offset, (long[])[-60, -30, 30, 60]);
}
}
[Fact]
public void APhoneSpotIsLeftWhereItIs()
{
Spot spot = At(14_250);
Assert.Equal(spot, SpotJitter.Shifted(spot, BandPlan.Default, new Random(1)));
}
}