Add CW keying, function key messages and the documentation

The function keys send through cwdaemon or a WinKeyer, with N1MM's message
macros. Escape stops sending.

Settings are read with the reflection serializer rather than a generated one:
the generated one hands back null for every property the file leaves out
instead of the value the property is declared with, which crashed the program
the first time a new setting was added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik
2026-08-27 11:23:30 +00:00
parent d96cbe146b
commit f5aa77ece3
25 changed files with 1002 additions and 26 deletions

View File

@@ -0,0 +1,52 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace Nonemm.Keying.Tests;
/// The sender is tested against a socket that speaks cwdaemon's side of the
/// protocol, so what goes on the wire is what is asserted.
public class CwDaemonSenderTests : IDisposable
{
private readonly UdpClient daemon = new(new IPEndPoint(IPAddress.Loopback, 0));
private int Port => ((IPEndPoint)daemon.Client.LocalEndPoint!).Port;
public void Dispose()
{
daemon.Dispose();
GC.SuppressFinalize(this);
}
private async Task<byte[]> NextDatagram(Func<Task> send)
{
Task<UdpReceiveResult> receiving = daemon.ReceiveAsync();
await send();
UdpReceiveResult received = await receiving.WaitAsync(TimeSpan.FromSeconds(5));
return received.Buffer;
}
[Fact]
public async Task TextGoesOutInUpperCase()
{
using CwDaemonSender sender = new("127.0.0.1", Port);
byte[] sent = await NextDatagram(() => sender.SendAsync("cq test de dl1abc"));
Assert.Equal("CQ TEST DE DL1ABC", Encoding.ASCII.GetString(sent));
}
[Fact]
public async Task AbortIsTheEscapeFourCommand()
{
using CwDaemonSender sender = new("127.0.0.1", Port);
byte[] sent = await NextDatagram(() => sender.AbortAsync());
Assert.Equal([0x1B, (byte)'4'], sent);
}
[Fact]
public async Task SpeedIsTheEscapeTwoCommand()
{
using CwDaemonSender sender = new("127.0.0.1", Port);
byte[] sent = await NextDatagram(() => sender.SetSpeedAsync(32));
Assert.Equal([0x1B, (byte)'2', (byte)'3', (byte)'2'], sent);
}
}

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.Keying\Nonemm.Keying.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,59 @@
using Nonemm.Core;
namespace Nonemm.Network.Tests;
public class ContactMessageTests
{
private static Qso Contact() => new()
{
Id = "0123456789abcdef0123456789abcdef",
TimestampUtc = new DateTime(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc),
Call = Callsign.Parse("JA1XYZ"),
Frequency = Frequency.FromKilohertz(14_025.5),
Mode = Modes.Cw,
ContestName = "CQWW",
ContestNumber = 3,
SentReport = "599",
ReceivedReport = "579",
Zone = 25,
Points = 3,
IsMultiplier1 = true,
CountryPrefix = "JA",
Continent = "AS",
WpxPrefix = "JA1",
IsRunQso = true,
};
[Fact]
public void ContactSurvivesTheRoundTrip()
{
Qso? read = ContactMessage.Read(ContactMessage.Write(Contact(), "DL1ABC", "PC1"));
Assert.NotNull(read);
Assert.Equal("0123456789abcdef0123456789abcdef", read.Id);
Assert.Equal("JA1XYZ", read.Call.Text);
Assert.Equal(14_025_500, read.Frequency.Hertz);
Assert.Equal(new DateTime(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc), read.TimestampUtc);
Assert.Equal(25, read.Zone);
Assert.Equal("JA", read.CountryPrefix);
Assert.True(read.IsRunQso);
Assert.Equal("PC1", read.StationName);
}
/// A contact that arrived over the network was made somewhere else, whatever
/// the sender put in the field.
[Fact]
public void AContactFromTheNetworkIsNotOriginal() =>
Assert.False(ContactMessage.Read(ContactMessage.Write(Contact(), "DL1ABC", "PC1"))!.IsOriginal);
[Fact]
public void FrequenciesAreSentInUnitsOfTenHertz() =>
Assert.Contains("<rxfreq>1402550</rxfreq>", ContactMessage.Write(Contact(), "DL1ABC", "PC1"));
[Theory]
[InlineData("<radioinfo><app>N1MM</app></radioinfo>")]
[InlineData("not xml at all")]
[InlineData("<contactinfo><call></call></contactinfo>")]
public void AnythingThatIsNotAContactIsPassedOver(string message) =>
Assert.Null(ContactMessage.Read(message));
}

View File

@@ -0,0 +1,26 @@
<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.Network\Nonemm.Network.csproj" />
<ProjectReference Include="..\..\src\Nonemm.Core\Nonemm.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,54 @@
using Nonemm.Contests;
using Nonemm.Contests.Rules;
using Nonemm.Core;
using Nonemm.Storage;
namespace Nonemm.Session.Tests;
public class MessageExpanderTests
{
private static LoggingSession Session()
{
FakeLogStore store = new();
ContestInstance instance = store.AddContest(new ContestInstance
{
ContestNumber = 0,
ContestName = "CQWW",
SentExchange = "14",
});
return new LoggingSession(
store,
new CqWorldWide(ModeCategory.Cw),
instance,
new StationInfo { Callsign = "DL1ABC", CqZone = 14, Name = "Erik" },
null);
}
[Fact]
public void MyCallAndTheirCallAreFilledIn()
{
LoggingSession session = Session();
session.Entry.Call = "JA1XYZ";
Assert.Equal("JA1XYZ DE DL1ABC", MessageExpander.Expand("{CALL} DE {MYCALL}", session));
}
[Fact]
public void TheSentExchangeComesFromTheContestSetup() =>
Assert.Equal("599 14", MessageExpander.Expand("{SENTRST} {EXCH}", Session()));
[Fact]
public void HashIsTheSerialNumber() =>
Assert.Equal("NR 1", MessageExpander.Expand("NR #", Session()));
[Fact]
public void CutNumbersReplaceZeroAndNine() =>
Assert.Equal("5NN", MessageExpander.Expand("{SENTRSTCUT}", Session()));
[Fact]
public void AMacroThatIsNotKnownDisappears() =>
Assert.Equal("TU ", MessageExpander.Expand("TU {NOTAMACRO}", Session()));
[Fact]
public void AnUnclosedMacroIsLeftAsTyped() =>
Assert.Equal("TU {MYCALL", MessageExpander.Expand("TU {MYCALL", Session()));
}