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 <noreply@anthropic.com>
This commit is contained in:
@@ -92,11 +92,17 @@ public sealed class AppSession : IDisposable
|
|||||||
return stored;
|
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)
|
public void OpenContest(int contestNumber)
|
||||||
{
|
{
|
||||||
ContestInstance instance = Store.Contest(contestNumber)
|
ContestInstance instance = Store.Contest(contestNumber)
|
||||||
?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log");
|
?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log");
|
||||||
Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
|
Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
|
||||||
|
SaveDefinition(contest);
|
||||||
Logging = new LoggingSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries);
|
Logging = new LoggingSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries);
|
||||||
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||||
Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
|
Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<ComboBox Name="TransmitterBox" Grid.Row="5" Grid.Column="2" HorizontalAlignment="Stretch" />
|
<ComboBox Name="TransmitterBox" Grid.Row="5" Grid.Column="2" HorizontalAlignment="Stretch" />
|
||||||
|
|
||||||
<TextBlock Grid.Row="6" Text="Overlay" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
<TextBlock Grid.Row="6" Text="Overlay" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
||||||
<TextBox Name="OverlayBox" Grid.Row="7" />
|
<ComboBox Name="OverlayBox" Grid.Row="7" HorizontalAlignment="Stretch" />
|
||||||
<TextBlock Grid.Row="6" Grid.Column="2" Text="Operators" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
<TextBlock Grid.Row="6" Grid.Column="2" Text="Operators" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
||||||
<TextBox Name="OperatorsBox" Grid.Row="7" Grid.Column="2" />
|
<TextBox Name="OperatorsBox" Grid.Row="7" Grid.Column="2" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ public sealed partial class ContestSetupDialog : Window
|
|||||||
AssistedBox.SelectedIndex = 0;
|
AssistedBox.SelectedIndex = 0;
|
||||||
TransmitterBox.ItemsSource = new[] { "ONE", "TWO", "LIMITED", "UNLIMITED", "SWL" };
|
TransmitterBox.ItemsSource = new[] { "ONE", "TWO", "LIMITED", "UNLIMITED", "SWL" };
|
||||||
TransmitterBox.SelectedIndex = 0;
|
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;
|
OperatorsBox.Text = session.Settings.Station.Callsign;
|
||||||
ShowChosen();
|
ShowChosen();
|
||||||
}
|
}
|
||||||
@@ -67,7 +72,7 @@ public sealed partial class ContestSetupDialog : Window
|
|||||||
ModeCategory = Text(ModeBox),
|
ModeCategory = Text(ModeBox),
|
||||||
AssistedCategory = Text(AssistedBox),
|
AssistedCategory = Text(AssistedBox),
|
||||||
TransmitterCategory = Text(TransmitterBox),
|
TransmitterCategory = Text(TransmitterBox),
|
||||||
OverlayCategory = OverlayBox.Text ?? "",
|
OverlayCategory = Text(OverlayBox),
|
||||||
Operators = OperatorsBox.Text ?? "",
|
Operators = OperatorsBox.Text ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,7 @@ public sealed class ArrlDx : Contest
|
|||||||
public IReadOnlyList<ModeCategory> Modes => [mode];
|
public IReadOnlyList<ModeCategory> Modes => [mode];
|
||||||
|
|
||||||
public string SentExchangeFor(StationInfo me) =>
|
public string SentExchangeFor(StationInfo me) =>
|
||||||
IsNorthAmericanHome(me)
|
IsNorthAmericanHome(me) ? StateOrProvince(me) : me.Power;
|
||||||
? $"{DefaultReport()} {StateOrProvince(me)}"
|
|
||||||
: $"{DefaultReport()} {me.Power}";
|
|
||||||
|
|
||||||
/// Only contacts across the W/VE line count, so a DX station working DX or
|
/// Only contacts across the W/VE line count, so a DX station working DX or
|
||||||
/// a W station working W scores nothing.
|
/// 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 ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB";
|
||||||
|
|
||||||
private string DefaultReport() => mode == ModeCategory.Cw ? "599" : "59";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ public sealed class CqWorldWide : Contest
|
|||||||
|
|
||||||
public IReadOnlyList<ModeCategory> Modes => [mode];
|
public IReadOnlyList<ModeCategory> Modes => [mode];
|
||||||
|
|
||||||
public string SentExchangeFor(StationInfo me) =>
|
/// The report is fixed for the whole contest, so the sent exchange is the
|
||||||
$"{DefaultReport()} {me.CqZone}";
|
/// zone alone, which is also what N1MM stores.
|
||||||
|
public string SentExchangeFor(StationInfo me) => me.CqZone.ToString();
|
||||||
|
|
||||||
public int PointsFor(QsoContext qso)
|
public int PointsFor(QsoContext qso)
|
||||||
{
|
{
|
||||||
@@ -83,5 +84,4 @@ public sealed class CqWorldWide : Contest
|
|||||||
|
|
||||||
private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB";
|
private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB";
|
||||||
|
|
||||||
private string DefaultReport() => mode == ModeCategory.Cw ? "599" : "59";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ public sealed class CqWpx : Contest
|
|||||||
|
|
||||||
public IReadOnlyList<ModeCategory> Modes => [mode];
|
public IReadOnlyList<ModeCategory> 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)
|
public int PointsFor(QsoContext qso)
|
||||||
{
|
{
|
||||||
@@ -89,5 +91,4 @@ public sealed class CqWpx : Contest
|
|||||||
_ => "RTTY",
|
_ => "RTTY",
|
||||||
};
|
};
|
||||||
|
|
||||||
private string DefaultReport() => mode == ModeCategory.Phone ? "59" : "599";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public sealed class GeneralLogging : Contest
|
|||||||
|
|
||||||
public IReadOnlyList<ModeCategory> Modes => [];
|
public IReadOnlyList<ModeCategory> Modes => [];
|
||||||
|
|
||||||
public string SentExchangeFor(StationInfo me) => "599";
|
public string SentExchangeFor(StationInfo me) => "";
|
||||||
|
|
||||||
public int PointsFor(QsoContext qso) => 0;
|
public int PointsFor(QsoContext qso) => 0;
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public sealed class IaruHf : Contest
|
|||||||
|
|
||||||
public IReadOnlyList<ModeCategory> Modes => [ModeCategory.Cw, ModeCategory.Phone];
|
public IReadOnlyList<ModeCategory> 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)
|
public int PointsFor(QsoContext qso)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public sealed class RttyRoundup : Contest
|
|||||||
public IReadOnlyList<ModeCategory> Modes => [ModeCategory.Digital];
|
public IReadOnlyList<ModeCategory> Modes => [ModeCategory.Digital];
|
||||||
|
|
||||||
public string SentExchangeFor(StationInfo me) =>
|
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;
|
public int PointsFor(QsoContext qso) => 1;
|
||||||
|
|
||||||
|
|||||||
51
src/Nonemm.Session/ContestDefinitions.cs
Normal file
51
src/Nonemm.Session/ContestDefinitions.cs
Normal file
@@ -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<string> 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<string> names, int at) =>
|
||||||
|
at < names.Count ? names[at] : "N/A";
|
||||||
|
}
|
||||||
32
src/Nonemm.Storage/ContestDefinition.cs
Normal file
32
src/Nonemm.Storage/ContestDefinition.cs
Normal file
@@ -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";
|
||||||
|
}
|
||||||
@@ -22,7 +22,9 @@ public sealed record ContestInstance
|
|||||||
|
|
||||||
public string ModeCategory { get; init; } = "";
|
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; } = "";
|
public string StationCategory { get; init; } = "";
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ namespace Nonemm.Storage;
|
|||||||
/// the program talks to this so the store can move elsewhere later.
|
/// the program talks to this so the store can move elsewhere later.
|
||||||
public interface LogStore : IDisposable
|
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<ContestInstance> Contests();
|
IReadOnlyList<ContestInstance> Contests();
|
||||||
|
|
||||||
ContestInstance? Contest(int contestNumber);
|
ContestInstance? Contest(int contestNumber);
|
||||||
|
|||||||
@@ -29,6 +29,31 @@ public sealed class SqliteLogStore : LogStore
|
|||||||
return store;
|
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<ContestInstance> Contests()
|
public IReadOnlyList<ContestInstance> Contests()
|
||||||
{
|
{
|
||||||
using SqliteCommand command = connection.CreateCommand();
|
using SqliteCommand command = connection.CreateCommand();
|
||||||
|
|||||||
42
tests/Nonemm.Session.Tests/ContestDefinitionsTests.cs
Normal file
42
tests/Nonemm.Session.Tests/ContestDefinitionsTests.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ public sealed class FakeLogStore : LogStore
|
|||||||
{
|
{
|
||||||
private readonly List<Qso> qsos = [];
|
private readonly List<Qso> qsos = [];
|
||||||
private readonly List<ContestInstance> contests = [];
|
private readonly List<ContestInstance> contests = [];
|
||||||
|
private readonly List<ContestDefinition> definitions = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<ContestDefinition> Definitions => definitions;
|
||||||
|
|
||||||
|
public void SaveContestDefinition(ContestDefinition definition) =>
|
||||||
|
definitions.Add(definition);
|
||||||
|
|
||||||
public IReadOnlyList<ContestInstance> Contests() => contests;
|
public IReadOnlyList<ContestInstance> Contests() => contests;
|
||||||
|
|
||||||
|
|||||||
10
tests/Nonemm.Storage.Tests/ContestInstanceTests.cs
Normal file
10
tests/Nonemm.Storage.Tests/ContestInstanceTests.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user