diff --git a/src/Nonemm.Core/Mode.cs b/src/Nonemm.Core/Mode.cs index 879afeb..c29abca 100644 --- a/src/Nonemm.Core/Mode.cs +++ b/src/Nonemm.Core/Mode.cs @@ -3,5 +3,9 @@ namespace Nonemm.Core; /// One operating mode. `Name` is what goes in the log and must round-trip. public sealed record Mode(string Name, ModeCategory Category, string CabrilloCode, string AdifName) { + /// True for the weak-signal modes, which exchange a signal-to-noise ratio + /// in decibels rather than RST. + public bool ReportsDecibels { get; init; } + public override string ToString() => Name; } diff --git a/src/Nonemm.Core/Modes.cs b/src/Nonemm.Core/Modes.cs index 1b13c09..55b85b9 100644 --- a/src/Nonemm.Core/Modes.cs +++ b/src/Nonemm.Core/Modes.cs @@ -11,12 +11,17 @@ public static class Modes public static readonly Mode Rtty = new("RTTY", ModeCategory.Digital, "RY", "RTTY"); public static readonly Mode Psk31 = new("PSK31", ModeCategory.Digital, "DG", "PSK31"); public static readonly Mode Psk63 = new("PSK63", ModeCategory.Digital, "DG", "PSK63"); - public static readonly Mode Ft8 = new("FT8", ModeCategory.Digital, "DG", "FT8"); - public static readonly Mode Ft4 = new("FT4", ModeCategory.Digital, "DG", "FT4"); + public static readonly Mode Ft8 = + new("FT8", ModeCategory.Digital, "DG", "FT8") { ReportsDecibels = true }; + public static readonly Mode Ft4 = + new("FT4", ModeCategory.Digital, "DG", "FT4") { ReportsDecibels = true }; public static readonly Mode Mfsk = new("MFSK", ModeCategory.Digital, "DG", "MFSK"); - public static readonly Mode Jt65 = new("JT65", ModeCategory.Digital, "DG", "JT65"); - public static readonly Mode Msk144 = new("MSK144", ModeCategory.Digital, "DG", "MSK144"); - public static readonly Mode Q65 = new("Q65", ModeCategory.Digital, "DG", "Q65"); + public static readonly Mode Jt65 = + new("JT65", ModeCategory.Digital, "DG", "JT65") { ReportsDecibels = true }; + public static readonly Mode Msk144 = + new("MSK144", ModeCategory.Digital, "DG", "MSK144") { ReportsDecibels = true }; + public static readonly Mode Q65 = + new("Q65", ModeCategory.Digital, "DG", "Q65") { ReportsDecibels = true }; public static readonly Mode Digital = new("DIGI", ModeCategory.Digital, "DG", "DATA"); public static readonly IReadOnlyList All = diff --git a/src/Nonemm.Core/SignalReport.cs b/src/Nonemm.Core/SignalReport.cs new file mode 100644 index 0000000..7fcf0b8 --- /dev/null +++ b/src/Nonemm.Core/SignalReport.cs @@ -0,0 +1,37 @@ +using System.Globalization; + +namespace Nonemm.Core; + +/// Checks a signal report before it goes in the log. Most modes exchange RST; +/// the weak-signal modes exchange a signal-to-noise ratio in decibels instead. +public static class SignalReport +{ + private const int Weakest = -50; + private const int Strongest = 50; + + public static bool IsPlausible(string text, Mode mode) + { + string report = text.Trim(); + return mode.ReportsDecibels ? IsDecibels(report) : IsRst(report); + } + + /// What to type, for an error message. + public static string Example(Mode mode) => mode.ReportsDecibels + ? "-12" + : mode.Category == ModeCategory.Phone ? "59" : "599"; + + /// Readability 1 to 5, strength 1 to 9 and, on CW and the digital modes, + /// tone 1 to 9. Two figures pass on every mode: phone sends two, and an + /// operator on CW may drop the tone. + private static bool IsRst(string report) => + report.Length is 2 or 3 + && report[0] is >= '1' and <= '5' + && report[1] is >= '1' and <= '9' + && (report.Length == 2 || report[2] is >= '1' and <= '9'); + + private static bool IsDecibels(string report) => + int.TryParse(report, NumberStyles.AllowLeadingSign, + CultureInfo.InvariantCulture, out int decibels) + && decibels >= Weakest + && decibels <= Strongest; +} diff --git a/src/Nonemm.Session/QsoColumn.cs b/src/Nonemm.Session/QsoColumn.cs index 3fa998d..ca9f564 100644 --- a/src/Nonemm.Session/QsoColumn.cs +++ b/src/Nonemm.Session/QsoColumn.cs @@ -1,5 +1,6 @@ namespace Nonemm.Session; -/// One editable column of the log window. -/// `Width` is in characters. -public sealed record QsoColumn(string Label, QsoField Field, int Width); +/// One column of the log window. +/// `Width` is in characters. A column that is not editable is worked out from +/// the contact rather than typed in, such as the points it scored. +public sealed record QsoColumn(string Label, QsoField Field, int Width, bool IsEditable = true); diff --git a/src/Nonemm.Session/QsoEditor.cs b/src/Nonemm.Session/QsoEditor.cs index c296de1..00a8881 100644 --- a/src/Nonemm.Session/QsoEditor.cs +++ b/src/Nonemm.Session/QsoEditor.cs @@ -1,6 +1,7 @@ using System.Globalization; using Nonemm.Contests; using Nonemm.Contests.Multipliers; +using Nonemm.Contests.Rules; using Nonemm.Core; using Nonemm.Core.Country; @@ -22,7 +23,8 @@ public sealed class QsoEditor Columns = BuildColumns(contest, me, exchangeKinds); } - /// Log columns, left to right. + /// The log columns this contest opens with, left to right. The operator can + /// pick a different set; see `LogColumns`. public IReadOnlyList Columns { get; } public static string Read(Qso qso, QsoField field) => field switch @@ -52,6 +54,11 @@ public sealed class QsoEditor QsoField.WpxPrefix => qso.WpxPrefix, QsoField.RadioNumber => Digits(qso.RadioNumber), QsoField.RunPosition => Digits(qso.RunPosition), + QsoField.Points => qso.Points.ToString(CultureInfo.InvariantCulture), + QsoField.Multipliers => string.Concat( + qso.IsMultiplier1 ? "1" : "", + qso.IsMultiplier2 ? "2" : "", + qso.IsMultiplier3 ? "3" : ""), _ => "", }; @@ -94,9 +101,9 @@ public sealed class QsoEditor QsoField.QsxFrequency => WithFrequency(value, f => qso with { QsxFrequency = f }, allowEmpty: true), QsoField.Mode => WithMode(qso, value), QsoField.Call => WithCall(qso, value), - QsoField.SentReport => QsoEdit.Accept(qso with { SentReport = value }), + QsoField.SentReport => WithReport(qso, value, r => qso with { SentReport = r }), QsoField.SentNumber => Count(value, n => qso with { SentNumber = n }), - QsoField.ReceivedReport => QsoEdit.Accept(qso with { ReceivedReport = value }), + QsoField.ReceivedReport => WithReport(qso, value, r => qso with { ReceivedReport = r }), QsoField.ReceivedNumber => Count(value, n => qso with { ReceivedNumber = n }), QsoField.Zone => Count(value, n => qso with { Zone = n }), QsoField.Section => QsoEdit.Accept(qso with { Section = value }), @@ -106,7 +113,7 @@ public sealed class QsoEditor QsoField.MiscText => QsoEdit.Accept(qso with { MiscText = value }), QsoField.Name => QsoEdit.Accept(qso with { Name = value }), QsoField.Qth => QsoEdit.Accept(qso with { Qth = value }), - QsoField.GridSquare => QsoEdit.Accept(qso with { GridSquare = value }), + QsoField.GridSquare => WithGrid(qso, value), QsoField.Power => QsoEdit.Accept(qso with { Power = value }), QsoField.Comment => QsoEdit.Accept(qso with { Comment = value }), QsoField.Operator => QsoEdit.Accept(qso with { Operator = value }), @@ -165,6 +172,31 @@ public sealed class QsoEditor return QsoEdit.Accept(set(Frequency.FromKilohertz(kilohertz))); } + /// A WAE QTC row keeps the time sent and the other station's call in the + /// two report columns, so there is no report there to check. + private static QsoEdit WithReport(Qso qso, string value, Func set) + { + if (value.Length == 0 || WaeQtc.IsQtc(qso) || SignalReport.IsPlausible(value, qso.Mode)) + { + return QsoEdit.Accept(set(value)); + } + return QsoEdit.Refuse( + $"{value} is not a signal report on {qso.Mode.Name}; write it as {SignalReport.Example(qso.Mode)}"); + } + + /// The locator is stored the way it is written: upper case field and + /// square, lower case subsquare. + private static QsoEdit WithGrid(Qso qso, string value) + { + if (value.Length == 0) + { + return QsoEdit.Accept(qso with { GridSquare = "" }); + } + return GridSquare.TryParse(value, out GridSquare grid) + ? QsoEdit.Accept(qso with { GridSquare = grid.Text }) + : QsoEdit.Refuse($"{value} is not a grid square"); + } + private static QsoEdit WithMode(Qso qso, string value) { Mode? mode = Modes.Parse(value); @@ -237,11 +269,14 @@ public sealed class QsoEditor { columns.Add(new QsoColumn("SntNr", QsoField.SentNumber, 5)); } - foreach (ExchangeField field in contest.LogFieldsFor(me).Where(f => !f.IsSent)) + foreach (ExchangeField field in contest.LogFieldsFor(me)) { QsoField mapped = FieldFor(field.Slot); kinds[mapped] = field.Kind; - columns.Add(new QsoColumn(field.Label, mapped, Math.Max(field.Width, field.Label.Length + 1))); + if (!field.IsSent) + { + columns.Add(new QsoColumn(field.Label, mapped, Math.Max(field.Width, field.Label.Length + 1))); + } } if (contest.HasQtcTraffic) { @@ -253,11 +288,16 @@ public sealed class QsoEditor columns.Add(new QsoColumn("QTC?", QsoField.MiscText, 6)); } columns.Add(new QsoColumn("Op", QsoField.Operator, 8)); + columns.Add(new QsoColumn("Cty", QsoField.CountryPrefix, 6)); + columns.Add(new QsoColumn("Pts", QsoField.Points, 4, IsEditable: false)); + columns.Add(new QsoColumn("Mult", QsoField.Multipliers, 5, IsEditable: false)); return columns; } private static QsoField FieldFor(ExchangeSlot slot) => slot switch { + ExchangeSlot.SentReport => QsoField.SentReport, + ExchangeSlot.SentNumber => QsoField.SentNumber, ExchangeSlot.ReceivedReport => QsoField.ReceivedReport, ExchangeSlot.SerialNumber => QsoField.ReceivedNumber, ExchangeSlot.Zone => QsoField.Zone, diff --git a/src/Nonemm.Session/QsoField.cs b/src/Nonemm.Session/QsoField.cs index f750b18..260d76e 100644 --- a/src/Nonemm.Session/QsoField.cs +++ b/src/Nonemm.Session/QsoField.cs @@ -1,7 +1,8 @@ namespace Nonemm.Session; -/// A field of a logged contact that can be edited in the log window. The names -/// match the log columns. +/// A field of a logged contact the log window can show. Most can be edited; +/// `Points` and `Multipliers` are worked out from the rules and are read only. +/// The names match the log columns. public enum QsoField { Time, @@ -29,4 +30,6 @@ public enum QsoField WpxPrefix, RadioNumber, RunPosition, + Points, + Multipliers, } diff --git a/tests/Nonemm.Core.Tests/SignalReportTests.cs b/tests/Nonemm.Core.Tests/SignalReportTests.cs new file mode 100644 index 0000000..abf13f1 --- /dev/null +++ b/tests/Nonemm.Core.Tests/SignalReportTests.cs @@ -0,0 +1,41 @@ +namespace Nonemm.Core.Tests; + +public class SignalReportTests +{ + [Theory] + [InlineData("599")] + [InlineData("59")] + [InlineData("339")] + [InlineData("111")] + public void AnRstReportIsTaken(string report) => + Assert.True(SignalReport.IsPlausible(report, Modes.Cw)); + + [Theory] + [InlineData("nice signal")] + [InlineData("5")] + [InlineData("5999")] + [InlineData("699")] + [InlineData("509")] + [InlineData("590")] + [InlineData("5N9")] + public void TextThatIsNotAnRstReportIsRefused(string report) => + Assert.False(SignalReport.IsPlausible(report, Modes.Cw)); + + /// FT8 and the other weak-signal modes exchange a signal-to-noise ratio. + [Theory] + [InlineData("-12", true)] + [InlineData("+03", true)] + [InlineData("0", true)] + [InlineData("599", false)] + [InlineData("-99", false)] + public void AWeakSignalModeReportsDecibels(string report, bool taken) => + Assert.Equal(taken, SignalReport.IsPlausible(report, Modes.Ft8)); + + [Fact] + public void RttyStillReportsRst() => + Assert.True(SignalReport.IsPlausible("599", Modes.Rtty)); + + [Fact] + public void ThePhoneExampleHasNoTone() => + Assert.Equal("59", SignalReport.Example(Modes.Usb)); +} diff --git a/tests/Nonemm.Session.Tests/QsoEditorTests.cs b/tests/Nonemm.Session.Tests/QsoEditorTests.cs index 75e684d..4b0d9b1 100644 --- a/tests/Nonemm.Session.Tests/QsoEditorTests.cs +++ b/tests/Nonemm.Session.Tests/QsoEditorTests.cs @@ -208,6 +208,57 @@ public class QsoEditorTests public void TheWorkingFrequencyCannotBeCleared() => Assert.False(CqWw().Apply(Contact(), QsoField.Frequency, "").IsAccepted); + [Fact] + public void ArbitraryTextInTheReportColumnIsRefused() + { + QsoEdit edit = CqWw().Apply(Contact(), QsoField.ReceivedReport, "good copy"); + + Assert.False(edit.IsAccepted); + Assert.Contains("signal report", edit.Error); + Assert.Equal(QsoField.ReceivedReport, edit.Field); + } + + [Fact] + public void TheSentReportIsCheckedTheSameWay() => + Assert.False(CqWw().Apply(Contact(), QsoField.SentReport, "699").IsAccepted); + + [Fact] + public void AReportCanStillBeCorrected() => + Assert.Equal("339", CqWw().Apply(Contact(), QsoField.ReceivedReport, "339").Result!.ReceivedReport); + + /// FT8 exchanges a signal-to-noise ratio, so RST is the wrong thing there + /// and decibels are the wrong thing on CW. + [Fact] + public void AWeakSignalContactReportsDecibels() + { + Qso ft8 = Contact() with { Mode = Modes.Ft8, ReceivedReport = "-12" }; + + Assert.True(CqWw().Apply(ft8, QsoField.ReceivedReport, "-07").IsAccepted); + Assert.False(CqWw().Apply(ft8, QsoField.ReceivedReport, "599").IsAccepted); + Assert.False(CqWw().Apply(Contact(), QsoField.ReceivedReport, "-07").IsAccepted); + } + + /// A WAE QTC row keeps the time sent and the other station's call in the + /// two report columns, so those are not reports to check. + [Fact] + public void TheReportColumnsOfAQtcRowAreLeftAlone() + { + QsoEditor editor = new(new Wae(ModeCategory.Cw), Me, CountryFile.Parse(Countries)); + Qso traffic = Contact() with { ContestName = "WAEDC", Exchange1 = "SQTC", MiscText = "3/10" }; + + Assert.True(editor.Apply(traffic, QsoField.SentReport, "1234").IsAccepted); + Assert.True(editor.Apply(traffic, QsoField.ReceivedReport, "DL1ABC").IsAccepted); + } + + [Fact] + public void TextThatIsNotALocatorIsRefused() => + Assert.False(CqWw().Apply(Contact(), QsoField.GridSquare, "nowhere").IsAccepted); + + /// A locator is stored the way it is written, whatever case it was typed in. + [Fact] + public void ALocatorIsStoredWithALowerCaseSubsquare() => + Assert.Equal("JN88dd", CqWw().Apply(Contact(), QsoField.GridSquare, "jn88dd").Result!.GridSquare); + [Fact] public void ACorrectedCountryPrefixIsKept() => Assert.Equal("DL", CqWw().Apply(Contact(), QsoField.CountryPrefix, "dl").Result!.CountryPrefix);