From 1fda1696f2b253cf4d9f2478a82ecc051615be93 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 27 Aug 2026 18:15:41 +0000 Subject: [PATCH] Read call history files, and fill the exchange from them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N1MM's format, so a file published for a contest is read as it is: # comments, !!Order!! naming the columns of the lines after it, semicolons in preference to commas so user text can hold a comma, and a default column order for a file that names none. The directives that change what is stored are acted on; !!Order!! can appear again part way through, because these files are usually several files stuck together. Leaving the callsign box fills what the file knows into the exchange boxes that are still empty. A box with something in it is left alone: what the station actually sends beats what somebody published months ago. The zone comes from the CqZone or the ITUZone column depending on which the contest asks for, and a file with no Sect falls back to State. The check window's two missing columns are in. History lists matching calls from the file. Exchange is not about callsigns at all: it offers the values the exchange box with the cursor in it can hold, which is what N1MM's exchange pane does — it searches the contest's list of valid exchanges, not the log. CheckCandidate.Call is now Text, because an exchange value is not a call. The section-validating directives are read and passed over rather than acted on. Acting on them means holding N1MM's section lists and its rules about retired sections, and getting that wrong throws away good data. Running it caught what the unit tests could not: the exchange was filled and scored but the boxes on screen still looked empty, because nothing resynced them. Co-Authored-By: Claude Opus 5 --- README.md | 31 ++- docs/unfinished.md | 10 +- src/Nonemm.App/AppSession.cs | 25 ++- src/Nonemm.App/Configuration/Settings.cs | 4 + src/Nonemm.App/Windows/CheckWindow.axaml.cs | 18 +- src/Nonemm.App/Windows/EntryWindow.Menu.cs | 25 ++- src/Nonemm.App/Windows/EntryWindow.axaml | 2 + src/Nonemm.App/Windows/EntryWindow.axaml.cs | 6 + src/Nonemm.Contests/ExchangeValues.cs | 22 ++ src/Nonemm.Core/Calls/CallHistory.cs | 167 +++++++++++++++ src/Nonemm.Core/Calls/CallHistoryEntry.cs | 35 ++++ src/Nonemm.Session/CheckCandidate.cs | 15 +- src/Nonemm.Session/CheckWindowSources.cs | 37 +++- src/Nonemm.Session/ContestSession.cs | 8 +- src/Nonemm.Session/RadioPosition.cs | 48 +++++ .../Calls/CallHistoryTests.cs | 190 ++++++++++++++++++ .../RadioPositionTests.cs | 87 ++++++++ 17 files changed, 706 insertions(+), 24 deletions(-) create mode 100644 src/Nonemm.Contests/ExchangeValues.cs create mode 100644 src/Nonemm.Core/Calls/CallHistory.cs create mode 100644 src/Nonemm.Core/Calls/CallHistoryEntry.cs create mode 100644 tests/Nonemm.Core.Tests/Calls/CallHistoryTests.cs diff --git a/README.md b/README.md index 76a4240..33e6cf1 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ scorer: red for a dupe, green for a new multiplier, blue for points. |---|---| | Contests | CQ WW, CQ WPX, ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, general logging, and user-defined `.udc` contests | | Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out | -| While typing | dupe check, multiplier check, points, country and zone from the country file | +| While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file | | Windows | entry, log, check, bandmap, score summary, packet | | Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations | | Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own | @@ -120,6 +120,35 @@ Correcting the country prefix by hand changes the score. The country file is a best guess for calls it has no rule for, so what the contact says now wins over what the file says. +### Call history files + +**Config → Call History File…** points at one of the files published before a +contest. It is N1MM's format, so a file written for N1MM is read as it is: +`#` comments, `!!Order!!` naming the columns of the lines after it, semicolons +in preference to commas, and the directives that change what is stored — +`!!FourCharGridSq!!`, `!!NoLoc2AltGrid!!`, `!!MapStateToSect!!`, +`!!AppendUserText!!` and `!!NoAppendUserText!!`. + +Leaving the callsign box fills what the file knows into the exchange boxes that +are still empty. A box with something in it is left alone: what the other +station actually sends beats what somebody published months ago. The check +window gets a History column of matching calls. + +The section-validating directives — `!!Validate50State!!`, +`!!ValidateArrlSection!!`, `!!MapOnSection!!`, `!!GTA2GH_NT2TER!!` — are read +and passed over rather than acted on. Throwing data away on a section list this +program does not hold would be worse than keeping it. + +### The check window + +Five columns, kept apart because they answer different questions: **Log** is +what this station copied itself, **Master** is a guess about the whole world, +**History** is what was published before the contest, **Bandmap** is somebody +else's claim, and **Exchange** offers the values the exchange box with the +cursor in it can hold — the ARRL sections, or the states and provinces. A +serial number or a report has no list behind it, so that column stays empty +rather than guessing. + ### Radios **Config → Radios** takes a `rigctld` address per radio. Each radio needs its diff --git a/docs/unfinished.md b/docs/unfinished.md index c9ce077..c1e80f5 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -33,11 +33,11 @@ of the text would be a guess that goes wrong exactly when the contest is busy. recording, and nothing implements it. No DVK support either, so the second radio of an SO2R station cannot call CQ by voice. -**The check window's Call History and Exchange columns.** Left out rather than -shown empty: there is no call history file support to fill them from. - -**Call history files.** Not read at all. They are what fills a known station's -name, state or zone in as soon as the call is typed. +**Call history: the section-validating directives.** `!!Validate50State!!`, +`!!ValidateArrlSection!!`, `!!MapOnSection!!` and `!!GTA2GH_NT2TER!!` are read +and passed over. Acting on them means holding N1MM's section lists and its +rules about which sections are retired, and getting that wrong throws away good +data. The file is read without them. **QTC handling for WAE.** Not started. WAE logs will not be complete without it. diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index a550a8d..19ed2bd 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -33,6 +33,7 @@ public sealed class AppSession : IDisposable paths.CreateFolders(); Countries = LoadCountryFile(paths.CountryFile); Calls = LoadCallDatabase(paths.CallDatabaseFile); + History = LoadCallHistory(settings.CallHistoryFile); Registry = ContestRegistry.FromFolder(paths.UserDefinedContests, out IReadOnlyList problems); UserDefinedContestProblems = problems; } @@ -49,6 +50,10 @@ public sealed class AppSession : IDisposable public CallDatabase Calls { get; private set; } + /// What was published about the stations in this contest. Empty when no + /// file is chosen, and then nothing is filled in for the operator. + public CallHistory History { get; private set; } = CallHistory.Empty; + public Bandmap Bandmap { get; } = new(); /// The contest in progress: one log and one score, however many radios. @@ -133,7 +138,8 @@ public sealed class AppSession : IDisposable ?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log"); Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance)); SaveDefinition(contest); - Logging = new ContestSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries); + Logging = new ContestSession( + Store, contest, instance, Settings.Station.ToStationInfo(), Countries, History); positions.Clear(); for (int number = 1; number <= PositionCount; number++) { @@ -384,6 +390,7 @@ public sealed class AppSession : IDisposable { Countries = LoadCountryFile(Paths.CountryFile); Calls = LoadCallDatabase(Paths.CallDatabaseFile); + History = LoadCallHistory(Settings.CallHistoryFile); Registry = ContestRegistry.FromFolder(Paths.UserDefinedContests, out _); if (Logging is not null) { @@ -424,6 +431,22 @@ public sealed class AppSession : IDisposable } } + /// A file that will not parse is reported by coming back empty rather than + /// stopping the program before a contest. + private static CallHistory LoadCallHistory(string path) + { + try + { + return path.Length > 0 && File.Exists(path) + ? CallHistory.Parse(File.ReadAllText(path)) + : CallHistory.Empty; + } + catch (Exception e) when (e is FormatException or IOException) + { + return CallHistory.Empty; + } + } + private static CallDatabase LoadCallDatabase(string path) { try diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 60fb9ed..1cb6620 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -43,6 +43,10 @@ public sealed record Settings public IReadOnlyList NetworkPeers { get; init; } = []; + /// The call history file for this contest, or empty for none. They are + /// published per contest, so this is not a fixed name. + public string CallHistoryFile { get; init; } = ""; + /// The serial port of an SO2R box speaking OTRSP, or empty for none. The /// box routes the transmitter and the headphones between the two radios. public string So2rBoxPort { get; init; } = ""; diff --git a/src/Nonemm.App/Windows/CheckWindow.axaml.cs b/src/Nonemm.App/Windows/CheckWindow.axaml.cs index deeddc1..f04c5aa 100644 --- a/src/Nonemm.App/Windows/CheckWindow.axaml.cs +++ b/src/Nonemm.App/Windows/CheckWindow.axaml.cs @@ -11,12 +11,10 @@ namespace Nonemm.App.Windows; public sealed partial class CheckWindow : RefreshableWindow { private readonly AppSession session; - private readonly Func typed; - public CheckWindow(AppSession session, Func typed) + public CheckWindow(AppSession session) { this.session = session; - this.typed = typed; InitializeComponent(); Refresh(); } @@ -29,8 +27,14 @@ public sealed partial class CheckWindow : RefreshableWindow { return; } + IReadOnlyList columns = session.Check.Columns(); + Columns.ColumnDefinitions.Clear(); + foreach (CheckColumn _ in columns) + { + Columns.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); + } int at = 0; - foreach (CheckColumn column in session.Check.Columns(typed())) + foreach (CheckColumn column in columns) { Control panel = BuildColumn(column); Grid.SetColumn(panel, at++); @@ -52,7 +56,7 @@ public sealed partial class CheckWindow : RefreshableWindow { panel.Children.Add(new TextBlock { - Text = candidate.Call, + Text = candidate.Text, FontFamily = new FontFamily("monospace"), FontSize = 14, Foreground = Verdicts.Colour(candidate.Verdict), @@ -71,6 +75,8 @@ public sealed partial class CheckWindow : RefreshableWindow { CheckSource.Log => "Log", CheckSource.Database => "Master", - _ => "Bandmap", + CheckSource.History => "History", + CheckSource.Bandmap => "Bandmap", + _ => "Exchange", }; } diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs index 50e475a..5d50ac3 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs @@ -226,7 +226,7 @@ public sealed partial class EntryWindow private void OnShowLog(object? sender, RoutedEventArgs e) => Show(() => new LogWindow(session)); - private void OnShowCheck(object? sender, RoutedEventArgs e) => Show(() => new CheckWindow(session, () => Logging?.Entry.Call ?? "")); + private void OnShowCheck(object? sender, RoutedEventArgs e) => Show(() => new CheckWindow(session)); private void OnShowBandmap(object? sender, RoutedEventArgs e) => Show(() => new BandmapWindow(session, Tune)); @@ -329,6 +329,27 @@ public sealed partial class EntryWindow BuildFunctionKeys(); } + /// Call history files are published per contest, so the operator points at + /// one rather than the program looking in a fixed place. + private async void OnCallHistoryFile(object? sender, RoutedEventArgs e) + { + IReadOnlyList files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Call history file", + AllowMultiple = false, + SuggestedStartLocation = await Folder(session.Paths.SupportFiles), + }); + if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path) + { + return; + } + session.Save(session.Settings with { CallHistoryFile = path }); + session.ReloadSupportFiles(); + Status(session.History.Count > 0 + ? $"{session.History.Count} calls read from {Path.GetFileName(path)}" + : $"{Path.GetFileName(path)} holds no calls we could read"); + } + private async void OnDownloadCountryFile(object? sender, RoutedEventArgs e) => await Download( downloader => downloader.DownloadCountryFileAsync(session.Paths.CountryFile), @@ -343,7 +364,7 @@ public sealed partial class EntryWindow { session.ReloadSupportFiles(); Status($"country file {(session.Countries is null ? "missing" : "loaded")}, " + - $"{session.Calls.Count} callsigns"); + $"{session.Calls.Count} callsigns, {session.History.Count} in the call history"); } private async Task Download(Func> fetch, string what) diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml index e4efa68..df470d3 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml +++ b/src/Nonemm.App/Windows/EntryWindow.axaml @@ -57,6 +57,8 @@ + + diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index ff39a0a..39056e1 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -315,6 +315,12 @@ public sealed partial class EntryWindow : Window } if (forward) { + // leaving the callsign box is the moment the call is settled, which + // is when the call history can say what the exchange will be + if (Logging.Entry.Focus == 0 && Logging.FillFromHistory()) + { + SyncBoxes(); + } Logging.Entry.Advance(); } else diff --git a/src/Nonemm.Contests/ExchangeValues.cs b/src/Nonemm.Contests/ExchangeValues.cs new file mode 100644 index 0000000..aa14047 --- /dev/null +++ b/src/Nonemm.Contests/ExchangeValues.cs @@ -0,0 +1,22 @@ +using Nonemm.Contests.Multipliers; + +namespace Nonemm.Contests; + +/// Every value an exchange field can hold, where the field has a list behind +/// it. A serial number or a report has none, so those get an empty list rather +/// than a guess. +public static class ExchangeValues +{ + private static readonly IReadOnlyList StatesAndProvincesList = + [.. StatesAndProvinces.AllStates.Concat(StatesAndProvinces.Provinces).Order(StringComparer.Ordinal)]; + + private static readonly IReadOnlyList Sections = + [.. ArrlSections.All.Order(StringComparer.Ordinal)]; + + public static IReadOnlyList For(ExchangeFieldKind kind) => kind switch + { + ExchangeFieldKind.ArrlSection => Sections, + ExchangeFieldKind.UsStateOrCanadianProvince => StatesAndProvincesList, + _ => [], + }; +} diff --git a/src/Nonemm.Core/Calls/CallHistory.cs b/src/Nonemm.Core/Calls/CallHistory.cs new file mode 100644 index 0000000..1da940a --- /dev/null +++ b/src/Nonemm.Core/Calls/CallHistory.cs @@ -0,0 +1,167 @@ +using System.Globalization; + +namespace Nonemm.Core.Calls; + +/// A call history file: what is already known about a station before it sends +/// its exchange. N1MM's format, so the files published for a contest can be +/// used as they are. +/// +/// Lines starting with `#` are comments. A line whose first field is `!!Name!!` +/// is a directive rather than data, and `!!Order!!` names the columns of the +/// lines that follow. Fields are separated by semicolons if the line holds one, +/// and by commas otherwise. +public sealed class CallHistory +{ + /// The columns a file is read with when it names none. + private static readonly string[] DefaultOrder = + [ + "CALL", "NAME", "LOC1", "LOC2", "SECT", "STATE", "CK", + "BIRTHDATE", "EXCH1", "MISC", "POWER", "CQZONE", "ITUZONE", "USERTEXT", + ]; + + private readonly Dictionary entries; + + private CallHistory(Dictionary entries) => this.entries = entries; + + public static readonly CallHistory Empty = new([]); + + public int Count => entries.Count; + + /// Null for a station the file says nothing about. + public CallHistoryEntry? Find(string call) => + entries.TryGetValue(call.Trim().ToUpperInvariant(), out CallHistoryEntry? found) + ? found + : null; + + /// Every call in the file, for the check window to match against. + public IReadOnlyCollection Calls => entries.Keys; + + public static CallHistory Parse(string text) + { + Dictionary found = new(StringComparer.Ordinal); + string[] order = DefaultOrder; + bool fourCharacterGrid = false; + bool useAlternateGrid = true; + bool mapStateToSection = false; + bool appendUserText = true; + + foreach (string line in text.Split('\n')) + { + string trimmed = line.Trim(); + if (trimmed.Length == 0 || trimmed.StartsWith('#')) + { + continue; + } + string[] fields = Split(trimmed); + string first = fields[0].Trim().ToUpperInvariant(); + if (first.StartsWith("!!", StringComparison.Ordinal)) + { + switch (first) + { + case "!!ORDER!!": + order = [.. fields.Skip(1).Select(f => f.Trim().ToUpperInvariant())]; + break; + case "!!FOURCHARGRIDSQ!!": + fourCharacterGrid = true; + break; + case "!!NOLOC2ALTGRID!!": + useAlternateGrid = false; + break; + case "!!MAPSTATETOSECT!!": + mapStateToSection = true; + break; + case "!!APPENDUSERTEXT!!": + appendUserText = true; + break; + case "!!NOAPPENDUSERTEXT!!": + appendUserText = false; + break; + } + // the rest are validation directives, and validating a section + // list we do not hold would throw data away + continue; + } + CallHistoryEntry? entry = Read(fields, order, fourCharacterGrid, useAlternateGrid, mapStateToSection); + if (entry is not null) + { + found[entry.Call] = Merge(found.GetValueOrDefault(entry.Call), entry, appendUserText); + } + } + return new CallHistory(found); + } + + /// A call can appear more than once. Later lines win, except that user text + /// is added to rather than replaced unless the file says otherwise. + private static CallHistoryEntry Merge(CallHistoryEntry? earlier, CallHistoryEntry later, bool append) + { + if (earlier is null) + { + return later; + } + if (!append || earlier.UserText.Length == 0 || later.UserText.Length == 0) + { + return later; + } + return later with { UserText = $"{earlier.UserText} {later.UserText}" }; + } + + private static CallHistoryEntry? Read( + string[] fields, + string[] order, + bool fourCharacterGrid, + bool useAlternateGrid, + bool mapStateToSection) + { + string call = At(fields, order, "CALL").ToUpperInvariant(); + if (call.Length == 0) + { + return null; + } + string section = At(fields, order, "SECT"); + string state = At(fields, order, "STATE"); + return new CallHistoryEntry + { + Call = call, + Name = At(fields, order, "NAME"), + GridSquare = Grid(At(fields, order, "LOC1"), fourCharacterGrid), + AlternateGridSquare = useAlternateGrid + ? Grid(At(fields, order, "LOC2"), fourCharacterGrid) + : "", + Section = section.Length == 0 && mapStateToSection ? state : section, + State = state, + Check = Number(At(fields, order, "CK")), + Exchange1 = At(fields, order, "EXCH1"), + MiscText = At(fields, order, "MISC", "MISCTEXT"), + Power = At(fields, order, "POWER"), + CqZone = Number(At(fields, order, "CQZONE")), + ItuZone = Number(At(fields, order, "ITUZONE")), + UserText = At(fields, order, "USERTEXT"), + }; + } + + /// N1MM takes semicolons in preference to commas, so a file whose user text + /// holds commas can still be read. + private static string[] Split(string line) => + line.Contains(';', StringComparison.Ordinal) ? line.Split(';') : line.Split(','); + + private static string At(string[] fields, string[] order, params string[] names) + { + foreach (string name in names) + { + int at = Array.IndexOf(order, name); + if (at >= 0 && at < fields.Length) + { + return fields[at].Trim(); + } + } + return ""; + } + + private static string Grid(string grid, bool fourCharacters) => + fourCharacters && grid.Length > 4 ? grid[..4].ToUpperInvariant() : grid.ToUpperInvariant(); + + private static int Number(string value) => + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int number) + ? number + : 0; +} diff --git a/src/Nonemm.Core/Calls/CallHistoryEntry.cs b/src/Nonemm.Core/Calls/CallHistoryEntry.cs new file mode 100644 index 0000000..4c9feff --- /dev/null +++ b/src/Nonemm.Core/Calls/CallHistoryEntry.cs @@ -0,0 +1,35 @@ +namespace Nonemm.Core.Calls; + +/// What a call history file says about one station. The fields are N1MM's, so +/// a file written for N1MM means the same thing here. +public sealed record CallHistoryEntry +{ + public required string Call { get; init; } + + public string Name { get; init; } = ""; + + /// `Loc1` in the file. + public string GridSquare { get; init; } = ""; + + /// `Loc2`: a second grid for a station that operates from two places. + public string AlternateGridSquare { get; init; } = ""; + + public string Section { get; init; } = ""; + + public string State { get; init; } = ""; + + /// The two-digit year of first licensing, which Sweepstakes exchanges. + public int Check { get; init; } + + public string Exchange1 { get; init; } = ""; + + public string MiscText { get; init; } = ""; + + public string Power { get; init; } = ""; + + public int CqZone { get; init; } + + public int ItuZone { get; init; } + + public string UserText { get; init; } = ""; +} diff --git a/src/Nonemm.Session/CheckCandidate.cs b/src/Nonemm.Session/CheckCandidate.cs index e3f8bbc..a431f0d 100644 --- a/src/Nonemm.Session/CheckCandidate.cs +++ b/src/Nonemm.Session/CheckCandidate.cs @@ -3,19 +3,24 @@ using Nonemm.Core.Calls; namespace Nonemm.Session; -/// One callsign the check window offers, with what the log says about it so it -/// is coloured the same as everywhere else. -public sealed record CheckCandidate(string Call, PartialMatchQuality Quality, Verdict? Verdict); +/// One thing the check window offers: a callsign in most columns, an exchange +/// value in the exchange column. `Verdict` is what the log says about a call, +/// so it is coloured the same as everywhere else, and null for anything that is +/// not a call. +public sealed record CheckCandidate(string Text, PartialMatchQuality Quality, Verdict? Verdict); /// Where a column of candidates came from. They stay apart because they answer /// different questions: the log is what this station copied itself, the -/// database is a guess about the whole world, and the bandmap is somebody -/// else's claim. +/// database is a guess about the whole world, the call history is what was +/// published before the contest, the bandmap is somebody else's claim, and the +/// exchange column is not about callsigns at all. public enum CheckSource { Log, Database, + History, Bandmap, + Exchange, } /// One column of the check window. diff --git a/src/Nonemm.Session/CheckWindowSources.cs b/src/Nonemm.Session/CheckWindowSources.cs index e0d8ca7..72cb87c 100644 --- a/src/Nonemm.Session/CheckWindowSources.cs +++ b/src/Nonemm.Session/CheckWindowSources.cs @@ -19,9 +19,12 @@ public sealed class CheckWindowSources this.bandmap = bandmap; } - public IReadOnlyList Columns(string typed, int limit = 24) + /// The columns are read off what the operator has typed: the callsign box + /// feeds the call columns, and whichever exchange box has the cursor feeds + /// the exchange column. + public IReadOnlyList Columns(int limit = 24) { - string query = typed.Trim().ToUpperInvariant(); + string query = session.Entry.Call.Trim().ToUpperInvariant(); return [ Column(CheckSource.Log, LoggedCalls(), query, limit), @@ -31,10 +34,36 @@ public sealed class CheckWindowSources query.Length < PartialMatching.ShortestUsefulQuery ? [] : database.Matches(query, limit).Select(Judged).ToList()), + Column(CheckSource.History, HistoryCalls(), query, limit), Column(CheckSource.Bandmap, SpottedCalls(), query, limit), + ExchangeColumn(limit), ]; } + /// What the exchange box with the cursor in it could hold: the ARRL + /// sections, or the states and provinces. A serial number or a report has + /// no list behind it, so the column stays empty. + private CheckColumn ExchangeColumn(int limit) + { + int focus = session.Entry.Focus; + if (focus == 0) + { + return new CheckColumn(CheckSource.Exchange, 0, []); + } + ExchangeField field = session.Entry.Exchange[focus - 1]; + IReadOnlyList values = ExchangeValues.For(field.Kind); + string query = session.Entry[focus].Trim().ToUpperInvariant(); + if (query.Length == 0) + { + return new CheckColumn(CheckSource.Exchange, values.Count, []); + } + List found = [.. values + .Where(v => v.StartsWith(query, StringComparison.OrdinalIgnoreCase)) + .Take(limit) + .Select(v => new CheckCandidate(v, PartialMatchQuality.Exact, null))]; + return new CheckColumn(CheckSource.Exchange, values.Count, found); + } + private CheckColumn Column( CheckSource source, IReadOnlyList calls, @@ -57,7 +86,7 @@ public sealed class CheckWindowSources return new CheckColumn( source, calls.Count, - found.OrderBy(c => c.Quality).ThenBy(c => c.Call, StringComparer.Ordinal).Take(limit).ToList()); + found.OrderBy(c => c.Quality).ThenBy(c => c.Text, StringComparer.Ordinal).Take(limit).ToList()); } private CheckCandidate Judged(PartialMatch match) => @@ -81,6 +110,8 @@ public sealed class CheckWindowSources private IReadOnlyList LoggedCalls() => session.Log.Qsos.Select(q => q.Call.Text).Distinct(StringComparer.Ordinal).ToList(); + private IReadOnlyList HistoryCalls() => [.. session.Session.History.Calls]; + private IReadOnlyList SpottedCalls() => bandmap.All().Select(s => s.Call.Text).Distinct(StringComparer.Ordinal).ToList(); } diff --git a/src/Nonemm.Session/ContestSession.cs b/src/Nonemm.Session/ContestSession.cs index 2ab60a6..89b4e04 100644 --- a/src/Nonemm.Session/ContestSession.cs +++ b/src/Nonemm.Session/ContestSession.cs @@ -1,5 +1,6 @@ using Nonemm.Contests; using Nonemm.Core; +using Nonemm.Core.Calls; using Nonemm.Core.Country; using Nonemm.Storage; @@ -20,10 +21,12 @@ public sealed class ContestSession Contest contest, ContestInstance instance, StationInfo me, - CountryFile? countries) + CountryFile? countries, + CallHistory? history = null) { this.store = store; Countries = countries; + History = history ?? CallHistory.Empty; Contest = contest; Instance = instance; Me = me; @@ -45,6 +48,9 @@ public sealed class ContestSession public CountryFile? Countries { get; } + /// What was published about the stations in this contest before it started. + public CallHistory History { get; } + /// Serial numbers count up across the station, not per radio. public int SentNumber { get; private set; } diff --git a/src/Nonemm.Session/RadioPosition.cs b/src/Nonemm.Session/RadioPosition.cs index 4802313..bb5046e 100644 --- a/src/Nonemm.Session/RadioPosition.cs +++ b/src/Nonemm.Session/RadioPosition.cs @@ -1,5 +1,6 @@ using Nonemm.Contests; using Nonemm.Core; +using Nonemm.Core.Calls; using Nonemm.Core.Country; using Nonemm.Storage; @@ -87,6 +88,53 @@ public sealed class RadioPosition return stored; } + /// Fills what the call history knows into the exchange boxes that are still + /// empty. What the other station actually sends wins, so a box with + /// something in it is left alone. Returns true when anything was filled. + public bool FillFromHistory() + { + CallHistoryEntry? known = Session.History.Find(Entry.Call); + if (known is null) + { + return false; + } + bool filled = false; + for (int at = 0; at < Entry.Exchange.Count; at++) + { + string value = ValueFor(known, Entry.Exchange[at]); + if (value.Length == 0 || Entry[at + 1].Trim().Length > 0) + { + continue; + } + Entry[at + 1] = value; + filled = true; + } + if (filled) + { + Session.NotifyChanged(); + } + return filled; + } + + private static string ValueFor(CallHistoryEntry known, ExchangeField field) => field.Slot switch + { + ExchangeSlot.Name => known.Name, + // a file for a section contest often carries the state instead + ExchangeSlot.Section => known.Section.Length > 0 ? known.Section : known.State, + ExchangeSlot.Check => Digits(known.Check), + ExchangeSlot.Zone => field.Kind == ExchangeFieldKind.ItuZone + ? Digits(known.ItuZone) + : Digits(known.CqZone), + ExchangeSlot.Exchange1 => known.Exchange1, + ExchangeSlot.MiscText => known.MiscText, + ExchangeSlot.GridSquare => known.GridSquare, + ExchangeSlot.Power => known.Power, + ExchangeSlot.Qth => known.State, + _ => "", + }; + + private static string Digits(int value) => value > 0 ? value.ToString() : ""; + public void Wipe() { Entry.Clear(); diff --git a/tests/Nonemm.Core.Tests/Calls/CallHistoryTests.cs b/tests/Nonemm.Core.Tests/Calls/CallHistoryTests.cs new file mode 100644 index 0000000..d2cc84f --- /dev/null +++ b/tests/Nonemm.Core.Tests/Calls/CallHistoryTests.cs @@ -0,0 +1,190 @@ +using Nonemm.Core.Calls; + +namespace Nonemm.Core.Tests.Calls; + +public class CallHistoryTests +{ + [Fact] + public void AFileWithNoOrderLineIsReadInTheDefaultOrder() + { + CallHistory history = CallHistory.Parse( + "OM5M,Erik,JN88,,,,,,15,,100,15,28,notes"); + + CallHistoryEntry? found = history.Find("OM5M"); + + Assert.Equal("Erik", found?.Name); + Assert.Equal("JN88", found?.GridSquare); + Assert.Equal("15", found?.Exchange1); + Assert.Equal("100", found?.Power); + Assert.Equal(15, found?.CqZone); + Assert.Equal(28, found?.ItuZone); + } + + [Fact] + public void AnOrderLineNamesTheColumns() + { + CallHistory history = CallHistory.Parse(""" + !!Order!!,Call,Sect,Name + W3LPL,MDC,Frank + """); + + Assert.Equal("MDC", history.Find("W3LPL")?.Section); + Assert.Equal("Frank", history.Find("W3LPL")?.Name); + } + + [Fact] + public void CommentsAndBlankLinesArePassedOver() + { + CallHistory history = CallHistory.Parse(""" + # a comment + # another + + !!Order!!,Call,Name + OM5M,Erik + """); + + Assert.Equal(1, history.Count); + } + + /// N1MM takes semicolons in preference to commas, so user text holding a + /// comma can still be read. + [Fact] + public void ALineWithSemicolonsIsSplitOnThose() + { + CallHistory history = CallHistory.Parse(""" + !!Order!!;Call;Name;UserText + OM5M;Erik;works 20m, mostly CW + """); + + Assert.Equal("Erik", history.Find("OM5M")?.Name); + Assert.Equal("works 20m, mostly CW", history.Find("OM5M")?.UserText); + } + + [Fact] + public void AnUnknownCallIsNotFound() => + Assert.Null(CallHistory.Parse("OM5M,Erik").Find("JA1XYZ")); + + [Fact] + public void TheCallIsMatchedWhateverCaseItIsTypedIn() => + Assert.NotNull(CallHistory.Parse("om5m,Erik").Find("OM5M")); + + [Fact] + public void AGridIsCutToFourCharactersWhenTheFileAsksForIt() + { + CallHistory history = CallHistory.Parse(""" + !!FourCharGridSq!! + !!Order!!,Call,Loc1 + OM5M,JN88TG + """); + + Assert.Equal("JN88", history.Find("OM5M")?.GridSquare); + } + + [Fact] + public void TheSecondGridIsDroppedWhenTheFileAsksForIt() + { + CallHistory history = CallHistory.Parse(""" + !!NoLoc2AltGrid!! + !!Order!!,Call,Loc1,Loc2 + OM5M,JN88,JN99 + """); + + Assert.Equal("", history.Find("OM5M")?.AlternateGridSquare); + } + + [Fact] + public void AnEmptySectionIsFilledFromTheStateWhenTheFileAsksForIt() + { + CallHistory history = CallHistory.Parse(""" + !!MapStateToSect!! + !!Order!!,Call,Sect,State + K1TTT,,CT + """); + + Assert.Equal("CT", history.Find("K1TTT")?.Section); + } + + [Fact] + public void AStateDoesNotOverwriteASectionThatIsThere() + { + CallHistory history = CallHistory.Parse(""" + !!MapStateToSect!! + !!Order!!,Call,Sect,State + K1TTT,WMA,MA + """); + + Assert.Equal("WMA", history.Find("K1TTT")?.Section); + } + + /// The order can change part way through, because a file is often several + /// files stuck together. + [Fact] + public void AnOrderLineOnlyAppliesToTheLinesAfterIt() + { + CallHistory history = CallHistory.Parse(""" + !!Order!!,Call,Name + OM5M,Erik + !!Order!!,Call,Sect + W3LPL,MDC + """); + + Assert.Equal("Erik", history.Find("OM5M")?.Name); + Assert.Equal("MDC", history.Find("W3LPL")?.Section); + } + + [Fact] + public void AlaterLineWinsForACallThatAppearsTwice() + { + CallHistory history = CallHistory.Parse(""" + !!Order!!,Call,Name + OM5M,Erik + OM5M,Eric + """); + + Assert.Equal("Eric", history.Find("OM5M")?.Name); + Assert.Equal(1, history.Count); + } + + [Fact] + public void UserTextIsAddedToRatherThanReplaced() + { + CallHistory history = CallHistory.Parse(""" + !!Order!!,Call,UserText + OM5M,first + OM5M,second + """); + + Assert.Equal("first second", history.Find("OM5M")?.UserText); + } + + [Fact] + public void UserTextIsReplacedWhenTheFileAsksForIt() + { + CallHistory history = CallHistory.Parse(""" + !!NoAppendUserText!! + !!Order!!,Call,UserText + OM5M,first + OM5M,second + """); + + Assert.Equal("second", history.Find("OM5M")?.UserText); + } + + /// A directive we do not act on must not be read as a callsign. + [Fact] + public void ADirectiveWeIgnoreIsStillNotData() + { + CallHistory history = CallHistory.Parse(""" + !!ValidateArrlSection!! + !!Order!!,Call,Name + OM5M,Erik + """); + + Assert.Equal(1, history.Count); + Assert.Null(history.Find("!!ValidateArrlSection!!")); + } + + [Fact] + public void ALineWithNoCallIsNotAnEntry() => + Assert.Equal(0, CallHistory.Parse(",Erik,JN88").Count); +} diff --git a/tests/Nonemm.Session.Tests/RadioPositionTests.cs b/tests/Nonemm.Session.Tests/RadioPositionTests.cs index e16483f..58d8829 100644 --- a/tests/Nonemm.Session.Tests/RadioPositionTests.cs +++ b/tests/Nonemm.Session.Tests/RadioPositionTests.cs @@ -1,6 +1,7 @@ using Nonemm.Contests; using Nonemm.Contests.Rules; using Nonemm.Core; +using Nonemm.Core.Calls; using Nonemm.Core.Country; using Nonemm.Storage; @@ -295,4 +296,90 @@ public class RadioPositionTests Assert.Single(second.Log.Qsos); Assert.Equal(3, second.Log.Tally.Points); } + + private static RadioPosition WithHistory(Contest contest, string file) + { + FakeLogStore store = new(); + ContestInstance instance = store.AddContest(new ContestInstance + { + ContestNumber = 0, + ContestName = contest.Name, + }); + return new RadioPosition(new ContestSession( + store, + contest, + instance, + Me, + CountryFile.Parse(Countries), + CallHistory.Parse(file))); + } + + [Fact] + public void TheCallHistoryFillsTheExchangeIn() + { + RadioPosition session = WithHistory( + new Sweepstakes(ModeCategory.Cw), + "!!Order!!,Call,Sect,CK\nW3LPL,MDC,56"); + session.Entry.Call = "W3LPL"; + + Assert.True(session.FillFromHistory()); + Assert.Equal("MDC", session.Entry.ValueOf(ExchangeSlot.Section)); + Assert.Equal("56", session.Entry.ValueOf(ExchangeSlot.Check)); + } + + /// What the other station actually sends beats what was published, so a box + /// with something in it is left alone. + [Fact] + public void WhatIsAlreadyTypedIsNotOverwritten() + { + RadioPosition session = WithHistory( + new Sweepstakes(ModeCategory.Cw), + "!!Order!!,Call,Sect\nW3LPL,MDC"); + session.Entry.Call = "W3LPL"; + session.Entry.Set(ExchangeSlot.Section, "EPA"); + + session.FillFromHistory(); + + Assert.Equal("EPA", session.Entry.ValueOf(ExchangeSlot.Section)); + } + + [Fact] + public void ACallTheHistoryDoesNotHoldFillsNothing() + { + RadioPosition session = WithHistory( + new Sweepstakes(ModeCategory.Cw), + "!!Order!!,Call,Sect\nW3LPL,MDC"); + session.Entry.Call = "JA1XYZ"; + + Assert.False(session.FillFromHistory()); + Assert.Equal("", session.Entry.ValueOf(ExchangeSlot.Section)); + } + + /// CQ WW exchanges the CQ zone, so the CQ zone column is the one to use. + [Fact] + public void TheZoneComesFromTheColumnTheContestAsksFor() + { + RadioPosition session = WithHistory( + new CqWorldWide(ModeCategory.Cw), + "!!Order!!,Call,CqZone,ITUZone\nJA1XYZ,25,45"); + session.Entry.Call = "JA1XYZ"; + + session.FillFromHistory(); + + Assert.Equal("25", session.Entry.ValueOf(ExchangeSlot.Zone)); + } + + /// A file for a section contest often carries the state instead. + [Fact] + public void TheStateFillsASectionWhenTheFileHasNoSection() + { + RadioPosition session = WithHistory( + new Sweepstakes(ModeCategory.Cw), + "!!Order!!,Call,Sect,State\nK1TTT,,CT"); + session.Entry.Call = "K1TTT"; + + session.FillFromHistory(); + + Assert.Equal("CT", session.Entry.ValueOf(ExchangeSlot.Section)); + } }