Open a log N1MM wrote
Three things stopped it. A contest is keyed by ContestNR, not by ContestID. ContestID is only the ContestInstance table's primary key; DXLOG.ContestNR refers to ContestNR, which is what N1MM's SQLWhereString matches on. The two agree in a fresh log and drift apart in one that has been used for a while, so we were reading one contest's header with another contest's contacts. In a real log of 56284 contacts, joining on ContestID disagreed with the contact's own ContestName 52029 times; joining on ContestNR disagreed 109 times. N1MM names a contest per mode: CQWWCW, CQWWSSB, CQWWRTTY, never plain CQWW. Our contests now use those 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 the ModeCategory column. Plain CQWW still opens, because older versions wrote it that way. The log columns were sized by counting characters, which was too narrow for the headers: those are drawn in the theme's font, not the grid's monospace. The grid sizes them now, with the character count as a floor so a column of short values does not collapse. Bandmap spots now age from when they arrived rather than from the time written in them. A node with a wrong clock, or one replaying its backlog on connect, emptied the bandmap as fast as it filled it. Checked by opening a real 56284-contact N1MM log under Xvfb and reading the contest and contacts back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ContestChoice> 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;
|
||||
}
|
||||
}
|
||||
|
||||
40
src/Nonemm.Contests/N1mmContestNames.cs
Normal file
40
src/Nonemm.Contests/N1mmContestNames.cs
Normal file
@@ -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<string, (string Family, ModeCategory Mode)> 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;
|
||||
}
|
||||
}
|
||||
@@ -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()}";
|
||||
|
||||
|
||||
@@ -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()}";
|
||||
|
||||
|
||||
@@ -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()}";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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()}";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, Spot> spots = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, DateTime> arrived = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeSpan lifetime;
|
||||
private readonly Func<DateTime> clock;
|
||||
|
||||
public Bandmap(TimeSpan? lifetime = null) => this.lifetime = lifetime ?? TimeSpan.FromHours(1);
|
||||
public Bandmap(TimeSpan? lifetime = null, Func<DateTime>? 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<string> 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)
|
||||
{
|
||||
|
||||
@@ -57,7 +57,7 @@ public sealed class SqliteLogStore : LogStore
|
||||
public IReadOnlyList<ContestInstance> 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<ContestInstance> 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"),
|
||||
|
||||
55
tests/Nonemm.Contests.Tests/N1mmContestNamesTests.cs
Normal file
55
tests/Nonemm.Contests.Tests/N1mmContestNamesTests.cs
Normal file
@@ -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<KeyNotFoundException>(() => Registry().Create("BARTGSRTTY", ModeCategory.Digital));
|
||||
|
||||
[Fact]
|
||||
public void HasFindsAContestByTheNameN1mmWrote() =>
|
||||
Assert.True(Registry().Has("CQWPXRTTY"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -142,4 +142,57 @@ public class SqliteLogStoreTests : IDisposable
|
||||
command.CommandText = "UPDATE DXLOG SET ID = 'zzzz'";
|
||||
Assert.Throws<SqliteException>(() => 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user