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