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:
2026-08-27 16:27:35 +00:00
parent b9f0166777
commit da9884ebf5
17 changed files with 315 additions and 34 deletions

View 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"));
}

View File

@@ -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);
}

View File

@@ -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()
{

View File

@@ -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();
}
}