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