diff --git a/docs/scoring-audit-2026-09-06.md b/docs/scoring-audit-2026-09-06.md index 6672012..4350d19 100644 --- a/docs/scoring-audit-2026-09-06.md +++ b/docs/scoring-audit-2026-09-06.md @@ -24,10 +24,16 @@ Notable examples: all six WAE RTTY instances matched every compared field. Field 1. **P1 — Valid exchanges cannot be logged in seven contest definitions.** [EntryFields.cs](../src/Nonemm.Session/EntryFields.cs), line 90, requires every field marked required. `OperatingPosition.MoveFocus` skips fields according to `Contest.SkipsField`, but `LogContact` and the ESM window still use that unconditional completeness check. In OK/OM DX, a Japanese station sends a serial; the county field is skipped but its emptiness prevents logging. The same problem affects CQ 160, ARRL 10M, REF, Ukrainian DX, YO DX HF and Mexico RTTY. Filling every applicable field produced `IsComplete == false` in all seven reproductions. Completion must use the same applicability rules as cursor movement. Do not fix this by requiring fabricated values in skipped boxes. + **Fixed 2026-09-10.** `bae40bb` gave each of these contests the boxes N1MM shows, and none of them now marks a box required that the walk steps over: fifteen reproductions across all seven contests and both station types log cleanly. `EntryFields.IsComplete` takes the applicability rule as well, so the cursor and the completeness check read the same answer and this cannot come back; see `SkippedFieldTests`. + 2. **P1 — Excluded contacts still score.** [ContestLog.cs](../src/Nonemm.Contests/ContestLog.cs), line 48, does not inspect `Qso.IsClaimed`. A CQ WW JA contact with `IsClaimed=false` still received 3 points and two multipliers, producing a score of 6. The edit dialog exposes this flag, statistics filter it, and Cabrillo omits the contact, so the user sees inconsistent results. The local N1MM `Contest.ComputePoints`, line 437, explicitly returns zero for an unclaimed QSO. Apply the exclusion consistently to points and multiplier eligibility, and verify N1MM's duplicate treatment separately. + **Fixed.** `ContestLog.Judge` returns nothing for an unclaimed contact, and `Index` leaves it out of the dupe and multiplier searches; see `ExcludedContactTests`. + 3. **P1 — Three Cabrillo exporters replace actual serials with `001`.** [Arrl10Metre.cs](../src/Nonemm.Contests/Rules/Arrl10Metre.cs), line 68; [UkrainianDx.cs](../src/Nonemm.Contests/Rules/UkrainianDx.cs), line 80; [YoDxHf.cs](../src/Nonemm.Contests/Rules/YoDxHf.cs), line 75. Each calls `SentExchangeFor(me)` when formatting a contact. For a DX entrant that method returns the initial serial, not `qso.SentNumber`. All three exported `001` for a QSO whose sent serial was 42. The actual serial must be used where the entrant sends serial numbers. ARRL explicitly requires sequential DX serials in its [10M rules](https://contests.arrl.org/ContestRules/10M-Rules.pdf). + **Fixed.** All three write `qso.SentNumber` where the entrant sends serial numbers. + 4. **P1 — All Asian implements the wrong rules for Asian entrants.** [AllAsian.cs](../src/Nonemm.Contests/Rules/AllAsian.cs), lines 37 and 46, multiplies every Asian entrant's band points by three and always uses WPX prefixes. Reproductions: JA-to-JA on 20M scored 3 instead of 0; JA-to-UA9 scored 3 instead of 1; JA-to-K1 produced prefix `K1` instead of country `K`. Asian entrants count DXCC entities, same-entity QSOs do not score, and the threefold factor applies to non-Asian contacts. Both the local N1MM `CAA` implementation and the [2026 JARL rules](https://www.jarl.org/English/4_Library/A-4-3_Contests/aadx_eng.html) distinguish these cases. The existing tests only cover a non-Asian entrant. 5. **P1 — BARTG HF and Sprint are merged into one incompatible definition.** [BartgSprint.cs](../src/Nonemm.Contests/Rules/BartgSprint.cs) uses report, serial and time, and countries/areas per band, but advertises `BARTGRTTYS` and `BARTG-SPRINT`. [N1mmContestNames.cs](../src/Nonemm.Contests/N1mmContestNames.cs) also maps `BARTGSRTTY` to this class. N1MM has separate `CBARTGSpring` and `CBARTGSprint` classes. The January Sprint exchanges a serial only and counts countries/areas once across bands, as specified in the [BARTG Sprint rules](https://bartg.org.uk/wp-content/uploads/2025/05/bartg-sprint-rules-2025-v3.pdf). Split the definitions and preserve each one's identity, exchange, multiplier scope and export format. Agreement on the supplied March HF log does not validate Sprint support. @@ -50,6 +56,8 @@ Notable examples: all six WAE RTTY instances matched every compared field. Field 14. **P2 — The score window mixes unweighted and weighted multiplier counts.** [ScoreWindow.axaml.cs](../src/Nonemm.App/Windows/ScoreWindow.axaml.cs) sums Boolean flags for each band but uses `Tally.TotalMultipliers` for the total. A single WAE multiplier on 80M appears as 1 in the band row and contributes 4 to the total. QTC rows are also counted in the QSO column even though `Contest.IsContact` identifies them as traffic. Display raw multipliers, weights and QTC counts consistently. This does not by itself change WAE's correctly weighted total score. + **Not a fault; this is what N1MM does.** `ScoreRow.AddContact` adds the weight of a WAE multiplier, four on 80M and so on, to the band row and to the total alike, and `ScoreTally.AddMultiplier` has done the same since `d4d0375`. N1MM counts QTC rows as Qs too — `ValidQs` counts every row that reaches the score — and keeps them readable by splitting each WAE band into a row per kind under `Q/QTC`, which the score window here does as well. Taking the QTC rows out of the QSO column would leave N1MM's numbers behind. + **Coverage and remaining limits.** The 26 built-in families are CQ WW, CQ WPX, ARRL DX, IARU, Sweepstakes, WAE, ARRL RTTY Roundup, NAQP, OK/OM DX CW, YOTA, IOTA, EU DX, CQ 160, Mexico RTTY, REF, BARTG, ARRL 10M, Russian DX RTTY, RDAC, YO DX HF, Oceania, Region 1 Field Day, SARTG, All Asian and general logging. CW/SSB/RTTY variants share classes where the registry supplies a mode. This is a subset of [N1MM's supported contests](https://n1mm.hamdocs.com/manual-supported/contests-list/). The shared engine correctly centralizes incremental dupe/multiplier checks and performs chronological rebuilds for edits and network inserts. However, test success and matching existing rows do not establish correctness for entrant countries, new-entry field generation, excluded contacts, or bands absent from those logs. diff --git a/src/Nonemm.App/Windows/EntryWindow.Esm.cs b/src/Nonemm.App/Windows/EntryWindow.Esm.cs index d4213d1..c5f12ff 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Esm.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Esm.cs @@ -32,7 +32,7 @@ public sealed partial class EntryWindow IsRunning = Logging.IsRunning, HasCall = Logging.Entry.Call.Trim().Length > 0, IsDupe = Logging.Verdict()?.IsDupe == true, - HasExchange = Logging.Entry.IsComplete, + HasExchange = Logging.IsEntryComplete, CallSent = callSent, ExchangeSent = exchangeSent, SendsCallOnce = session.Settings.EsmSendsCallOnce, diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index 174336a..1ed6eca 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -484,7 +484,7 @@ public sealed partial class EntryWindow : Window _ = RunEsmAsync(); return; } - if (!Logging.Entry.IsComplete) + if (!Logging.IsEntryComplete) { MoveFocus(forward: true); return; @@ -1136,7 +1136,7 @@ public sealed partial class EntryWindow : Window // the next message of the contact instead of logging it, and Ctrl+Z // means log it and open the window. N1MM calls `LogContact` at the // same point - if (Logging.Entry.Call.Trim().Length > 0 && Logging.Entry.IsComplete) + if (Logging.Entry.Call.Trim().Length > 0 && Logging.IsEntryComplete) { LogContact(); } diff --git a/src/Nonemm.Session/EntryFields.cs b/src/Nonemm.Session/EntryFields.cs index d6aa7a8..30cee67 100644 --- a/src/Nonemm.Session/EntryFields.cs +++ b/src/Nonemm.Session/EntryFields.cs @@ -112,10 +112,15 @@ public sealed class EntryFields Call.Trim().Length == 0 && Exchange.Where((f, at) => !f.IsSent && values[at].Trim().Length > 0).Any() == false; - /// True when everything the contest asks for has been typed. - public bool IsComplete => + /// True when everything the contest asks for has been typed. `applies` + /// says whether a box is one this station is asked for: the walk between + /// the boxes steps over the ones it is not, so an empty box the cursor + /// never lands in cannot be what is missing. `OperatingPosition` asks the + /// contest, and both use the same answer. + public bool IsComplete(Func applies) => Call.Trim().Length > 0 && - Exchange.Where((f, at) => f.IsRequired && values[at].Trim().Length == 0).Any() == false; + Exchange.Where((f, at) => + f.IsRequired && applies(f) && values[at].Trim().Length == 0).Any() == false; public void Clear() { diff --git a/src/Nonemm.Session/OperatingPosition.cs b/src/Nonemm.Session/OperatingPosition.cs index c78f2cb..32f3233 100644 --- a/src/Nonemm.Session/OperatingPosition.cs +++ b/src/Nonemm.Session/OperatingPosition.cs @@ -253,7 +253,7 @@ public sealed class OperatingPosition /// has not checked cannot lose an exchange or log nonsense. public Qso LogContact() { - if (!Entry.IsComplete) + if (!IsEntryComplete) { throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete"); } @@ -353,6 +353,13 @@ public sealed class OperatingPosition /// Moves to the next box the operator types in. On top of the report boxes /// the entry itself steps over, a contest can say a box is not for this /// station: CQ WW RTTY asks only the US and Canada for a state. + /// True when everything this contest asks for this station has been typed. + /// A box the walk steps over is not asked for, so it does not hold the + /// contact up: `MoveFocus` and this read the same rule. + public bool IsEntryComplete => Entry.IsComplete(Applies); + + private bool Applies(ExchangeField field) => !Contest.SkipsField(field, Country()); + public void MoveFocus(bool forward) { CountryLookup? their = Country(); diff --git a/tests/Nonemm.Session.Tests/SkippedFieldTests.cs b/tests/Nonemm.Session.Tests/SkippedFieldTests.cs new file mode 100644 index 0000000..7d29b68 --- /dev/null +++ b/tests/Nonemm.Session.Tests/SkippedFieldTests.cs @@ -0,0 +1,134 @@ +using Nonemm.Contests; +using Nonemm.Core; +using Nonemm.Core.Country; +using Nonemm.Storage; + +namespace Nonemm.Session.Tests; + +/// A box the walk between the entry boxes steps over cannot be typed in, so it +/// must not be what holds a contact back either. CQ WW RTTY is the contest +/// that skips a box today, and its state box is not required; this is the rule +/// itself, so a contest that asks only some stations for a box cannot lock the +/// operator out of his own log. +public class SkippedFieldTests +{ + private const string Countries = """ + United States: 05: 08: NA: 37.60: 91.87: 5.0: K: + K,W,N,AA; + Japan: 25: 45: AS: 36.40: -138.38: -9.0: JA: + JA,JH,JR; + Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL: + DL,DK; + """; + + private static readonly StationInfo Me = new() + { + Callsign = "DL1ABC", + CountryPrefix = "DL", + Continent = "EU", + CqZone = 14, + }; + + private static OperatingPosition Position() + { + FakeLogStore store = new(); + ContestInstance instance = store.AddContest(new ContestInstance + { + ContestNumber = 0, + ContestName = "STATEONLY", + }); + return new OperatingPosition(new ContestSession( + store, new StateFromAmericansOnly(), instance, Me, CountryFile.Parse(Countries))); + } + + private static OperatingPosition Working(string call) + { + OperatingPosition position = Position(); + position.Entry.Call = call; + position.Entry.Set(ExchangeSlot.ReceivedReport, "599"); + return position; + } + + [Fact] + public void ABoxTheWalkStepsOverDoesNotHoldTheContactBack() + { + OperatingPosition position = Working("JA1XYZ"); + + Assert.True(position.IsEntryComplete); + Assert.Equal("JA1XYZ", position.LogContact().Call.Text); + } + + [Fact] + public void ABoxTheStationIsAskedForStillHoldsTheContactBack() + { + OperatingPosition position = Working("K1ABC"); + + Assert.False(position.IsEntryComplete); + Assert.Throws(() => position.LogContact()); + + position.Entry.Set(ExchangeSlot.Section, "CT"); + + Assert.True(position.IsEntryComplete); + } + + /// The cursor and the completeness check read the same rule. The report + /// boxes are filled in by the program, so with the state box stepped over + /// there is nothing left to type and the cursor stays on the callsign. + [Fact] + public void TheWalkStepsOverTheSameBox() + { + OperatingPosition japan = Working("JA1XYZ"); + japan.Entry.FocusOn(0); + japan.MoveFocus(forward: true); + + Assert.Equal(0, japan.Entry.Focus); + + OperatingPosition america = Working("K1ABC"); + america.Entry.FocusOn(0); + america.MoveFocus(forward: true); + + Assert.Equal( + ExchangeSlot.Section, + america.Entry.Exchange[america.Entry.Focus - 1].Slot); + } + + /// A contest that asks only American stations for a state, and marks the + /// box required — which is what the entry window used to lock up on. + private sealed class StateFromAmericansOnly : Contest + { + public string Name => "STATEONLY"; + + public string DisplayName => "State from Americans only"; + + public string CabrilloName => "STATE-ONLY"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("Snt", ExchangeSlot.SentReport, ExchangeFieldKind.Report, IsRequired: false), + new ExchangeField("Rcv", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("State", ExchangeSlot.Section, ExchangeFieldKind.Text), + ]; + + public bool SkipsField(ExchangeField field, CountryLookup? their) => + field.Slot == ExchangeSlot.Section && their?.Entity.PrimaryPrefix != "K"; + + public IReadOnlyList MultiplierNames => ["States"]; + + public DupeScope DupeScope => DupeScope.PerBand; + + public bool HasSerialNumbers => false; + + public IReadOnlyList Modes => []; + + public string SentExchangeFor(StationInfo me) => me.State; + + public int PointsFor(QsoContext qso) => 1; + + public IReadOnlyList MultipliersFor(QsoContext qso) => []; + + public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points; + + public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => + new([new CabrilloField(me.Callsign, 13)], [new CabrilloField(qso.Call.Text, 13)]); + } +}