diff --git a/docs/n1mm-interop.md b/docs/n1mm-interop.md index 7b67fe8..9c47296 100644 --- a/docs/n1mm-interop.md +++ b/docs/n1mm-interop.md @@ -52,3 +52,30 @@ leaves behind. Do not replace the database file under a running N1MM. It does not notice and throws on the next read. + +## A contact belongs to a ContestNR, not to the table key + +`ContestInstance` has two numbers: `ContestID`, the table's primary key, and +`ContestNR`. `DXLOG.ContestNR` refers to the second one. N1MM's +`ContestInstance.SQLWhereString` reads: + + " ContestNR = " + this.ContestNR + " " + +In a fresh log the two numbers match, so keying on either works. In a log N1MM +has been using for a while they drift apart. In one real log of 56284 contacts, +joining `DXLOG.ContestNR` to `ContestInstance.ContestID` disagreed with the +contact's own `ContestName` 52029 times; joining it to `ContestInstance.ContestNR` +disagreed 109 times. + +So `ContestInstance.ContestNumber` here is `ContestNR`, and `ContestID` is +allocated separately when a contest is created. + +## N1MM names a contest per mode + +CQ WW is `CQWWCW`, `CQWWSSB` or `CQWWRTTY`, never plain `CQWW`. The full list +lives in the `Contest` table of any N1MM database. Nonemm writes the same names, +and `N1mmContestNames` turns one back into a contest and a mode when a log is +opened. The name carries the mode, so it beats whatever `ModeCategory` says. + +Older N1MM versions wrote the CW running of CQ WW as plain `CQWW`. That name is +still in the registry, so those logs open. diff --git a/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs b/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs index f90ee86..0050141 100644 --- a/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs +++ b/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs @@ -63,7 +63,7 @@ public sealed partial class ContestSetupDialog : Window private void OnStart(object? sender, RoutedEventArgs e) => Close(new ContestInstance { ContestNumber = 0, - ContestName = Chosen.Name, + ContestName = Chosen.Create(ModeOf()).Name, StartDate = DateTime.UtcNow, SentExchange = ExchangeBox.Text ?? "", OperatorCategory = Text(OperatorBox), diff --git a/src/Nonemm.App/Windows/LogWindow.axaml.cs b/src/Nonemm.App/Windows/LogWindow.axaml.cs index b924823..07e7f65 100644 --- a/src/Nonemm.App/Windows/LogWindow.axaml.cs +++ b/src/Nonemm.App/Windows/LogWindow.axaml.cs @@ -13,7 +13,10 @@ namespace Nonemm.App.Windows; public sealed partial class LogWindow : RefreshableWindow { /// Roughly the width of one character of the grid font, in device pixels. - private const double CharacterWidth = 8; + private const double CharacterWidth = 7.5; + + /// What a cell takes either side of its text. + private const double CellPadding = 20; private readonly AppSession session; private string builtFor = ""; @@ -75,7 +78,8 @@ public sealed partial class LogWindow : RefreshableWindow Rows.Columns.Add(new DataGridTextColumn { Header = column.Label, - Width = new DataGridLength(column.Width * CharacterWidth), + Width = DataGridLength.Auto, + MinWidth = MinimumFor(column.Width), Binding = new Binding($"[{column.Field}]") { Mode = BindingMode.TwoWay }, }); } @@ -84,15 +88,23 @@ public sealed partial class LogWindow : RefreshableWindow Rows.Columns.Add(ReadOnlyColumn("Mult", nameof(LogRow.Multipliers), 5)); } - private static DataGridTextColumn ReadOnlyColumn(string header, string property, int characters) => + private DataGridTextColumn ReadOnlyColumn(string header, string property, int characters) => new() { Header = header, IsReadOnly = true, - Width = new DataGridLength(characters * CharacterWidth), + Width = DataGridLength.Auto, + MinWidth = MinimumFor(characters), Binding = new Binding(property), }; + /// The grid sizes a column to the wider of its header and the values it can + /// see. The header is in the theme's own font, not the grid's monospace, so + /// counting characters gets it wrong; the count is only a floor, so a + /// column of short values does not collapse. + private static double MinimumFor(int characters) => + (characters * CharacterWidth) + CellPadding; + /// True when the edit was taken. A refused edit puts the reason in the /// summary line and the cell falls back to what it held before. private bool Edit(Qso qso, QsoField field, string text) diff --git a/src/Nonemm.Contests/ContestRegistry.cs b/src/Nonemm.Contests/ContestRegistry.cs index 103867d..839039f 100644 --- a/src/Nonemm.Contests/ContestRegistry.cs +++ b/src/Nonemm.Contests/ContestRegistry.cs @@ -13,7 +13,7 @@ public sealed class ContestRegistry new("CQWW", "CQ World Wide DX", [ModeCategory.Cw, ModeCategory.Phone], m => new CqWorldWide(m)), new("CQWPX", "CQ WPX", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new CqWpx(m)), new("ARRLDX", "ARRL International DX", [ModeCategory.Cw, ModeCategory.Phone], m => new ArrlDx(m)), - new("IARUHF", "IARU HF World Championship", [ModeCategory.Cw, ModeCategory.Phone], _ => new IaruHf()), + new("IARU", "IARU HF World Championship", [ModeCategory.Cw, ModeCategory.Phone], _ => new IaruHf()), new("SS", "ARRL Sweepstakes", [ModeCategory.Cw, ModeCategory.Phone], m => new Sweepstakes(m)), new("ARRLRTTY", "ARRL RTTY Roundup", [ModeCategory.Digital], _ => new RttyRoundup()), new("NAQP", "North American QSO Party", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new NorthAmericanQsoParty(m)), @@ -68,10 +68,33 @@ public sealed class ContestRegistry public IReadOnlyList Choices => byName.Values.OrderBy(c => c.DisplayName, StringComparer.Ordinal).ToList(); - public Contest Create(string name, ModeCategory mode) => - byName.TryGetValue(name, out ContestChoice? choice) - ? choice.Create(mode) - : throw new KeyNotFoundException($"no contest named '{name}'"); + public Contest Create(string name, ModeCategory mode) + { + ContestChoice choice = Find(name, ref mode) + ?? throw new KeyNotFoundException($"no contest named '{name}'"); + return choice.Create(mode); + } - public bool Has(string name) => byName.ContainsKey(name); + public bool Has(string name) + { + ModeCategory ignored = ModeCategory.Cw; + return Find(name, ref ignored) is not null; + } + + /// A name N1MM wrote carries the mode, and that is better than whatever the + /// log's mode column says, so it wins when the name resolves that way. + private ContestChoice? Find(string name, ref ModeCategory mode) + { + if (byName.TryGetValue(name, out ContestChoice? choice)) + { + return choice; + } + if (N1mmContestNames.TryResolve(name, out string family, out ModeCategory named) + && byName.TryGetValue(family, out ContestChoice? resolved)) + { + mode = named; + return resolved; + } + return null; + } } diff --git a/src/Nonemm.Contests/N1mmContestNames.cs b/src/Nonemm.Contests/N1mmContestNames.cs new file mode 100644 index 0000000..4dea443 --- /dev/null +++ b/src/Nonemm.Contests/N1mmContestNames.cs @@ -0,0 +1,40 @@ +using Nonemm.Core; + +namespace Nonemm.Contests; + +/// N1MM names a contest per mode: CQ WW is `CQWWCW`, `CQWWSSB` or `CQWWRTTY`, +/// never plain `CQWW`. Our registry is keyed by the family, so a log written by +/// N1MM has to have its contest name turned back into a family and a mode +/// before the rules can be built. +public static class N1mmContestNames +{ + private static readonly Dictionary Known = + new(StringComparer.OrdinalIgnoreCase) + { + ["CQWWCW"] = ("CQWW", ModeCategory.Cw), + ["CQWWSSB"] = ("CQWW", ModeCategory.Phone), + ["CQWWRTTY"] = ("CQWW", ModeCategory.Digital), + ["CQWPXCW"] = ("CQWPX", ModeCategory.Cw), + ["CQWPXSSB"] = ("CQWPX", ModeCategory.Phone), + ["CQWPXRTTY"] = ("CQWPX", ModeCategory.Digital), + ["ARRLDXCW"] = ("ARRLDX", ModeCategory.Cw), + ["ARRLDXSSB"] = ("ARRLDX", ModeCategory.Phone), + ["SSCW"] = ("SS", ModeCategory.Cw), + ["SSSSB"] = ("SS", ModeCategory.Phone), + ["NAQPCW"] = ("NAQP", ModeCategory.Cw), + ["NAQPSSB"] = ("NAQP", ModeCategory.Phone), + ["NAQPRTTY"] = ("NAQP", ModeCategory.Digital), + }; + + public static bool TryResolve(string name, out string family, out ModeCategory mode) + { + if (Known.TryGetValue(name.Trim(), out (string Family, ModeCategory Mode) found)) + { + (family, mode) = found; + return true; + } + family = ""; + mode = ModeCategory.Cw; + return false; + } +} diff --git a/src/Nonemm.Contests/Rules/ArrlDx.cs b/src/Nonemm.Contests/Rules/ArrlDx.cs index 93f8e14..6396759 100644 --- a/src/Nonemm.Contests/Rules/ArrlDx.cs +++ b/src/Nonemm.Contests/Rules/ArrlDx.cs @@ -12,7 +12,7 @@ public sealed class ArrlDx : Contest public ArrlDx(ModeCategory mode) => this.mode = mode; - public string Name => "ARRLDX"; + public string Name => mode == ModeCategory.Cw ? "ARRLDXCW" : "ARRLDXSSB"; public string DisplayName => $"ARRL International DX {ModeLabel()}"; diff --git a/src/Nonemm.Contests/Rules/CqWorldWide.cs b/src/Nonemm.Contests/Rules/CqWorldWide.cs index b76857c..c217c5d 100644 --- a/src/Nonemm.Contests/Rules/CqWorldWide.cs +++ b/src/Nonemm.Contests/Rules/CqWorldWide.cs @@ -11,7 +11,12 @@ public sealed class CqWorldWide : Contest public CqWorldWide(ModeCategory mode) => this.mode = mode; - public string Name => "CQWW"; + public string Name => mode switch + { + ModeCategory.Phone => "CQWWSSB", + ModeCategory.Digital => "CQWWRTTY", + _ => "CQWWCW", + }; public string DisplayName => $"CQ World Wide DX {ModeLabel()}"; diff --git a/src/Nonemm.Contests/Rules/CqWpx.cs b/src/Nonemm.Contests/Rules/CqWpx.cs index 410f116..0d6a38d 100644 --- a/src/Nonemm.Contests/Rules/CqWpx.cs +++ b/src/Nonemm.Contests/Rules/CqWpx.cs @@ -15,7 +15,12 @@ public sealed class CqWpx : Contest public CqWpx(ModeCategory mode) => this.mode = mode; - public string Name => "CQWPX"; + public string Name => mode switch + { + ModeCategory.Phone => "CQWPXSSB", + ModeCategory.Digital => "CQWPXRTTY", + _ => "CQWPXCW", + }; public string DisplayName => $"CQ WPX {ModeLabel()}"; diff --git a/src/Nonemm.Contests/Rules/IaruHf.cs b/src/Nonemm.Contests/Rules/IaruHf.cs index 6208ad3..0aad1ff 100644 --- a/src/Nonemm.Contests/Rules/IaruHf.cs +++ b/src/Nonemm.Contests/Rules/IaruHf.cs @@ -6,7 +6,7 @@ namespace Nonemm.Contests.Rules; /// headquarters station, its abbreviation; both count as multipliers per band. public sealed class IaruHf : Contest { - public string Name => "IARUHF"; + public string Name => "IARU"; public string DisplayName => "IARU HF World Championship"; diff --git a/src/Nonemm.Contests/Rules/Sweepstakes.cs b/src/Nonemm.Contests/Rules/Sweepstakes.cs index f4ba39f..f854e9f 100644 --- a/src/Nonemm.Contests/Rules/Sweepstakes.cs +++ b/src/Nonemm.Contests/Rules/Sweepstakes.cs @@ -11,7 +11,7 @@ public sealed class Sweepstakes : Contest public Sweepstakes(ModeCategory mode) => this.mode = mode; - public string Name => "SS"; + public string Name => mode == ModeCategory.Cw ? "SSCW" : "SSSSB"; public string DisplayName => $"ARRL Sweepstakes {ModeLabel()}"; diff --git a/src/Nonemm.Session/QsoEditor.cs b/src/Nonemm.Session/QsoEditor.cs index 7e9008d..715b9cd 100644 --- a/src/Nonemm.Session/QsoEditor.cs +++ b/src/Nonemm.Session/QsoEditor.cs @@ -241,7 +241,7 @@ public sealed class QsoEditor { QsoField mapped = FieldFor(field.Slot); kinds[mapped] = field.Kind; - columns.Add(new QsoColumn(field.Label, mapped, Math.Max(field.Width, field.Label.Length))); + columns.Add(new QsoColumn(field.Label, mapped, Math.Max(field.Width, field.Label.Length + 1))); } columns.Add(new QsoColumn("Op", QsoField.Operator, 8)); return columns; diff --git a/src/Nonemm.Spotting/Bandmap.cs b/src/Nonemm.Spotting/Bandmap.cs index d6ee40c..40b35f9 100644 --- a/src/Nonemm.Spotting/Bandmap.cs +++ b/src/Nonemm.Spotting/Bandmap.cs @@ -5,24 +5,38 @@ namespace Nonemm.Spotting; /// The stations on the band, in frequency order. A spot ages out after an hour /// because a bandmap is a picture of the last hour, and a stale spot costs a /// move to an empty frequency. +/// +/// Age is counted from when the spot arrived, not from the time written in it. +/// A node with a wrong clock, or one replaying its backlog, would otherwise +/// empty the bandmap as fast as it filled it. public sealed class Bandmap { private readonly Dictionary spots = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary arrived = new(StringComparer.OrdinalIgnoreCase); private readonly TimeSpan lifetime; + private readonly Func clock; - public Bandmap(TimeSpan? lifetime = null) => this.lifetime = lifetime ?? TimeSpan.FromHours(1); + public Bandmap(TimeSpan? lifetime = null, Func? clock = null) + { + this.lifetime = lifetime ?? TimeSpan.FromHours(1); + this.clock = clock ?? (() => DateTime.UtcNow); + } public event EventHandler? Changed; public void Add(Spot spot) { - spots[Key(spot)] = spot; + string key = Key(spot); + spots[key] = spot; + arrived[key] = clock(); Changed?.Invoke(this, EventArgs.Empty); } public void Remove(Callsign call, Band band) { - if (spots.Remove($"{call.Text}|{band.Name}")) + string key = $"{call.Text}|{band.Name}"; + arrived.Remove(key); + if (spots.Remove(key)) { Changed?.Invoke(this, EventArgs.Empty); } @@ -31,12 +45,13 @@ public sealed class Bandmap public void DropOlderThan(DateTime nowUtc) { List stale = spots - .Where(pair => nowUtc - pair.Value.AtUtc > lifetime) + .Where(pair => nowUtc - arrived.GetValueOrDefault(pair.Key, nowUtc) > lifetime) .Select(pair => pair.Key) .ToList(); foreach (string key in stale) { spots.Remove(key); + arrived.Remove(key); } if (stale.Count > 0) { diff --git a/src/Nonemm.Storage/SqliteLogStore.cs b/src/Nonemm.Storage/SqliteLogStore.cs index 6271c65..b0d6ccb 100644 --- a/src/Nonemm.Storage/SqliteLogStore.cs +++ b/src/Nonemm.Storage/SqliteLogStore.cs @@ -57,7 +57,7 @@ public sealed class SqliteLogStore : LogStore public IReadOnlyList Contests() { using SqliteCommand command = connection.CreateCommand(); - command.CommandText = "SELECT * FROM ContestInstance ORDER BY ContestID"; + command.CommandText = "SELECT * FROM ContestInstance ORDER BY ContestNR, ContestID"; using SqliteDataReader row = command.ExecuteReader(); List found = []; while (row.Read()) @@ -70,15 +70,17 @@ public sealed class SqliteLogStore : LogStore public ContestInstance? Contest(int contestNumber) { using SqliteCommand command = connection.CreateCommand(); - command.CommandText = "SELECT * FROM ContestInstance WHERE ContestID = @id"; - command.Parameters.AddWithValue("@id", contestNumber); + command.CommandText = + "SELECT * FROM ContestInstance WHERE COALESCE(ContestNR, ContestID) = @nr ORDER BY ContestID"; + command.Parameters.AddWithValue("@nr", contestNumber); using SqliteDataReader row = command.ExecuteReader(); return row.Read() ? ReadContest(row) : null; } public ContestInstance AddContest(ContestInstance instance) { - ContestInstance stored = instance with { ContestNumber = NextContestNumber() }; + ContestInstance stored = instance with { ContestNumber = Next("ContestNR") }; + int contestId = Next("ContestID"); using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ INSERT INTO ContestInstance @@ -87,9 +89,10 @@ public sealed class SqliteLogStore : LogStore Soapbox, SentExchange, ContestNR, SubType, StationCategory, AssistedCategory, TransmitterCategory, TimeCategory) VALUES - (@id, @name, @start, @op, @band, @power, @mode, @overlay, @score, @ops, + (@contestId, @name, @start, @op, @band, @power, @mode, @overlay, @score, @ops, @soapbox, @sent, @id, @subtype, @station, @assisted, @tx, @time) """; + command.Parameters.AddWithValue("@contestId", contestId); BindContest(command, stored); command.ExecuteNonQuery(); return stored; @@ -106,7 +109,7 @@ public sealed class SqliteLogStore : LogStore Soapbox = @soapbox, SentExchange = @sent, SubType = @subtype, StationCategory = @station, AssistedCategory = @assisted, TransmitterCategory = @tx, TimeCategory = @time - WHERE ContestID = @id + WHERE COALESCE(ContestNR, ContestID) = @id """; BindContest(command, instance); if (command.ExecuteNonQuery() == 0) @@ -184,10 +187,10 @@ public sealed class SqliteLogStore : LogStore command.ExecuteNonQuery(); } - private int NextContestNumber() + private int Next(string column) { using SqliteCommand command = connection.CreateCommand(); - command.CommandText = "SELECT COALESCE(MAX(ContestID), 0) + 1 FROM ContestInstance"; + command.CommandText = $"SELECT COALESCE(MAX({column}), 0) + 1 FROM ContestInstance"; return (int)(long)(command.ExecuteScalar() ?? 1L); } @@ -231,9 +234,20 @@ public sealed class SqliteLogStore : LogStore command.Parameters.AddWithValue("@time", instance.TimeCategory); } + /// A row written before N1MM filled ContestNR in falls back to the key. + private static int NumberOf(SqliteDataReader row) + { + int at = row.GetOrdinal("ContestNR"); + return row.IsDBNull(at) + ? (int)row.GetInt64(row.GetOrdinal("ContestID")) + : (int)row.GetInt64(at); + } + private static ContestInstance ReadContest(SqliteDataReader row) => new() { - ContestNumber = (int)row.GetInt64(row.GetOrdinal("ContestID")), + // N1MM matches a contact to its contest on ContestNR, not on the table's + // own key, and the two differ in any log N1MM has been using for a while + ContestNumber = NumberOf(row), ContestName = TextOf(row, "ContestName"), StartDate = DateTime.TryParse( TextOf(row, "StartDate"), diff --git a/tests/Nonemm.Contests.Tests/N1mmContestNamesTests.cs b/tests/Nonemm.Contests.Tests/N1mmContestNamesTests.cs new file mode 100644 index 0000000..ad7ebf6 --- /dev/null +++ b/tests/Nonemm.Contests.Tests/N1mmContestNamesTests.cs @@ -0,0 +1,55 @@ +using Nonemm.Contests; +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +public class N1mmContestNamesTests +{ + private static ContestRegistry Registry() => ContestRegistry.Create(); + + [Theory] + [InlineData("CQWWCW", ModeCategory.Cw)] + [InlineData("CQWWSSB", ModeCategory.Phone)] + [InlineData("CQWWRTTY", ModeCategory.Digital)] + [InlineData("ARRLDXCW", ModeCategory.Cw)] + [InlineData("SSSSB", ModeCategory.Phone)] + public void ANameN1mmWroteResolvesToAContestAndAMode(string name, ModeCategory mode) + { + Assert.True(N1mmContestNames.TryResolve(name, out _, out ModeCategory found)); + Assert.Equal(mode, found); + } + + /// The log's mode column can be wrong or say MIXED; the name N1MM wrote + /// carries the mode itself, so it wins. + [Fact] + public void TheModeInTheNameBeatsTheModePassedIn() + { + Contest contest = Registry().Create("CQWWSSB", ModeCategory.Cw); + + Assert.Equal("CQWWSSB", contest.Name); + Assert.Equal("CQ-WW-SSB", contest.CabrilloName); + } + + /// Older N1MM versions wrote the CW running of CQ WW as plain CQWW. + [Fact] + public void TheOldPlainNameStillOpens() => + Assert.Equal("CQWWCW", Registry().Create("CQWW", ModeCategory.Cw).Name); + + [Fact] + public void OurOwnContestsNameThemselvesTheWayN1mmDoes() + { + ContestRegistry registry = Registry(); + + Assert.Equal("ARRLDXSSB", registry.Create("ARRLDX", ModeCategory.Phone).Name); + Assert.Equal("IARU", registry.Create("IARU", ModeCategory.Cw).Name); + Assert.Equal("NAQPRTTY", registry.Create("NAQP", ModeCategory.Digital).Name); + } + + [Fact] + public void AContestNeitherProgramKnowsIsStillAnError() => + Assert.Throws(() => Registry().Create("BARTGSRTTY", ModeCategory.Digital)); + + [Fact] + public void HasFindsAContestByTheNameN1mmWrote() => + Assert.True(Registry().Has("CQWPXRTTY")); +} diff --git a/tests/Nonemm.Session.Tests/ContestDefinitionsTests.cs b/tests/Nonemm.Session.Tests/ContestDefinitionsTests.cs index b022bc5..dd7c798 100644 --- a/tests/Nonemm.Session.Tests/ContestDefinitionsTests.cs +++ b/tests/Nonemm.Session.Tests/ContestDefinitionsTests.cs @@ -10,7 +10,7 @@ public class ContestDefinitionsTests public void NamesAndModeComeFromTheContest() { ContestDefinition definition = ContestDefinitions.For(new CqWorldWide(ModeCategory.Cw)); - Assert.Equal("CQWW", definition.Name); + Assert.Equal("CQWWCW", definition.Name); Assert.Equal("CQ-WW-CW", definition.CabrilloName); Assert.Equal("CW", definition.Mode); } diff --git a/tests/Nonemm.Spotting.Tests/BandmapTests.cs b/tests/Nonemm.Spotting.Tests/BandmapTests.cs index 223bc00..314e411 100644 --- a/tests/Nonemm.Spotting.Tests/BandmapTests.cs +++ b/tests/Nonemm.Spotting.Tests/BandmapTests.cs @@ -40,13 +40,45 @@ public class BandmapTests [Fact] public void SpotsOlderThanAnHourAreDropped() { - Bandmap map = new(); - map.Add(At("DL1ABC", 14_010, Now.AddHours(-2))); - map.Add(At("JA1XYZ", 14_020, Now.AddMinutes(-5))); + DateTime arriving = Now.AddHours(-2); + Bandmap map = new(clock: () => arriving); + map.Add(At("DL1ABC", 14_010)); + arriving = Now.AddMinutes(-5); + map.Add(At("JA1XYZ", 14_020)); + map.DropOlderThan(Now); + Assert.Equal("JA1XYZ", Assert.Single(map.All()).Call.Text); } + /// Nodes get their clocks wrong and replay their backlog on connect. Age is + /// counted from when the spot arrived, so a spot stamped hours ago still + /// shows for the hour after it turned up. + [Fact] + public void ASpotStampedLongAgoIsKeptIfItOnlyJustArrived() + { + Bandmap map = new(clock: () => Now); + map.Add(At("DL1ABC", 14_010, Now.AddHours(-4))); + + map.DropOlderThan(Now); + + Assert.Single(map.All()); + } + + [Fact] + public void ARespottedStationStartsItsHourAgain() + { + DateTime arriving = Now.AddMinutes(-50); + Bandmap map = new(clock: () => arriving); + map.Add(At("DL1ABC", 14_010)); + arriving = Now; + map.Add(At("DL1ABC", 14_012)); + + map.DropOlderThan(Now.AddMinutes(30)); + + Assert.Single(map.All()); + } + [Fact] public void TheNearestSpotInsideTheWindowIsFound() { diff --git a/tests/Nonemm.Storage.Tests/SqliteLogStoreTests.cs b/tests/Nonemm.Storage.Tests/SqliteLogStoreTests.cs index 2699ea2..b99372c 100644 --- a/tests/Nonemm.Storage.Tests/SqliteLogStoreTests.cs +++ b/tests/Nonemm.Storage.Tests/SqliteLogStoreTests.cs @@ -142,4 +142,57 @@ public class SqliteLogStoreTests : IDisposable command.CommandText = "UPDATE DXLOG SET ID = 'zzzz'"; Assert.Throws(() => command.ExecuteNonQuery()); } + + /// N1MM matches a contact to its contest on ContestNR. ContestID is only the + /// table's key, and in a log N1MM has been using for a while the two have + /// drifted apart, so keying on ContestID hands back another contest's log. + [Fact] + public void AContestIsFoundByContestNrNotByTheTableKey() + { + using (SqliteLogStore store = SqliteLogStore.Open(path)) + { + Execute(""" + INSERT INTO ContestInstance (ContestID, ContestName, ContestNR, ModeCategory) + VALUES (9, 'CQWWSSB', 7, 'SSB') + """); + store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc), 7)); + store.Add(Contact("JA1XYZ", new DateTime(2026, 5, 30, 12, 1, 0, DateTimeKind.Utc), 9)); + } + + using SqliteLogStore reopened = SqliteLogStore.Open(path); + ContestInstance? found = reopened.Contest(7); + + Assert.Equal("CQWWSSB", found?.ContestName); + Assert.Equal(7, found?.ContestNumber); + Assert.Equal(["DL1ABC"], reopened.Qsos(7).Select(q => q.Call.Text)); + Assert.Null(reopened.Contest(9)); + } + + [Fact] + public void ANewContestGetsANumberOfItsOwn() + { + using SqliteLogStore store = SqliteLogStore.Open(path); + Execute(""" + INSERT INTO ContestInstance (ContestID, ContestName, ContestNR, ModeCategory) + VALUES (9, 'CQWWSSB', 7, 'SSB') + """); + + ContestInstance added = store.AddContest(new ContestInstance + { + ContestNumber = 0, + ContestName = "CQWWCW", + }); + + Assert.Equal(8, added.ContestNumber); + Assert.Equal("CQWWCW", store.Contest(8)?.ContestName); + } + + private void Execute(string sql) + { + using SqliteConnection connection = new($"Data Source={path}"); + connection.Open(); + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } }