Add the Broadcast Data dialog and send N1MM's score breakdown

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 03:42:19 +00:00
co-authored by Claude Sonnet 5.5
parent ec12fb1d0f
commit 65178b2d89
24 changed files with 1325 additions and 21 deletions
@@ -0,0 +1,72 @@
using Nonemm.Contests.Rules;
using Nonemm.Core;
namespace Nonemm.Contests.Tests;
public class ScoreBreakdownTests
{
private static readonly Contest Contest = new CqWorldWide(ModeCategory.Cw);
private static Qso Scored(string call, double kilohertz, Mode mode, int points, bool zone = false, bool country = false) =>
TestLog.Contact(call, kilohertz, mode) with
{
Points = points,
IsMultiplier1 = zone,
IsMultiplier2 = country,
};
[Fact]
public void Rows_are_ordered_by_the_band_as_text_then_by_mode_and_the_total_is_last()
{
List<Qso> log =
[
Scored("DL1AAA", 3_525, Modes.Cw, 1),
Scored("DL1BBB", 10_120, Modes.Cw, 1),
Scored("DL1CCC", 14_025, Modes.Cw, 1),
Scored("DL1DDD", 14_200, Modes.Usb, 1),
Scored("DL1EEE", 1_830, Modes.Cw, 1),
];
IReadOnlyList<ScoreBreakdownRow> rows = ScoreBreakdown.For(log, Contest);
Assert.Equal(
[(1.8, "CW"), (10.0, "CW"), (14.0, "CW"), (14.0, "USB"), (3.5, "CW"), (ScoreBreakdown.TotalBand, "Both")],
rows.Select(r => (r.Band, r.Mode)));
}
[Fact]
public void A_row_adds_up_contacts_points_and_multiplier_flags()
{
List<Qso> log =
[
Scored("DL1AAA", 14_025, Modes.Cw, 3, zone: true, country: true),
Scored("DL1BBB", 14_030, Modes.Cw, 3, country: true),
Scored("DL1CCC", 7_010, Modes.Cw, 3, zone: true),
];
ScoreBreakdownRow twenty = ScoreBreakdown.For(log, Contest).Single(r => r.Band == 14.0);
ScoreBreakdownRow total = ScoreBreakdown.For(log, Contest).Last();
Assert.Equal((2, 6, 1, 2, 0), (twenty.Qsos, twenty.Points, twenty.Multipliers[0], twenty.Multipliers[1], twenty.Multipliers[2]));
Assert.Equal((3, 9, 2, 2), (total.Qsos, total.Points, total.Multipliers[0], total.Multipliers[1]));
}
[Fact]
public void A_dupe_and_an_unclaimed_contact_are_not_counted()
{
List<Qso> log =
[
Scored("DL1AAA", 14_025, Modes.Cw, 3),
Scored("DL1AAA", 14_026, Modes.Cw, 0) with { ContactType = "D" },
Scored("DL1BBB", 14_027, Modes.Cw, 3) with { IsClaimed = false },
];
Assert.Equal(1, ScoreBreakdown.For(log, Contest).Last().Qsos);
}
[Fact]
public void An_empty_log_has_no_rows_not_even_a_total()
{
Assert.Empty(ScoreBreakdown.For([], Contest));
}
}
@@ -0,0 +1,214 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Xml.Linq;
namespace Nonemm.Network.Tests;
public class BroadcastTests
{
[Fact]
public void An_address_without_a_port_takes_the_default_port()
{
IReadOnlyList<IPEndPoint>? parsed = BroadcastTargets.Parse("127.0.0.1 192.168.1.255:9000", 12060, out _);
Assert.Equal([new IPEndPoint(IPAddress.Loopback, 12060), new IPEndPoint(IPAddress.Parse("192.168.1.255"), 9000)], parsed);
}
[Theory]
[InlineData("not-an-address")]
[InlineData("127.0.0.1:99999")]
[InlineData("127.0.0.1:12060:1")]
public void An_entry_that_is_not_an_address_is_refused(string text)
{
Assert.Null(BroadcastTargets.Parse(text, 12060, out string problem));
Assert.Contains(text, problem);
}
[Fact]
public void The_score_and_the_rotor_have_their_own_default_ports()
{
Assert.Equal(12050, BroadcastTargets.DefaultFor(BroadcastKind.Score).Port);
Assert.Equal(12040, BroadcastTargets.DefaultFor(BroadcastKind.Rotor).Port);
Assert.Equal(12060, BroadcastTargets.DefaultFor(BroadcastKind.Radio).Port);
}
[Fact]
public void Radio_info_carries_the_frequency_in_units_of_ten_hertz()
{
string xml = BroadcastMessages.Radio(
new RadioReport(1, 14_025_500, 14_025_500, "CW", "OK1ABC", "OK1ABC", true, 1, false, 1, false, "", true),
"RUN-PC");
XElement root = XElement.Parse(xml);
Assert.Equal("RadioInfo", root.Name.LocalName);
Assert.Equal("1402550", root.Element("Freq")!.Value);
Assert.Equal("True", root.Element("IsRunning")!.Value);
Assert.Equal("RUN-PC", root.Element("StationName")!.Value);
}
[Fact]
public void A_deleted_spot_carries_no_comment_or_status()
{
SpotReport spot = new("DL1ABC", 7_012_300, "OK2XYZ", new DateTime(2026, 5, 30, 12, 0, 0), "CW", "up 1", "new qso");
XElement added = XElement.Parse(BroadcastMessages.Spot(spot, "add", "RUN-PC"));
XElement deleted = XElement.Parse(BroadcastMessages.Spot(spot, "delete", "RUN-PC"));
Assert.Equal("7012.3", added.Element("frequency")!.Value);
Assert.Equal("new qso", added.Element("status")!.Value);
Assert.Null(deleted.Element("status"));
Assert.Null(deleted.Element("comment"));
}
private static ScoreReport Score(string state = "", string grid = "JO70", string assisted = "NON-ASSISTED") => new(
"CQ-WW-CW", "OK1ABC ", "OK1ABC", "HIGH", assisted, "ONE", "SINGLE-OP-ASSISTED", "ALL", "CW", "", "",
"", "OK", 15, 28, "", state, grid,
["ZN", "COUNTRYPREFIX"],
[
new ScoreReportRow(3.5, "CW", 7, [2, 3], 18),
new ScoreReportRow(ScoreLabels.TotalBand, "Both", 10, [3, 4], 25),
],
175, new DateTime(2026, 5, 30, 12, 0, 0));
[Fact]
public void The_score_is_written_in_the_layout_N1MM_uses()
{
string xml = BroadcastMessages.Score(Score(state: "OK"));
Assert.Equal(
"<?xml version=\"1.0\"?>\r\n<dynamicresults>\r\n"
+ "\t<contest>CQ-WW-CW</contest>\r\n"
+ "\t<call>OK1ABC</call>\r\n"
+ "\t<ops>OK1ABC</ops>\r\n"
+ "\t<class power=\"HIGH\" assisted = \"NON-ASSISTED\" transmitter=\"ONE\" ops=\"SINGLE-OP\" bands=\"ALL\" mode=\"CW\" overlay=\"\" station=\"\" ></class>\r\n"
+ "\t<club></club>\r\n"
+ "\t<qth>\r\n"
+ "\t\t<dxcccountry>OK</dxcccountry>\r\n"
+ "\t\t<cqzone>15</cqzone>\r\n"
+ "\t\t<iaruzone>28</iaruzone>\r\n"
+ "\t\t<stprvoth>OK</stprvoth>\r\n"
+ "\t\t<grid4>JO70</grid4>\r\n"
+ "\t</qth>\r\n"
+ "\t<breakdown>\r\n"
+ "\t\t<qso band=\"80\" mode=\"CW\">7</qso>\r\n"
+ "\t\t<mult band=\"80\" mode=\"CW\" type=\"zone\">2</mult>\r\n"
+ "\t\t<mult band=\"80\" mode=\"CW\" type=\"country\">3</mult>\r\n"
+ "\t\t<point band=\"80\" mode=\"CW\">18</point>\r\n"
+ "\t\t<qso band=\"total\" mode=\"ALL\">10</qso>\r\n"
+ "\t\t<mult band=\"total\" mode=\"ALL\" type=\"zone\">3</mult>\r\n"
+ "\t\t<mult band=\"total\" mode=\"ALL\" type=\"country\">4</mult>\r\n"
+ "\t\t<point band=\"total\" mode=\"ALL\">25</point>\r\n"
+ "\t</breakdown>\r\n"
+ "\t<score>175</score>\r\n"
+ "\t<timestamp>2026-05-30 12:00:00</timestamp>\r\n"
+ "</dynamicresults>\r\n",
xml);
}
[Fact]
public void The_score_leaves_out_an_empty_state_and_a_grid_that_is_not_four_or_six_letters()
{
string xml = BroadcastMessages.Score(Score(grid: "JO"));
Assert.DoesNotContain("stprvoth", xml);
Assert.DoesNotContain("grid", xml);
}
[Theory]
[InlineData(1.8, "160")]
[InlineData(3.5, "80")]
[InlineData(5, "60")]
[InlineData(7, "40")]
[InlineData(10, "30")]
[InlineData(14, "20")]
[InlineData(18, "17")]
[InlineData(21, "15")]
[InlineData(24, "12")]
[InlineData(28, "10")]
[InlineData(50, "6")]
[InlineData(70, "4")]
[InlineData(144, "2")]
[InlineData(222, "1.25")]
[InlineData(420, "0.7")]
[InlineData(902, "0.33")]
[InlineData(1240, "0.23")]
[InlineData(2300, "0.13")]
[InlineData(3300, "0.09")]
[InlineData(5650, "0.06")]
[InlineData(10000, "0.03")]
[InlineData(24000, "0.01")]
[InlineData(47000, "0.0064")]
[InlineData(76000, "0.004")]
[InlineData(122250, "0.0025")]
[InlineData(134000, "0.002")]
[InlineData(241000, "0.0012")]
[InlineData(0.1, "0")]
[InlineData(0.5, "0")]
[InlineData(ScoreLabels.TotalBand, "total")]
public void A_band_is_named_by_its_wavelength_in_metres(double band, string label)
{
Assert.Equal(label, ScoreLabels.Band(band));
}
[Theory]
[InlineData("USB", "PH")]
[InlineData("LSB", "PH")]
[InlineData("SSB", "PH")]
[InlineData("FM", "PH")]
[InlineData("CW", "CW")]
[InlineData("RCW", "CW")]
[InlineData("RTTY", "RTTY")]
[InlineData("RTTY-R", "RTTY")]
[InlineData("FT8", "FT8")]
[InlineData("PSK31", "PSK31")]
[InlineData("JT65", "ALL")]
[InlineData("Both", "ALL")]
public void A_mode_is_folded_into_the_few_N1MM_names(string mode, string label)
{
Assert.Equal(label, ScoreLabels.Mode(mode));
}
[Theory]
[InlineData("CountryPrefix", "country")]
[InlineData("Countryprefix", "country")]
[InlineData("Sect", "state")]
[InlineData("ZN", "zone")]
[InlineData("WPXPrefix", "WPXPREFIX")]
public void Three_multiplier_types_are_renamed_and_the_rest_are_upper_case(string name, string type)
{
Assert.Equal(type, ScoreLabels.MultiplierType(name));
}
[Fact]
public async Task A_kind_that_is_on_reaches_its_address_and_a_kind_that_is_off_does_not()
{
using UdpClient receiver = new(new IPEndPoint(IPAddress.Loopback, 0));
int port = ((IPEndPoint)receiver.Client.LocalEndPoint!).Port;
using DataBroadcaster broadcaster = new(new Dictionary<BroadcastKind, BroadcastChannel>
{
[BroadcastKind.Radio] = new() { IsEnabled = true, Addresses = [$"127.0.0.1:{port}"] },
[BroadcastKind.Spots] = new() { Addresses = [$"127.0.0.1:{port}"] },
});
broadcaster.Send(BroadcastKind.Spots, "<spot/>");
broadcaster.Send(BroadcastKind.Radio, "<RadioInfo/>");
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
UdpReceiveResult received = await receiver.ReceiveAsync(timeout.Token);
Assert.Equal("<RadioInfo/>", Encoding.UTF8.GetString(received.Buffer));
Assert.False(broadcaster.IsSending(BroadcastKind.Spots));
}
[Fact]
public void A_row_with_an_unreadable_address_is_left_off_and_reported()
{
using DataBroadcaster broadcaster = new(new Dictionary<BroadcastKind, BroadcastChannel>
{
[BroadcastKind.Score] = new() { IsEnabled = true, Addresses = ["nowhere"] },
});
Assert.False(broadcaster.IsSending(BroadcastKind.Score));
Assert.Single(broadcaster.Problems);
}
}