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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NZkFxHde1iifAQemFTHxf
This commit is contained in:
@@ -490,7 +490,7 @@ public sealed partial class EntryWindow : Window
|
|||||||
/// both do.
|
/// both do.
|
||||||
private void SaveQuickEdit()
|
private void SaveQuickEdit()
|
||||||
{
|
{
|
||||||
if (Logging?.SaveQuickEdit() is not { } saved)
|
if (!IsEntryUsable() || Logging?.SaveQuickEdit() is not { } saved)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -502,7 +502,7 @@ public sealed partial class EntryWindow : Window
|
|||||||
|
|
||||||
private void LogContact()
|
private void LogContact()
|
||||||
{
|
{
|
||||||
if (Logging is null)
|
if (Logging is null || !IsEntryUsable())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -521,6 +521,22 @@ public sealed partial class EntryWindow : Window
|
|||||||
boxes[0].Focus();
|
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
|
/// 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 reports and over a box this station is not asked for, and round to
|
||||||
/// the callsign again. This is N1MM's NextTab.
|
/// the callsign again. This is N1MM's NextTab.
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -209,14 +209,24 @@ public sealed class OperatingPosition
|
|||||||
&& !call.All(c => char.IsAsciiDigit(c) || c == '.')
|
&& !call.All(c => char.IsAsciiDigit(c) || c == '.')
|
||||||
&& !string.Equals(call, Me.Callsign, StringComparison.OrdinalIgnoreCase);
|
&& !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
|
/// 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()
|
public Qso LogContact()
|
||||||
{
|
{
|
||||||
if (!Entry.IsComplete)
|
if (!Entry.IsComplete)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete");
|
throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete");
|
||||||
}
|
}
|
||||||
|
if (EntryProblem() is { } problem)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(problem.Message);
|
||||||
|
}
|
||||||
StampSentTime();
|
StampSentTime();
|
||||||
Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim())));
|
Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim())));
|
||||||
Entry.Clear();
|
Entry.Clear();
|
||||||
@@ -384,13 +394,18 @@ public sealed class OperatingPosition
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Writes the boxes back to the contact being quick edited and leaves quick
|
/// 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()
|
public Qso? SaveQuickEdit()
|
||||||
{
|
{
|
||||||
if (Editing is null)
|
if (Editing is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (EntryProblem() is { } problem)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(problem.Message);
|
||||||
|
}
|
||||||
Qso saved = Noted(ApplyExchange(CountryFields.Apply(
|
Qso saved = Noted(ApplyExchange(CountryFields.Apply(
|
||||||
Editing with { Call = Callsign.Parse(Entry.Call.Trim()) }, Session.Countries)));
|
Editing with { Call = Callsign.Parse(Entry.Call.Trim()) }, Session.Countries)));
|
||||||
Session.Update(saved);
|
Session.Update(saved);
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ public sealed class QsoEditor
|
|||||||
private QsoEdit Applied(Qso qso, QsoField field, string text)
|
private QsoEdit Applied(Qso qso, QsoField field, string text)
|
||||||
{
|
{
|
||||||
string value = Normalize(field, text);
|
string value = Normalize(field, text);
|
||||||
if (KindError(field, value) is { } refused)
|
if (KindError(qso, field, value) is { } refused)
|
||||||
{
|
{
|
||||||
return QsoEdit.Refuse(refused);
|
return QsoEdit.Refuse(refused);
|
||||||
}
|
}
|
||||||
@@ -218,34 +218,13 @@ public sealed class QsoEditor
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Checks the value against the exchange field kind the contest defined.
|
/// Checks the value against the exchange field kind the contest defined.
|
||||||
/// Returns null if it is acceptable. An empty value always is.
|
/// Reports are left out: they are checked per field, because a WAE QTC row
|
||||||
private string? KindError(QsoField field, string value)
|
/// keeps a time and a call in the two report columns.
|
||||||
{
|
private string? KindError(Qso qso, QsoField field, string value) =>
|
||||||
if (value.Length == 0 || !exchangeKinds.TryGetValue(field, out ExchangeFieldKind kind))
|
exchangeKinds.TryGetValue(field, out ExchangeFieldKind kind)
|
||||||
{
|
&& kind != ExchangeFieldKind.Report
|
||||||
return null;
|
? ExchangeCheck.Error(kind, value, qso.Mode)
|
||||||
}
|
: 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;
|
|
||||||
|
|
||||||
private static string Kilohertz(Frequency frequency) =>
|
private static string Kilohertz(Frequency frequency) =>
|
||||||
frequency.Kilohertz.ToString("0.0", CultureInfo.InvariantCulture);
|
frequency.Kilohertz.ToString("0.0", CultureInfo.InvariantCulture);
|
||||||
|
|||||||
@@ -598,4 +598,54 @@ public class OperatingPositionTests
|
|||||||
Assert.Equal("JA1XYZ", session.Entry.Call);
|
Assert.Equal("JA1XYZ", session.Entry.Call);
|
||||||
Assert.False(session.QuickEdit(forward: false));
|
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<InvalidOperationException>(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());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user