Files
Nonemm/tests/Nonemm.Storage.Tests/SqliteLogStoreTests.cs
ericek111 da9884ebf5 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>
2026-08-27 16:27:35 +00:00

199 lines
7.1 KiB
C#

using Microsoft.Data.Sqlite;
using Nonemm.Core;
namespace Nonemm.Storage.Tests;
public class SqliteLogStoreTests : IDisposable
{
private readonly string path = Path.Combine(Path.GetTempPath(), $"nonemm-{Guid.NewGuid():N}.s3db");
public void Dispose()
{
SqliteConnection.ClearAllPools();
File.Delete(path);
GC.SuppressFinalize(this);
}
private static Qso Contact(string call, DateTime at, int contestNumber = 1) => new()
{
Id = Qso.NewId(),
TimestampUtc = at,
Call = Callsign.Parse(call),
Frequency = Frequency.FromKilohertz(14_025.5),
Mode = Modes.Cw,
ContestName = "CQWW",
ContestNumber = contestNumber,
SentReport = "599",
ReceivedReport = "599",
Zone = 14,
Points = 3,
IsMultiplier1 = true,
CountryPrefix = "DL",
Continent = "EU",
WpxPrefix = "DL1",
};
[Fact]
public void ContactSurvivesARoundTrip()
{
DateTime at = new(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc);
using SqliteLogStore store = SqliteLogStore.Open(path);
Qso written = store.Add(Contact("DL1ABC", at));
Qso read = store.Qsos(1).Single();
Assert.Equal(written.Id, read.Id);
Assert.Equal("DL1ABC", read.Call.Text);
Assert.Equal(at, read.TimestampUtc);
Assert.Equal(14_025_500, read.Frequency.Hertz);
Assert.Equal(Modes.Cw, read.Mode);
Assert.Equal(14, read.Zone);
Assert.Equal("EU", read.Continent);
}
[Fact]
public void SecondContactInTheSameSecondMovesOnASecond()
{
DateTime at = new(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc);
using SqliteLogStore store = SqliteLogStore.Open(path);
store.Add(Contact("DL1ABC", at));
Qso second = store.Add(Contact("DL1ABC", at));
Assert.Equal(at.AddSeconds(1), second.TimestampUtc);
Assert.Equal(2, store.Qsos(1).Count);
}
[Fact]
public void EditedContactKeepsItsIdentity()
{
using SqliteLogStore store = SqliteLogStore.Open(path);
Qso written = store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc)));
store.Update(written with { ReceivedReport = "579", Zone = 15 });
Qso read = store.Qsos(1).Single();
Assert.Equal(written.Id, read.Id);
Assert.Equal("579", read.ReceivedReport);
Assert.Equal(15, read.Zone);
}
[Fact]
public void DeletedContactIsGone()
{
using SqliteLogStore store = SqliteLogStore.Open(path);
Qso written = store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc)));
store.Delete(written.Id);
Assert.Empty(store.Qsos(1));
}
[Fact]
public void ContestsAreNumberedInTurn()
{
using SqliteLogStore store = SqliteLogStore.Open(path);
ContestInstance first = store.AddContest(new ContestInstance { ContestNumber = 0, ContestName = "CQWW" });
ContestInstance second = store.AddContest(new ContestInstance { ContestNumber = 0, ContestName = "CQWPX" });
Assert.Equal(1, first.ContestNumber);
Assert.Equal(2, second.ContestNumber);
Assert.Equal(2, store.Contests().Count);
}
[Fact]
public void ContactsBelongToTheirOwnContest()
{
using SqliteLogStore store = SqliteLogStore.Open(path);
store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc), contestNumber: 1));
store.Add(Contact("DL2ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc), contestNumber: 2));
Assert.Equal("DL1ABC", store.Qsos(1).Single().Call.Text);
Assert.Equal("DL2ABC", store.Qsos(2).Single().Call.Text);
}
[Fact]
public void NewDatabaseCarriesN1mmTablesAndVersion()
{
using (SqliteLogStore store = SqliteLogStore.Open(path))
{
}
using SqliteConnection connection = new($"Data Source={path}");
connection.Open();
using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name";
using SqliteDataReader row = command.ExecuteReader();
List<string> tables = [];
while (row.Read())
{
tables.Add(row.GetString(0));
}
Assert.Contains("DXLOG", tables);
Assert.Contains("ContestInstance", tables);
Assert.Contains("Contest", tables);
Assert.Contains("Station", tables);
using SqliteCommand version = connection.CreateCommand();
version.CommandText = "PRAGMA user_version";
Assert.Equal(4L, version.ExecuteScalar());
}
[Fact]
public void ContactIdCannotBeChanged()
{
using SqliteLogStore store = SqliteLogStore.Open(path);
store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc)));
using SqliteConnection connection = new($"Data Source={path}");
connection.Open();
using SqliteCommand command = connection.CreateCommand();
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();
}
}