From 265f90ccd7403aaf9a27984e590fc7e6125229f0 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 10 Sep 2026 19:30:32 +0000 Subject: [PATCH] Keep QTC traffic out of the serial numbers A QTC row holds the serial of the contact it reports, which the station being reported sent, not us. The serial pool was filled from every row in the log, so reporting a station that sent us 1800 pushed our own next serial to 1801, and reopening the log read it back from the rows again. The pool now takes the sent number of contacts alone, when a contact is logged, when one arrives from another station of the entry, and when the numbers are seeded from the log at startup. N1MM leaves both QTC kinds out of the same query, in QSONrsByBand. The test opens a real log file, sends a series, closes the store and opens it again, so the series numbering and the contacts already reported are checked across a restart as well. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ --- src/Nonemm.Session/ContestSession.cs | 19 ++- tests/Nonemm.Session.Tests/QtcRestartTests.cs | 138 ++++++++++++++++++ 2 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 tests/Nonemm.Session.Tests/QtcRestartTests.cs diff --git a/src/Nonemm.Session/ContestSession.cs b/src/Nonemm.Session/ContestSession.cs index f42d40f..9f8e4dd 100644 --- a/src/Nonemm.Session/ContestSession.cs +++ b/src/Nonemm.Session/ContestSession.cs @@ -111,7 +111,7 @@ public sealed class ContestSession { Log.Replace(stored); } - Numbers.Record(stored.SentNumber, stored.Band); + RecordNumber(stored); Logged?.Invoke(this, stored); Changed?.Invoke(this, EventArgs.Empty); return stored; @@ -203,7 +203,7 @@ public sealed class ContestSession } Qso arrived = store.Add(FromNetwork(qso)); Log.Insert(arrived); - Numbers.Record(arrived.SentNumber, arrived.Band); + RecordNumber(arrived); Changed?.Invoke(this, EventArgs.Empty); return true; } @@ -274,6 +274,19 @@ public sealed class ContestSession ? Log.Qsos.Where(q => Contest.SessionOf(q.TimestampUtc) == Contest.SessionOf(DateTime.UtcNow)) : Log.Qsos; - Numbers.SeedFrom(counted); + Numbers.SeedFrom(counted.Where(Contest.IsContact)); + } + + /// The sent number of a contact, and nothing else. A WAE QTC row carries + /// the serial of the contact it reports in the same column, and that + /// number was sent by the station being reported rather than by us, so + /// taking it would push our own numbers up. N1MM leaves the QTC rows out + /// of the same query, in `QSONrsByBand`. + private void RecordNumber(Qso qso) + { + if (Contest.IsContact(qso)) + { + Numbers.Record(qso.SentNumber, qso.Band); + } } } diff --git a/tests/Nonemm.Session.Tests/QtcRestartTests.cs b/tests/Nonemm.Session.Tests/QtcRestartTests.cs new file mode 100644 index 0000000..95ac535 --- /dev/null +++ b/tests/Nonemm.Session.Tests/QtcRestartTests.cs @@ -0,0 +1,138 @@ +using Microsoft.Data.Sqlite; +using Nonemm.Contests; +using Nonemm.Contests.Rules; +using Nonemm.Core; +using Nonemm.Core.Country; +using Nonemm.Storage; + +namespace Nonemm.Session.Tests; + +/// QTC traffic through a real log file and back: what a station has already +/// had, which contacts have already been reported and how the series are +/// numbered all come from the log rows, so they have to survive the program +/// being closed and opened again. +public class QtcRestartTests : IDisposable +{ + private const string Countries = """ + Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL: + DL,DK,DJ; + United States: 05: 08: NA: 37.53: 91.67: 5.0: K: + K,W,N,AA,NE; + """; + + private static readonly StationInfo UnitedStates = new() + { + Callsign = "NE1C", + CqZone = 5, + ItuZone = 8, + Continent = "NA", + CountryPrefix = "K", + }; + + private readonly string path = Path.Combine(Path.GetTempPath(), $"nonemm-{Guid.NewGuid():N}.s3db"); + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + File.Delete(path); + GC.SuppressFinalize(this); + } + + /// Opens the log the way the program does when it starts: the store, then + /// the contest, then the position. + private OperatingPosition Open() + { + SqliteLogStore store = SqliteLogStore.Open(path); + ContestInstance instance = store.Contest(1) ?? store.AddContest(new ContestInstance + { + ContestNumber = 0, + ContestName = "WAECW", + ModeCategory = "CW", + }); + return new OperatingPosition(new ContestSession( + store, + new Wae(ModeCategory.Cw), + instance, + UnitedStates, + CountryFile.Parse(Countries))); + } + + private static Qso Contact( + OperatingPosition position, + string call, + int receivedNumber, + int minute) => + position.Session.Add(new Qso + { + Id = Qso.NewId(), + TimestampUtc = new DateTime(2026, 11, 14, 13, minute, 0, DateTimeKind.Utc), + Call = Callsign.Parse(call), + Frequency = Frequency.FromKilohertz(14_025), + Mode = Modes.Cw, + ContestName = "WAECW", + ContestNumber = position.Instance.ContestNumber, + SentReport = "599", + ReceivedReport = "599", + SentNumber = position.Session.ReserveNumberOn(position.Band), + ReceivedNumber = receivedNumber, + Exchange1 = WaeQtc.Contact, + }); + + private static void Send(OperatingPosition position, IReadOnlyList lines) + { + for (int line = 0; line < lines.Count; line++) + { + position.Session.Add(lines[line].ApplyTo(new Qso + { + Id = Qso.NewId(), + TimestampUtc = new DateTime(2026, 11, 14, 14, 0, line, DateTimeKind.Utc), + Call = lines[line].Station, + Frequency = Frequency.FromKilohertz(14_025), + Mode = Modes.Cw, + ContestName = "WAECW", + ContestNumber = position.Instance.ContestNumber, + })); + } + } + + [Fact] + public void TheSeriesCarriesOnAfterTheProgramIsOpenedAgain() + { + OperatingPosition first = Open(); + QtcTraffic traffic = new(first); + Contact(first, "DL1AAA", receivedNumber: 16, minute: 40); + Contact(first, "DL2BBB", receivedNumber: 40, minute: 41); + Contact(first, "DL3CCC", receivedNumber: 7, minute: 42); + Send(first, traffic.ToSend(Callsign.Parse("DL9XYZ"), mostPerSeries: 2)); + Assert.Equal(2, traffic.NextSeries()); + + OperatingPosition second = Open(); + QtcTraffic reopened = new(second); + + // the series number carries on rather than starting at one again + Assert.Equal(2, reopened.NextSeries()); + // and the two contacts already reported are not offered a second time + Assert.Equal( + ["DL3CCC"], + reopened.ToSend(Callsign.Parse("DL9XYZ")).Select(l => l.Call.Text).ToList()); + QtcCounts counts = reopened.CountsFor(Callsign.Parse("DL9XYZ")); + Assert.Equal(2, counts.Exchanged); + Assert.Equal(1, counts.Available); + Assert.Equal(1, counts.LeftWith); + } + + /// A QTC row holds the serial of the contact it reports, which the other + /// station sent. Taking that for our own next number would jump the log + /// forward; N1MM leaves the QTC rows out of the same query. + [Fact] + public void TrafficDoesNotTakeTheNextSerialNumber() + { + OperatingPosition first = Open(); + Contact(first, "DL1AAA", receivedNumber: 1_450, minute: 40); + Contact(first, "DL2BBB", receivedNumber: 1_800, minute: 41); + Send(first, new QtcTraffic(first).ToSend(Callsign.Parse("DL9XYZ"))); + + Assert.Equal(3, first.SentNumber); + Assert.Equal(3, Open().SentNumber); + } +}