From d96cbe146be04a779cfa93f8adc43a4bb8b97ff3 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 27 Aug 2026 11:15:48 +0000 Subject: [PATCH] Write what N1MM needs before it will open a log Checked by opening a log this program wrote in N1MM 1.0.11031. Three things it rejected: - a log naming a contest with no row in the Contest table throws "No current row" in Contest.FromRow, so the definition row is written whenever a contest is opened; - an empty overlay category is answered with "Invalid Overlay Category:", so an entry with no overlay now says "N/A", and the dialog offers N1MM's list; - the sent exchange omits the report, which is what N1MM's own contest dialog asks for. With those three fixed N1MM opens the log and shows the contacts. Co-Authored-By: Claude Opus 5 --- src/Nonemm.App/AppSession.cs | 6 +++ .../Dialogs/ContestSetupDialog.axaml | 2 +- .../Dialogs/ContestSetupDialog.axaml.cs | 7 ++- src/Nonemm.Contests/Rules/ArrlDx.cs | 5 +- src/Nonemm.Contests/Rules/CqWorldWide.cs | 6 +-- src/Nonemm.Contests/Rules/CqWpx.cs | 5 +- src/Nonemm.Contests/Rules/GeneralLogging.cs | 2 +- src/Nonemm.Contests/Rules/IaruHf.cs | 2 +- src/Nonemm.Contests/Rules/RttyRoundup.cs | 2 +- src/Nonemm.Session/ContestDefinitions.cs | 51 +++++++++++++++++++ src/Nonemm.Storage/ContestDefinition.cs | 32 ++++++++++++ src/Nonemm.Storage/ContestInstance.cs | 4 +- src/Nonemm.Storage/LogStore.cs | 4 ++ src/Nonemm.Storage/SqliteLogStore.cs | 25 +++++++++ .../ContestDefinitionsTests.cs | 42 +++++++++++++++ tests/Nonemm.Session.Tests/FakeLogStore.cs | 6 +++ .../ContestInstanceTests.cs | 10 ++++ 17 files changed, 196 insertions(+), 15 deletions(-) create mode 100644 src/Nonemm.Session/ContestDefinitions.cs create mode 100644 src/Nonemm.Storage/ContestDefinition.cs create mode 100644 tests/Nonemm.Session.Tests/ContestDefinitionsTests.cs create mode 100644 tests/Nonemm.Storage.Tests/ContestInstanceTests.cs diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index cf74bab..0e853b9 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -92,11 +92,17 @@ public sealed class AppSession : IDisposable return stored; } + /// N1MM reads the `Contest` table when it opens a log, so the definition is + /// written again whenever a contest is opened, not only when it is created. + private void SaveDefinition(Contest contest) => + Store.SaveContestDefinition(ContestDefinitions.For(contest)); + public void OpenContest(int contestNumber) { ContestInstance instance = Store.Contest(contestNumber) ?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log"); Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance)); + SaveDefinition(contest); Logging = new LoggingSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries); Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty); Logging.Logged += (_, qso) => Bandmap.Add(new Spot( diff --git a/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml b/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml index afd60b9..9bdf57f 100644 --- a/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml +++ b/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml @@ -24,7 +24,7 @@ - + diff --git a/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs b/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs index 63c56e0..f90ee86 100644 --- a/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs +++ b/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs @@ -31,6 +31,11 @@ public sealed partial class ContestSetupDialog : Window AssistedBox.SelectedIndex = 0; TransmitterBox.ItemsSource = new[] { "ONE", "TWO", "LIMITED", "UNLIMITED", "SWL" }; TransmitterBox.SelectedIndex = 0; + OverlayBox.ItemsSource = new[] + { + "N/A", "ROOKIE", "BAND-LIMITED", "TB-WIRES", "OVER-50", "HQ", "NOVICE-TECH", "EXPERT", + }; + OverlayBox.SelectedIndex = 0; OperatorsBox.Text = session.Settings.Station.Callsign; ShowChosen(); } @@ -67,7 +72,7 @@ public sealed partial class ContestSetupDialog : Window ModeCategory = Text(ModeBox), AssistedCategory = Text(AssistedBox), TransmitterCategory = Text(TransmitterBox), - OverlayCategory = OverlayBox.Text ?? "", + OverlayCategory = Text(OverlayBox), Operators = OperatorsBox.Text ?? "", }); diff --git a/src/Nonemm.Contests/Rules/ArrlDx.cs b/src/Nonemm.Contests/Rules/ArrlDx.cs index 027ecdb..93f8e14 100644 --- a/src/Nonemm.Contests/Rules/ArrlDx.cs +++ b/src/Nonemm.Contests/Rules/ArrlDx.cs @@ -35,9 +35,7 @@ public sealed class ArrlDx : Contest public IReadOnlyList Modes => [mode]; public string SentExchangeFor(StationInfo me) => - IsNorthAmericanHome(me) - ? $"{DefaultReport()} {StateOrProvince(me)}" - : $"{DefaultReport()} {me.Power}"; + IsNorthAmericanHome(me) ? StateOrProvince(me) : me.Power; /// Only contacts across the W/VE line count, so a DX station working DX or /// a W station working W scores nothing. @@ -88,5 +86,4 @@ public sealed class ArrlDx : Contest private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB"; - private string DefaultReport() => mode == ModeCategory.Cw ? "599" : "59"; } diff --git a/src/Nonemm.Contests/Rules/CqWorldWide.cs b/src/Nonemm.Contests/Rules/CqWorldWide.cs index 606d929..b76857c 100644 --- a/src/Nonemm.Contests/Rules/CqWorldWide.cs +++ b/src/Nonemm.Contests/Rules/CqWorldWide.cs @@ -31,8 +31,9 @@ public sealed class CqWorldWide : Contest public IReadOnlyList Modes => [mode]; - public string SentExchangeFor(StationInfo me) => - $"{DefaultReport()} {me.CqZone}"; + /// The report is fixed for the whole contest, so the sent exchange is the + /// zone alone, which is also what N1MM stores. + public string SentExchangeFor(StationInfo me) => me.CqZone.ToString(); public int PointsFor(QsoContext qso) { @@ -83,5 +84,4 @@ public sealed class CqWorldWide : Contest private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB"; - private string DefaultReport() => mode == ModeCategory.Cw ? "599" : "59"; } diff --git a/src/Nonemm.Contests/Rules/CqWpx.cs b/src/Nonemm.Contests/Rules/CqWpx.cs index 8797dd3..410f116 100644 --- a/src/Nonemm.Contests/Rules/CqWpx.cs +++ b/src/Nonemm.Contests/Rules/CqWpx.cs @@ -40,7 +40,9 @@ public sealed class CqWpx : Contest public IReadOnlyList Modes => [mode]; - public string SentExchangeFor(StationInfo me) => DefaultReport(); + /// The serial number is generated per contact, so the stored exchange is + /// the number the contest starts at. + public string SentExchangeFor(StationInfo me) => "001"; public int PointsFor(QsoContext qso) { @@ -89,5 +91,4 @@ public sealed class CqWpx : Contest _ => "RTTY", }; - private string DefaultReport() => mode == ModeCategory.Phone ? "59" : "599"; } diff --git a/src/Nonemm.Contests/Rules/GeneralLogging.cs b/src/Nonemm.Contests/Rules/GeneralLogging.cs index 494b70c..19092b2 100644 --- a/src/Nonemm.Contests/Rules/GeneralLogging.cs +++ b/src/Nonemm.Contests/Rules/GeneralLogging.cs @@ -27,7 +27,7 @@ public sealed class GeneralLogging : Contest public IReadOnlyList Modes => []; - public string SentExchangeFor(StationInfo me) => "599"; + public string SentExchangeFor(StationInfo me) => ""; public int PointsFor(QsoContext qso) => 0; diff --git a/src/Nonemm.Contests/Rules/IaruHf.cs b/src/Nonemm.Contests/Rules/IaruHf.cs index 5664b76..6208ad3 100644 --- a/src/Nonemm.Contests/Rules/IaruHf.cs +++ b/src/Nonemm.Contests/Rules/IaruHf.cs @@ -26,7 +26,7 @@ public sealed class IaruHf : Contest public IReadOnlyList Modes => [ModeCategory.Cw, ModeCategory.Phone]; - public string SentExchangeFor(StationInfo me) => $"599 {me.ItuZone}"; + public string SentExchangeFor(StationInfo me) => me.ItuZone.ToString(); public int PointsFor(QsoContext qso) { diff --git a/src/Nonemm.Contests/Rules/RttyRoundup.cs b/src/Nonemm.Contests/Rules/RttyRoundup.cs index ddd3ffd..a2771f0 100644 --- a/src/Nonemm.Contests/Rules/RttyRoundup.cs +++ b/src/Nonemm.Contests/Rules/RttyRoundup.cs @@ -28,7 +28,7 @@ public sealed class RttyRoundup : Contest public IReadOnlyList Modes => [ModeCategory.Digital]; public string SentExchangeFor(StationInfo me) => - me.State.Length > 0 ? $"599 {me.State}" : "599"; + me.State.Length > 0 ? me.State : "001"; public int PointsFor(QsoContext qso) => 1; diff --git a/src/Nonemm.Session/ContestDefinitions.cs b/src/Nonemm.Session/ContestDefinitions.cs new file mode 100644 index 0000000..a08a2ca --- /dev/null +++ b/src/Nonemm.Session/ContestDefinitions.cs @@ -0,0 +1,51 @@ +using Nonemm.Contests; +using Nonemm.Core; +using Nonemm.Storage; + +namespace Nonemm.Session; + +/// Turns a contest's rules into the definition row N1MM keeps in the `Contest` +/// table, which it reads when it opens a log. +public static class ContestDefinitions +{ + public static ContestDefinition For(Contest contest) + { + IReadOnlyList mults = contest.MultiplierNames; + return new ContestDefinition + { + Name = contest.Name, + DisplayName = contest.DisplayName, + CabrilloName = contest.CabrilloName, + Mode = ModeOf(contest), + DupeType = DupeTypeOf(contest.DupeScope), + Multiplier1Name = NameAt(mults, 0), + Multiplier2Name = NameAt(mults, 1), + Multiplier3Name = NameAt(mults, 2), + }; + } + + private static string ModeOf(Contest contest) + { + if (contest.Modes.Count != 1) + { + return "BOTH"; + } + return contest.Modes[0] switch + { + ModeCategory.Cw => "CW", + ModeCategory.Phone => "SSB", + _ => "RTTY", + }; + } + + private static int DupeTypeOf(DupeScope scope) => scope switch + { + DupeScope.Once => 1, + DupeScope.PerBandAndMode => 3, + DupeScope.Never => 4, + _ => 2, + }; + + private static string NameAt(IReadOnlyList names, int at) => + at < names.Count ? names[at] : "N/A"; +} diff --git a/src/Nonemm.Storage/ContestDefinition.cs b/src/Nonemm.Storage/ContestDefinition.cs new file mode 100644 index 0000000..979e6e4 --- /dev/null +++ b/src/Nonemm.Storage/ContestDefinition.cs @@ -0,0 +1,32 @@ +namespace Nonemm.Storage; + +/// The row N1MM keeps in the `Contest` table for each contest it knows. N1MM +/// reads this row when it opens a log and throws "No current row" if the +/// contest named by the log has none, so a log written here writes it too. +public sealed record ContestDefinition +{ + public required string Name { get; init; } + + public required string DisplayName { get; init; } + + public required string CabrilloName { get; init; } + + /// `CW`, `SSB`, `RTTY` or `BOTH`. + public required string Mode { get; init; } + + /// 1 all bands, 2 each band, 3 each band and mode, 4 no check. + public int DupeType { get; init; } = 2; + + public string Multiplier1Name { get; init; } = "N/A"; + + public string Multiplier2Name { get; init; } = "N/A"; + + public string Multiplier3Name { get; init; } = "N/A"; + + /// How many days the contest runs. + public int Period { get; init; } = 2; + + public int PointsPerContact { get; init; } = 1; + + public string CabrilloVersion { get; init; } = "3.0"; +} diff --git a/src/Nonemm.Storage/ContestInstance.cs b/src/Nonemm.Storage/ContestInstance.cs index 581071f..9937550 100644 --- a/src/Nonemm.Storage/ContestInstance.cs +++ b/src/Nonemm.Storage/ContestInstance.cs @@ -22,7 +22,9 @@ public sealed record ContestInstance public string ModeCategory { get; init; } = ""; - public string OverlayCategory { get; init; } = ""; + /// N1MM rejects a contest whose overlay is empty, so an entry with no + /// overlay says so with "N/A". + public string OverlayCategory { get; init; } = "N/A"; public string StationCategory { get; init; } = ""; diff --git a/src/Nonemm.Storage/LogStore.cs b/src/Nonemm.Storage/LogStore.cs index 103b68f..6238c29 100644 --- a/src/Nonemm.Storage/LogStore.cs +++ b/src/Nonemm.Storage/LogStore.cs @@ -6,6 +6,10 @@ namespace Nonemm.Storage; /// the program talks to this so the store can move elsewhere later. public interface LogStore : IDisposable { + /// Adds or replaces the contest's definition row. N1MM will not open a log + /// whose contest has no definition. + void SaveContestDefinition(ContestDefinition definition); + IReadOnlyList Contests(); ContestInstance? Contest(int contestNumber); diff --git a/src/Nonemm.Storage/SqliteLogStore.cs b/src/Nonemm.Storage/SqliteLogStore.cs index 479a5dc..6271c65 100644 --- a/src/Nonemm.Storage/SqliteLogStore.cs +++ b/src/Nonemm.Storage/SqliteLogStore.cs @@ -29,6 +29,31 @@ public sealed class SqliteLogStore : LogStore return store; } + public void SaveContestDefinition(ContestDefinition definition) + { + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = """ + INSERT OR REPLACE INTO Contest + (Name, DisplayName, CabrilloName, Mode, DupeType, Multiplier1Name, + Multiplier2Name, Multiplier3Name, Period, PointsPerContact, CabrilloVersion) + VALUES + (@name, @display, @cabrillo, @mode, @dupe, @mult1, @mult2, @mult3, + @period, @points, @version) + """; + command.Parameters.AddWithValue("@name", definition.Name); + command.Parameters.AddWithValue("@display", definition.DisplayName); + command.Parameters.AddWithValue("@cabrillo", definition.CabrilloName); + command.Parameters.AddWithValue("@mode", definition.Mode); + command.Parameters.AddWithValue("@dupe", definition.DupeType); + command.Parameters.AddWithValue("@mult1", definition.Multiplier1Name); + command.Parameters.AddWithValue("@mult2", definition.Multiplier2Name); + command.Parameters.AddWithValue("@mult3", definition.Multiplier3Name); + command.Parameters.AddWithValue("@period", definition.Period); + command.Parameters.AddWithValue("@points", definition.PointsPerContact); + command.Parameters.AddWithValue("@version", definition.CabrilloVersion); + command.ExecuteNonQuery(); + } + public IReadOnlyList Contests() { using SqliteCommand command = connection.CreateCommand(); diff --git a/tests/Nonemm.Session.Tests/ContestDefinitionsTests.cs b/tests/Nonemm.Session.Tests/ContestDefinitionsTests.cs new file mode 100644 index 0000000..b022bc5 --- /dev/null +++ b/tests/Nonemm.Session.Tests/ContestDefinitionsTests.cs @@ -0,0 +1,42 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; +using Nonemm.Storage; + +namespace Nonemm.Session.Tests; + +public class ContestDefinitionsTests +{ + [Fact] + public void NamesAndModeComeFromTheContest() + { + ContestDefinition definition = ContestDefinitions.For(new CqWorldWide(ModeCategory.Cw)); + Assert.Equal("CQWW", definition.Name); + Assert.Equal("CQ-WW-CW", definition.CabrilloName); + Assert.Equal("CW", definition.Mode); + } + + [Fact] + public void MultiplierNamesFillUpToThree() + { + ContestDefinition definition = ContestDefinitions.For(new CqWorldWide(ModeCategory.Cw)); + Assert.Equal("Zones", definition.Multiplier1Name); + Assert.Equal("Countries", definition.Multiplier2Name); + Assert.Equal("N/A", definition.Multiplier3Name); + } + + [Theory] + [InlineData(typeof(Sweepstakes), 1)] + [InlineData(typeof(CqWorldWide), 2)] + [InlineData(typeof(IaruHf), 3)] + public void DupeScopeBecomesN1mmsDupeType(Type contest, int expected) + { + object made = contest == typeof(IaruHf) + ? new IaruHf() + : Activator.CreateInstance(contest, ModeCategory.Cw)!; + Assert.Equal(expected, ContestDefinitions.For((Contests.Contest)made).DupeType); + } + + [Fact] + public void ContestWithSeveralModesIsBoth() => + Assert.Equal("BOTH", ContestDefinitions.For(new IaruHf()).Mode); +} diff --git a/tests/Nonemm.Session.Tests/FakeLogStore.cs b/tests/Nonemm.Session.Tests/FakeLogStore.cs index 91d99ad..6670fb3 100644 --- a/tests/Nonemm.Session.Tests/FakeLogStore.cs +++ b/tests/Nonemm.Session.Tests/FakeLogStore.cs @@ -10,6 +10,12 @@ public sealed class FakeLogStore : LogStore { private readonly List qsos = []; private readonly List contests = []; + private readonly List definitions = []; + + public IReadOnlyList Definitions => definitions; + + public void SaveContestDefinition(ContestDefinition definition) => + definitions.Add(definition); public IReadOnlyList Contests() => contests; diff --git a/tests/Nonemm.Storage.Tests/ContestInstanceTests.cs b/tests/Nonemm.Storage.Tests/ContestInstanceTests.cs new file mode 100644 index 0000000..cb2fcc9 --- /dev/null +++ b/tests/Nonemm.Storage.Tests/ContestInstanceTests.cs @@ -0,0 +1,10 @@ +namespace Nonemm.Storage.Tests; + +public class ContestInstanceTests +{ + /// N1MM answers "Invalid Overlay Category:" and will not open a contest + /// whose overlay column is empty, so an entry with no overlay says "N/A". + [Fact] + public void OverlayCategoryDefaultsToNotApplicable() => + Assert.Equal("N/A", new ContestInstance { ContestNumber = 1, ContestName = "CQWW" }.OverlayCategory); +}