From ab9730b26d84f1e74b7da9392333c1e39e7782c8 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 10 Sep 2026 11:13:38 +0000 Subject: [PATCH] Check the entry boxes by the rules the log window uses The entry window took any text and only the log window checked it, so the same value was accepted in one place and refused in the other. The rules now live in one file, ExchangeCheck, which both windows call. OperatingPosition.EntryProblem names the first box that cannot be logged and why. LogContact and SaveQuickEdit throw on one, so a caller that has not checked cannot write nonsense to the log. The entry window checks before it logs, puts the reason in the status line and the cursor in the box to fix, which covers Enter, the Log It button, ESM and {LOG}. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015NZkFxHde1iifAQemFTHxf --- src/Nonemm.App/Windows/EntryWindow.axaml.cs | 20 ++++- src/Nonemm.Session/EntryProblem.cs | 5 ++ src/Nonemm.Session/ExchangeCheck.cs | 77 +++++++++++++++++++ src/Nonemm.Session/OperatingPosition.cs | 19 ++++- src/Nonemm.Session/QsoEditor.cs | 37 ++------- .../OperatingPositionTests.cs | 50 ++++++++++++ 6 files changed, 175 insertions(+), 33 deletions(-) create mode 100644 src/Nonemm.Session/EntryProblem.cs create mode 100644 src/Nonemm.Session/ExchangeCheck.cs diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index 2c7afa3..a09f849 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -490,7 +490,7 @@ public sealed partial class EntryWindow : Window /// both do. private void SaveQuickEdit() { - if (Logging?.SaveQuickEdit() is not { } saved) + if (!IsEntryUsable() || Logging?.SaveQuickEdit() is not { } saved) { return; } @@ -502,7 +502,7 @@ public sealed partial class EntryWindow : Window private void LogContact() { - if (Logging is null) + if (Logging is null || !IsEntryUsable()) { return; } @@ -521,6 +521,22 @@ public sealed partial class EntryWindow : Window boxes[0].Focus(); } + /// True when everything typed can go in the log. A value the contest will + /// not take puts the reason in the status line and the cursor in its box, + /// which is where the operator has to fix it. + private bool IsEntryUsable() + { + if (Logging?.EntryProblem() is not { } problem) + { + return true; + } + Status(problem.Message); + Logging.Entry.FocusOn(problem.Field); + boxes[problem.Field].Focus(); + boxes[problem.Field].SelectAll(); + return false; + } + /// What space does: the callsign box and the exchange boxes, stepping over /// the reports and over a box this station is not asked for, and round to /// the callsign again. This is N1MM's NextTab. diff --git a/src/Nonemm.Session/EntryProblem.cs b/src/Nonemm.Session/EntryProblem.cs new file mode 100644 index 0000000..bd9c1e1 --- /dev/null +++ b/src/Nonemm.Session/EntryProblem.cs @@ -0,0 +1,5 @@ +namespace Nonemm.Session; + +/// A value in the entry boxes that cannot be logged. `Field` is the box it is +/// in, numbered the way `EntryFields` numbers them: 0 is the callsign. +public sealed record EntryProblem(int Field, string Message); diff --git a/src/Nonemm.Session/ExchangeCheck.cs b/src/Nonemm.Session/ExchangeCheck.cs new file mode 100644 index 0000000..c366eca --- /dev/null +++ b/src/Nonemm.Session/ExchangeCheck.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using Nonemm.Contests; +using Nonemm.Contests.Multipliers; +using Nonemm.Core; + +namespace Nonemm.Session; + +/// What each kind of exchange value has to look like. The entry window and the +/// log window both check against this, so a value refused in one is refused in +/// the other. +public static class ExchangeCheck +{ + private const int LowestCqZone = 1; + private const int HighestCqZone = 40; + private const int HighestItuZone = 90; + + /// Null when the value can be logged. An empty value is acceptable here: + /// whether the contest needs it is a separate question, answered by + /// `EntryFields.IsComplete`. + public static string? Error(ExchangeFieldKind kind, string value, Mode mode) + { + // the exchange is stored upper case, so it is checked upper case: a + // section typed in lower case is the section, not an unknown one + string text = value.Trim().ToUpperInvariant(); + if (text.Length == 0) + { + return null; + } + return kind switch + { + ExchangeFieldKind.Report => SignalReport.IsPlausible(text, mode) + ? null + : $"{text} is not a signal report on {mode.Name}; write it as {SignalReport.Example(mode)}", + ExchangeFieldKind.Number => InRange(text, 0, int.MaxValue) ? null : $"{text} is not a whole number", + ExchangeFieldKind.Check => InRange(text, 0, 99) ? null : "the check is a two-digit year", + ExchangeFieldKind.CqZone => InRange(text, LowestCqZone, HighestCqZone) + ? null + : $"CQ zones run from {LowestCqZone} to {HighestCqZone}", + ExchangeFieldKind.ItuZone => InRange(text, LowestCqZone, HighestItuZone) + ? null + : $"ITU zones run from {LowestCqZone} to {HighestItuZone}", + ExchangeFieldKind.ArrlSection => + ArrlSections.IsSection(text) ? null : $"{text} is not an ARRL section", + ExchangeFieldKind.UsStateOrCanadianProvince => + StatesAndProvinces.IsStateOrProvince(text) ? null : $"{text} is not a state or province", + ExchangeFieldKind.Grid => + GridSquare.TryParse(text, out _) ? null : $"{text} is not a grid square", + ExchangeFieldKind.Precedence => + text.Length == 1 && char.IsLetter(text[0]) ? null : "the precedence is one letter", + _ => null, + }; + } + + /// The first entry box holding something that cannot be logged, and why. + /// Null when everything typed can go in the log. + public static EntryProblem? Problem(EntryFields entry, Mode mode) + { + string call = entry.Call.Trim(); + if (call.Length > 0 && !Callsign.Parse(call).IsPlausible) + { + return new EntryProblem(0, $"{call} is not shaped like a callsign"); + } + for (int at = 0; at < entry.Exchange.Count; at++) + { + if (Error(entry.Exchange[at].Kind, entry[at + 1], mode) is { } error) + { + return new EntryProblem(at + 1, error); + } + } + return null; + } + + private static bool InRange(string value, int low, int high) => + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int number) + && number >= low + && number <= high; +} diff --git a/src/Nonemm.Session/OperatingPosition.cs b/src/Nonemm.Session/OperatingPosition.cs index 79158db..ac4338e 100644 --- a/src/Nonemm.Session/OperatingPosition.cs +++ b/src/Nonemm.Session/OperatingPosition.cs @@ -209,14 +209,24 @@ public sealed class OperatingPosition && !call.All(c => char.IsAsciiDigit(c) || c == '.') && !string.Equals(call, Me.Callsign, StringComparison.OrdinalIgnoreCase); + /// The first entry box holding something that cannot be logged, and why. + /// Null when everything typed can go in the log. The log window checks the + /// same way, so a value refused here is refused there. + public EntryProblem? EntryProblem() => ExchangeCheck.Problem(Entry, Mode); + /// Logs what is typed and clears the entry. Throws when the entry is not - /// complete, so a caller that has not checked cannot lose an exchange. + /// complete or holds a value the contest will not take, so a caller that + /// has not checked cannot lose an exchange or log nonsense. public Qso LogContact() { if (!Entry.IsComplete) { throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete"); } + if (EntryProblem() is { } problem) + { + throw new InvalidOperationException(problem.Message); + } StampSentTime(); Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim()))); Entry.Clear(); @@ -384,13 +394,18 @@ public sealed class OperatingPosition } /// Writes the boxes back to the contact being quick edited and leaves quick - /// edit. Null when no contact is being edited. + /// edit. Null when no contact is being edited. Throws on a value the + /// contest will not take, the same as logging a new contact does. public Qso? SaveQuickEdit() { if (Editing is null) { return null; } + if (EntryProblem() is { } problem) + { + throw new InvalidOperationException(problem.Message); + } Qso saved = Noted(ApplyExchange(CountryFields.Apply( Editing with { Call = Callsign.Parse(Entry.Call.Trim()) }, Session.Countries))); Session.Update(saved); diff --git a/src/Nonemm.Session/QsoEditor.cs b/src/Nonemm.Session/QsoEditor.cs index 00a8881..50dcd53 100644 --- a/src/Nonemm.Session/QsoEditor.cs +++ b/src/Nonemm.Session/QsoEditor.cs @@ -90,7 +90,7 @@ public sealed class QsoEditor private QsoEdit Applied(Qso qso, QsoField field, string text) { string value = Normalize(field, text); - if (KindError(field, value) is { } refused) + if (KindError(qso, field, value) is { } refused) { return QsoEdit.Refuse(refused); } @@ -218,34 +218,13 @@ public sealed class QsoEditor } /// Checks the value against the exchange field kind the contest defined. - /// Returns null if it is acceptable. An empty value always is. - private string? KindError(QsoField field, string value) - { - if (value.Length == 0 || !exchangeKinds.TryGetValue(field, out ExchangeFieldKind kind)) - { - return null; - } - return kind switch - { - ExchangeFieldKind.Check => InRange(value, 0, 99) ? null : "the check is a two-digit year", - ExchangeFieldKind.CqZone => InRange(value, 1, 40) ? null : "CQ zones run from 1 to 40", - ExchangeFieldKind.ItuZone => InRange(value, 1, 90) ? null : "ITU zones run from 1 to 90", - ExchangeFieldKind.ArrlSection => - ArrlSections.IsSection(value) ? null : $"{value} is not an ARRL section", - ExchangeFieldKind.UsStateOrCanadianProvince => - StatesAndProvinces.IsStateOrProvince(value) ? null : $"{value} is not a state or province", - ExchangeFieldKind.Grid => - GridSquare.TryParse(value, out _) ? null : $"{value} is not a grid square", - ExchangeFieldKind.Precedence => - value.Length == 1 && char.IsLetter(value[0]) ? null : "the precedence is one letter", - _ => null, - }; - } - - private static bool InRange(string value, int low, int high) => - int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int number) - && number >= low - && number <= high; + /// Reports are left out: they are checked per field, because a WAE QTC row + /// keeps a time and a call in the two report columns. + private string? KindError(Qso qso, QsoField field, string value) => + exchangeKinds.TryGetValue(field, out ExchangeFieldKind kind) + && kind != ExchangeFieldKind.Report + ? ExchangeCheck.Error(kind, value, qso.Mode) + : null; private static string Kilohertz(Frequency frequency) => frequency.Kilohertz.ToString("0.0", CultureInfo.InvariantCulture); diff --git a/tests/Nonemm.Session.Tests/OperatingPositionTests.cs b/tests/Nonemm.Session.Tests/OperatingPositionTests.cs index e50a583..cc878eb 100644 --- a/tests/Nonemm.Session.Tests/OperatingPositionTests.cs +++ b/tests/Nonemm.Session.Tests/OperatingPositionTests.cs @@ -598,4 +598,54 @@ public class OperatingPositionTests Assert.Equal("JA1XYZ", session.Entry.Call); Assert.False(session.QuickEdit(forward: false)); } + + /// The entry boxes and the log window take the same values. A zone that + /// does not exist is refused in both, and the box holding it is named so + /// the entry window can put the cursor back in it. + [Fact] + public void AZoneThatDoesNotExistCannotBeLogged() + { + OperatingPosition session = Session(); + Type(session, "JA1XYZ", "41"); + + EntryProblem? problem = session.EntryProblem(); + + Assert.Equal("CQ zones run from 1 to 40", problem!.Message); + Assert.Equal("41", session.Entry[problem.Field]); + Assert.Throws(session.LogContact); + } + + [Fact] + public void ArbitraryTextInTheReportBoxCannotBeLogged() + { + OperatingPosition session = Session(); + Type(session, "JA1XYZ", "25"); + session.Entry.Set(ExchangeSlot.ReceivedReport, "good copy"); + + Assert.Contains("signal report", session.EntryProblem()!.Message); + } + + [Fact] + public void ACallThatIsNotShapedLikeOneCannotBeLogged() + { + OperatingPosition session = Session(); + Type(session, "12345", "25"); + + Assert.Equal(0, session.EntryProblem()!.Field); + } + + [Fact] + public void AnExchangeTheContestTakesGivesNoProblem() + { + OperatingPosition session = Session(); + Type(session, "ja1xyz", "25"); + + Assert.Null(session.EntryProblem()); + } + + /// Nothing is typed yet when the operator is between contacts, and that is + /// not something to complain about. + [Fact] + public void EmptyBoxesGiveNoProblem() => + Assert.Null(Session().EntryProblem()); }