Let the walk and the completeness check read one rule

A box the walk between the entry boxes steps over cannot be typed in, so it
must not hold the contact back either. `EntryFields.IsComplete` counted every
box marked required, while `MoveFocus` stepped over the ones the contest does
not ask this station for, so a contest with a box for some stations only
could lock the operator out of his own log.

The seven contests the scoring audit named do not do that any more —
`bae40bb` gave each of them the boxes N1MM shows, and fifteen reproductions
across all seven and both station types log cleanly — but the rule they broke
was still there to break again. Completeness now takes the same
applicability rule the cursor uses, and `OperatingPosition` answers it in one
place for the entry window, the ESM window and logging alike.

The audit is marked up for the three findings that are already fixed and for
item 14, which turns out to be what N1MM does rather than a fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
This commit is contained in:
2026-09-10 20:33:30 +00:00
co-authored by Claude Opus 5
parent e56d6096f3
commit 211298e04c
6 changed files with 161 additions and 7 deletions
+8
View File
@@ -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. 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. 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). 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. 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. 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. 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/). **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. 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.
+1 -1
View File
@@ -32,7 +32,7 @@ public sealed partial class EntryWindow
IsRunning = Logging.IsRunning, IsRunning = Logging.IsRunning,
HasCall = Logging.Entry.Call.Trim().Length > 0, HasCall = Logging.Entry.Call.Trim().Length > 0,
IsDupe = Logging.Verdict()?.IsDupe == true, IsDupe = Logging.Verdict()?.IsDupe == true,
HasExchange = Logging.Entry.IsComplete, HasExchange = Logging.IsEntryComplete,
CallSent = callSent, CallSent = callSent,
ExchangeSent = exchangeSent, ExchangeSent = exchangeSent,
SendsCallOnce = session.Settings.EsmSendsCallOnce, SendsCallOnce = session.Settings.EsmSendsCallOnce,
+2 -2
View File
@@ -484,7 +484,7 @@ public sealed partial class EntryWindow : Window
_ = RunEsmAsync(); _ = RunEsmAsync();
return; return;
} }
if (!Logging.Entry.IsComplete) if (!Logging.IsEntryComplete)
{ {
MoveFocus(forward: true); MoveFocus(forward: true);
return; return;
@@ -1136,7 +1136,7 @@ public sealed partial class EntryWindow : Window
// the next message of the contact instead of logging it, and Ctrl+Z // 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 // means log it and open the window. N1MM calls `LogContact` at the
// same point // same point
if (Logging.Entry.Call.Trim().Length > 0 && Logging.Entry.IsComplete) if (Logging.Entry.Call.Trim().Length > 0 && Logging.IsEntryComplete)
{ {
LogContact(); LogContact();
} }
+8 -3
View File
@@ -112,10 +112,15 @@ public sealed class EntryFields
Call.Trim().Length == 0 && Call.Trim().Length == 0 &&
Exchange.Where((f, at) => !f.IsSent && values[at].Trim().Length > 0).Any() == false; Exchange.Where((f, at) => !f.IsSent && values[at].Trim().Length > 0).Any() == false;
/// True when everything the contest asks for has been typed. /// True when everything the contest asks for has been typed. `applies`
public bool IsComplete => /// 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<ExchangeField, bool> applies) =>
Call.Trim().Length > 0 && 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() public void Clear()
{ {
+8 -1
View File
@@ -253,7 +253,7 @@ public sealed class OperatingPosition
/// has not checked cannot lose an exchange or log nonsense. /// has not checked cannot lose an exchange or log nonsense.
public Qso LogContact() public Qso LogContact()
{ {
if (!Entry.IsComplete) if (!IsEntryComplete)
{ {
throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete"); 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 /// 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 /// 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. /// 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) public void MoveFocus(bool forward)
{ {
CountryLookup? their = Country(); CountryLookup? their = Country();
@@ -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<InvalidOperationException>(() => 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<ExchangeField> 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<string> MultiplierNames => ["States"];
public DupeScope DupeScope => DupeScope.PerBand;
public bool HasSerialNumbers => false;
public IReadOnlyList<ModeCategory> Modes => [];
public string SentExchangeFor(StationInfo me) => me.State;
public int PointsFor(QsoContext qso) => 1;
public IReadOnlyList<Multiplier> 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)]);
}
}