From d375d86466487aa6efeab78dc3fe8998c2e36f3d Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 27 Aug 2026 23:01:11 +0000 Subject: [PATCH] Show what is spotted and not worked yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Available Mults and Qs window: a column per band, the multipliers at the top of each column and then the stations that are only points. Clicking one puts the radio there with the call in the entry window, the same as the bandmap does. Every spot on the bandmap goes through ContestLog.Judge, and what comes back a dupe is dropped, so the colours and the ordering agree with every other window. A spot holds a callsign and a frequency and nothing else. For CQ WW that gives the country multiplier, which is derived from the call, but not the zone, which is what the other station sends. Where a call history file has an entry for the call its exchange is filled into the candidate first, so a published CQZone column brings the zone in and a Sect column makes Sweepstakes work at all. Filling the zone from the country file instead was rejected: it is wrong for every large country, and a multiplier that is not there is better than one that is not real. A spot has no mode either, so the candidate is judged in the mode the radio is in. In a mixed-mode contest that means the answer follows the operator. RadioPosition kept two private mappings — exchange value into a contact, and call history field into an exchange box — and both are needed here, so they move to ExchangeSlots and both callers share them. Co-Authored-By: Claude Opus 5 --- README.md | 20 ++- docs/unfinished.md | 8 +- src/Nonemm.App/AppSession.cs | 5 + src/Nonemm.App/Windows/AvailableWindow.axaml | 15 ++ .../Windows/AvailableWindow.axaml.cs | 110 ++++++++++++ src/Nonemm.App/Windows/EntryWindow.Menu.cs | 3 + src/Nonemm.App/Windows/EntryWindow.axaml | 1 + src/Nonemm.Session/AvailableStations.cs | 80 +++++++++ src/Nonemm.Session/ExchangeSlots.cs | 72 ++++++++ src/Nonemm.Session/RadioPosition.cs | 42 +---- .../AvailableStationsTests.cs | 170 ++++++++++++++++++ 11 files changed, 483 insertions(+), 43 deletions(-) create mode 100644 src/Nonemm.App/Windows/AvailableWindow.axaml create mode 100644 src/Nonemm.App/Windows/AvailableWindow.axaml.cs create mode 100644 src/Nonemm.Session/AvailableStations.cs create mode 100644 src/Nonemm.Session/ExchangeSlots.cs create mode 100644 tests/Nonemm.Session.Tests/AvailableStationsTests.cs diff --git a/README.md b/README.md index 33e6cf1..986f8bf 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,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, exchange filled from a call history file | -| Windows | entry, log, check, bandmap, score summary, packet | +| Windows | entry, log, check, bandmap, available mults and Qs, 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 | | Cluster | DX cluster over telnet, spots feeding the bandmap, Alt+P to spot a station | @@ -149,6 +149,24 @@ 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. +### Available mults and Qs + +**View → Available Mults and Qs** lists what is spotted and not worked yet, a +column per band, multipliers first and then the stations that are only points. +Clicking one puts the radio there with the call in the entry window, the same as +clicking a spot on the bandmap. **Mults only** hides everything that brings no +multiplier. + +A spot carries no exchange, so a station is only known to be a multiplier where +the multiplier follows from the callsign — a DXCC entity — or where the call +history file says what the station sends. That is why CQ WW shows the country +but not the zone: the zone that counts is the one the station sends, and the +country file's guess is wrong for the large countries. A call history file with +a `CQZone` column fills that in. + +A spot carries no mode either. Stations are judged in the mode the radio is in, +so in a mixed-mode contest the answer follows the operator. + ### Radios **Config → Radios** takes a `rigctld` address per radio. Each radio needs its diff --git a/docs/unfinished.md b/docs/unfinished.md index c1e80f5..25a6b73 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -54,8 +54,6 @@ are in the user-defined folder. N1MM ships well over a hundred. Opening a log from a contest not in the registry fails with the contest name, which is the right answer but it is still a wall. -**The Available Mults and Qs window.** Not started. - ## Known rough edges **The log window's column widths** are the grid's own automatic sizing with a @@ -66,6 +64,12 @@ thread, and `AppSession.TakeFromNetwork` reopens the whole contest to do it. That is correct but heavy, and it discards what is typed in an entry window on another radio. +**Available mults and Qs is only as good as the bandmap.** It lists what is +spotted, so a multiplier nobody has spotted is not there. N1MM's window also +shows the mults a contest has and nobody has worked, which needs a list of every +multiplier the contest counts. Only the section and state lists are held here, +so the zone and country lists would have to come from the country file first. + **Two entry windows and one keyer.** The SO2R box is told which radio to key before each message, but nothing stops both radios sending at once if the operator asks them to. diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 19ed2bd..da05c1f 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -69,6 +69,9 @@ public sealed class AppSession : IDisposable public CheckWindowSources? Check { get; private set; } + /// What is spotted and not worked yet. + public AvailableStations? Available { get; private set; } + /// One entry per configured radio, in radio-number order. public IReadOnlyList Radios => radios; @@ -109,6 +112,7 @@ public sealed class AppSession : IDisposable store = SqliteLogStore.Open(path); Logging = null; Check = null; + Available = null; Save(Settings with { DatabasePath = path, @@ -154,6 +158,7 @@ public sealed class AppSession : IDisposable change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc); Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign); Check = new CheckWindowSources(positions[0], Calls, Bandmap); + Available = new AvailableStations(positions[0], Bandmap); Save(Settings with { ContestNumber = contestNumber }); ContestChanged?.Invoke(this, EventArgs.Empty); } diff --git a/src/Nonemm.App/Windows/AvailableWindow.axaml b/src/Nonemm.App/Windows/AvailableWindow.axaml new file mode 100644 index 0000000..387cf07 --- /dev/null +++ b/src/Nonemm.App/Windows/AvailableWindow.axaml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/src/Nonemm.App/Windows/AvailableWindow.axaml.cs b/src/Nonemm.App/Windows/AvailableWindow.axaml.cs new file mode 100644 index 0000000..d4b8127 --- /dev/null +++ b/src/Nonemm.App/Windows/AvailableWindow.axaml.cs @@ -0,0 +1,110 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using Nonemm.Core; +using Nonemm.Session; + +namespace Nonemm.App.Windows; + +/// What is left to work, a column per band: the multipliers at the top of each +/// column, then the stations that are only points. Clicking one puts the radio +/// there with the call in the entry window, the same as clicking a spot on the +/// bandmap. +public sealed partial class AvailableWindow : RefreshableWindow +{ + private readonly AppSession session; + private readonly Action tune; + + public AvailableWindow(AppSession session, Action tune) + { + this.session = session; + this.tune = tune; + InitializeComponent(); + MultsOnlyBox.IsCheckedChanged += (_, _) => Refresh(); + session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh); + Refresh(); + } + + public override void Refresh() + { + Columns.Children.Clear(); + Columns.ColumnDefinitions.Clear(); + if (session.Available is null) + { + StatusText.Text = "no contest is open"; + return; + } + + session.Bandmap.DropOlderThan(DateTime.UtcNow); + bool multsOnly = MultsOnlyBox.IsChecked == true; + List stations = session.Available.All() + .Where(s => !multsOnly || s.IsMultiplier) + .ToList(); + List bands = stations + .Select(s => s.Band) + .Distinct() + .OrderBy(b => b.Low.Hertz) + .ToList(); + + int at = 0; + foreach (Band band in bands) + { + Columns.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); + Control column = BuildColumn(band, stations.Where(s => s.Band == band).ToList()); + Grid.SetColumn(column, at++); + Columns.Children.Add(column); + } + + int mults = stations.Count(s => s.IsMultiplier); + StatusText.Text = stations.Count == 0 + ? "nothing spotted that is not worked" + : $"{stations.Count} to work · {mults} multipliers"; + } + + private Control BuildColumn(Band band, IReadOnlyList stations) + { + StackPanel panel = new() { Margin = new Avalonia.Thickness(0, 0, 8, 0) }; + panel.Children.Add(new TextBlock + { + Text = $"{band.Name} {stations.Count}", + FontSize = 11, + Opacity = 0.7, + Margin = new Avalonia.Thickness(0, 0, 0, 4), + }); + foreach (AvailableStation station in stations) + { + panel.Children.Add(Row(station)); + } + return panel; + } + + private Control Row(AvailableStation station) + { + TextBlock text = new() + { + Text = Label(station), + FontFamily = new FontFamily("monospace"), + FontSize = 13, + Foreground = Verdicts.Colour(station.Verdict), + HorizontalAlignment = HorizontalAlignment.Left, + Cursor = new Cursor(StandardCursorType.Hand), + }; + ToolTip.SetTip(text, Tip(station)); + text.PointerPressed += (_, _) => tune(station.Spot.Frequency, station.Spot.Call.Text); + return text; + } + + private static string Label(AvailableStation station) + { + string call = $"{station.Spot.Frequency.Kilohertz,9:0.0} {station.Spot.Call.Text}"; + return station.IsMultiplier ? $"{call} · {station.MultiplierText}" : call; + } + + private static string Tip(AvailableStation station) + { + string who = station.Spot.Spotter.Length > 0 ? $" de {station.Spot.Spotter}" : ""; + return $"{Verdicts.Describe(station.Verdict)}{who}"; + } +} diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs index 5d50ac3..3352bd6 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs @@ -230,6 +230,9 @@ public sealed partial class EntryWindow private void OnShowBandmap(object? sender, RoutedEventArgs e) => Show(() => new BandmapWindow(session, Tune)); + private void OnShowAvailable(object? sender, RoutedEventArgs e) => + Show(() => new AvailableWindow(session, Tune)); + private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session)); private void OnShowPacket(object? sender, RoutedEventArgs e) => Show(() => new PacketWindow(session)); diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml index df470d3..a302858 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml +++ b/src/Nonemm.App/Windows/EntryWindow.axaml @@ -47,6 +47,7 @@ + diff --git a/src/Nonemm.Session/AvailableStations.cs b/src/Nonemm.Session/AvailableStations.cs new file mode 100644 index 0000000..c1ddaa4 --- /dev/null +++ b/src/Nonemm.Session/AvailableStations.cs @@ -0,0 +1,80 @@ +using Nonemm.Contests; +using Nonemm.Core; +using Nonemm.Spotting; + +namespace Nonemm.Session; + +/// One spotted station worth working, and what working it would bring. +public sealed record AvailableStation(Spot Spot, Band Band, Verdict Verdict) +{ + public IReadOnlyList NewMultipliers => Verdict.NewMultipliers; + + public bool IsMultiplier => Verdict.NewMultipliers.Count > 0; + + /// The multiplier values, e.g. `JA, 25` for a country and a zone. + public string MultiplierText => + string.Join(", ", Verdict.NewMultipliers.Select(m => m.Value).Distinct(StringComparer.Ordinal)); +} + +/// What is on the bandmap that this station has not worked: the multipliers +/// first, then the stations that are only points. +/// +/// A spot carries no exchange, so a contest scored on what the other station +/// sends — a section, a name — cannot be judged from the spot alone. Where the +/// call history file has an entry for the call, its exchange is used, which is +/// what makes this window worth opening in a domestic contest. Without one the +/// station shows as a plain contact rather than as a multiplier it might be. +/// +/// A spot carries no mode either. Contacts are judged in the mode the radio is +/// in, so in a mixed-mode contest the answer follows the operator. +public sealed class AvailableStations +{ + private readonly RadioPosition position; + private readonly Bandmap bandmap; + + public AvailableStations(RadioPosition position, Bandmap bandmap) + { + this.position = position; + this.bandmap = bandmap; + } + + /// Every spotted station that is not a dupe, multipliers first and then in + /// frequency order. + public IReadOnlyList All() + { + List found = []; + foreach (Spot spot in bandmap.All()) + { + if (spot.Band is not { } band) + { + continue; + } + Verdict verdict = position.Log.Judge(Candidate(spot)); + if (!verdict.IsDupe) + { + found.Add(new AvailableStation(spot, band, verdict)); + } + } + return found + .OrderByDescending(s => s.IsMultiplier) + .ThenBy(s => s.Spot.Frequency.Hertz) + .ToList(); + } + + private Qso Candidate(Spot spot) + { + Qso qso = new() + { + Id = "", + TimestampUtc = DateTime.UtcNow, + Call = spot.Call, + Frequency = spot.Frequency, + Mode = position.Mode, + ContestName = position.Contest.Name, + }; + return ExchangeSlots.ApplyHistory( + CountryFields.Apply(qso, position.Session.Countries), + position.Session.History, + position.Entry.Exchange); + } +} diff --git a/src/Nonemm.Session/ExchangeSlots.cs b/src/Nonemm.Session/ExchangeSlots.cs new file mode 100644 index 0000000..c8a7beb --- /dev/null +++ b/src/Nonemm.Session/ExchangeSlots.cs @@ -0,0 +1,72 @@ +using Nonemm.Contests; +using Nonemm.Core; +using Nonemm.Core.Calls; + +namespace Nonemm.Session; + +/// Moving exchange values between the entry boxes, the call history file and a +/// contact. +public static class ExchangeSlots +{ + public static Qso Apply(Qso qso, ExchangeSlot slot, string value) => slot switch + { + ExchangeSlot.ReceivedReport => qso with { ReceivedReport = value }, + ExchangeSlot.SerialNumber => qso with { ReceivedNumber = Number(value) }, + ExchangeSlot.Zone => qso with { Zone = Number(value) }, + ExchangeSlot.Section => qso with { Section = value.ToUpperInvariant() }, + ExchangeSlot.Check => qso with { Check = Number(value) }, + ExchangeSlot.Precedence => qso with { Precedence = value.ToUpperInvariant() }, + ExchangeSlot.Exchange1 => qso with { Exchange1 = value.ToUpperInvariant() }, + ExchangeSlot.MiscText => qso with { MiscText = value.ToUpperInvariant() }, + ExchangeSlot.Name => qso with { Name = value }, + ExchangeSlot.Qth => qso with { Qth = value }, + ExchangeSlot.GridSquare => qso with { GridSquare = value.ToUpperInvariant() }, + ExchangeSlot.Power => qso with { Power = value }, + ExchangeSlot.Comment => qso with { Comment = value }, + _ => qso, + }; + + /// What the call history file says this station sends in one exchange box, + /// or an empty string when the file holds nothing for it. + public static string ValueFrom(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, + _ => "", + }; + + /// Fills the exchange the call history file published for this station into + /// a contact. Used where nobody has copied the exchange yet: a spotted + /// station the operator has not worked. + public static Qso ApplyHistory(Qso qso, CallHistory history, IReadOnlyList exchange) + { + CallHistoryEntry? known = history.Find(qso.Call.Text); + if (known is null) + { + return qso; + } + foreach (ExchangeField field in exchange) + { + string value = ValueFrom(known, field); + if (value.Length > 0) + { + qso = Apply(qso, field.Slot, value); + } + } + return qso; + } + + private static string Digits(int value) => value > 0 ? value.ToString() : ""; + + private static int Number(string value) => int.TryParse(value, out int parsed) ? parsed : 0; +} diff --git a/src/Nonemm.Session/RadioPosition.cs b/src/Nonemm.Session/RadioPosition.cs index bb5046e..664b1fa 100644 --- a/src/Nonemm.Session/RadioPosition.cs +++ b/src/Nonemm.Session/RadioPosition.cs @@ -101,7 +101,7 @@ public sealed class RadioPosition bool filled = false; for (int at = 0; at < Entry.Exchange.Count; at++) { - string value = ValueFor(known, Entry.Exchange[at]); + string value = ExchangeSlots.ValueFrom(known, Entry.Exchange[at]); if (value.Length == 0 || Entry[at + 1].Trim().Length > 0) { continue; @@ -116,25 +116,6 @@ public sealed class RadioPosition 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(); @@ -175,27 +156,8 @@ public sealed class RadioPosition { foreach (ExchangeField field in Entry.Exchange) { - string value = Entry.ValueOf(field.Slot).Trim(); - qso = field.Slot switch - { - ExchangeSlot.ReceivedReport => qso with { ReceivedReport = value }, - ExchangeSlot.SerialNumber => qso with { ReceivedNumber = Number(value) }, - ExchangeSlot.Zone => qso with { Zone = Number(value) }, - ExchangeSlot.Section => qso with { Section = value.ToUpperInvariant() }, - ExchangeSlot.Check => qso with { Check = Number(value) }, - ExchangeSlot.Precedence => qso with { Precedence = value.ToUpperInvariant() }, - ExchangeSlot.Exchange1 => qso with { Exchange1 = value.ToUpperInvariant() }, - ExchangeSlot.MiscText => qso with { MiscText = value.ToUpperInvariant() }, - ExchangeSlot.Name => qso with { Name = value }, - ExchangeSlot.Qth => qso with { Qth = value }, - ExchangeSlot.GridSquare => qso with { GridSquare = value.ToUpperInvariant() }, - ExchangeSlot.Power => qso with { Power = value }, - ExchangeSlot.Comment => qso with { Comment = value }, - _ => qso, - }; + qso = ExchangeSlots.Apply(qso, field.Slot, Entry.ValueOf(field.Slot).Trim()); } return qso; } - - private static int Number(string value) => int.TryParse(value, out int parsed) ? parsed : 0; } diff --git a/tests/Nonemm.Session.Tests/AvailableStationsTests.cs b/tests/Nonemm.Session.Tests/AvailableStationsTests.cs new file mode 100644 index 0000000..abf252c --- /dev/null +++ b/tests/Nonemm.Session.Tests/AvailableStationsTests.cs @@ -0,0 +1,170 @@ +using Nonemm.Contests; +using Nonemm.Contests.Rules; +using Nonemm.Core; +using Nonemm.Core.Calls; +using Nonemm.Core.Country; +using Nonemm.Spotting; +using Nonemm.Storage; + +namespace Nonemm.Session.Tests; + +public class AvailableStationsTests +{ + private const string Countries = """ + Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL: + DL,DK,DJ; + Japan: 25: 45: AS: 36.40: -138.38: -9.0: JA: + JA,JH,JR; + United States: 05: 08: NA: 37.53: 91.67: 5.0: K: + K,W,N; + """; + + private static readonly StationInfo Me = new() + { + Callsign = "DL1ABC", + CqZone = 14, + ItuZone = 28, + Continent = "EU", + CountryPrefix = "DL", + }; + + private static readonly Frequency On20 = Frequency.FromKilohertz(14_030); + + private static (RadioPosition Position, Bandmap Bandmap, AvailableStations Available) Station( + Contest? contest = null, + CallHistory? history = null) + { + FakeLogStore store = new(); + ContestInstance instance = store.AddContest(new ContestInstance + { + ContestNumber = 0, + ContestName = "CQWW", + }); + ContestSession session = new( + store, + contest ?? new CqWorldWide(ModeCategory.Cw), + instance, + Me, + CountryFile.Parse(Countries), + history); + RadioPosition position = new(session); + Bandmap bandmap = new(); + return (position, bandmap, new AvailableStations(position, bandmap)); + } + + private static Spot At(string call, Frequency where) => + new(Callsign.Parse(call), where, DateTime.UtcNow, SpotSource.Cluster, "OK1TEST"); + + private static void Work(RadioPosition position, string call, string zone, Frequency where) + { + position.Tune(where); + position.Entry.Call = call; + position.Entry.Set(ExchangeSlot.ReceivedReport, "599"); + position.Entry.Set(ExchangeSlot.Zone, zone); + position.LogContact(); + } + + [Fact] + public void ASpottedStationThatIsANewCountryIsAMultiplier() + { + (_, Bandmap bandmap, AvailableStations available) = Station(); + bandmap.Add(At("JA1XYZ", On20)); + + AvailableStation station = Assert.Single(available.All()); + Assert.True(station.IsMultiplier); + Assert.Equal(Bands.Band20M, station.Band); + Assert.Equal("JA", station.MultiplierText); + } + + /// CQ WW counts the zone the station sends, not the one the country file + /// guesses, and a spot carries no exchange. So the zone shows only where + /// the call history file published it. + [Fact] + public void TheCallHistoryZoneBringsInTheZoneMultiplier() + { + CallHistory history = CallHistory.Parse(""" + !!Order!!,Call,CQZone + JA1XYZ,25 + """); + (_, Bandmap bandmap, AvailableStations available) = Station(history: history); + bandmap.Add(At("JA1XYZ", On20)); + + AvailableStation station = Assert.Single(available.All()); + Assert.Equal("25, JA", station.MultiplierText); + } + + [Fact] + public void AStationAlreadyWorkedOnThatBandIsLeftOut() + { + (RadioPosition position, Bandmap bandmap, AvailableStations available) = Station(); + Work(position, "JA1XYZ", "25", On20); + bandmap.Add(At("JA1XYZ", On20)); + + Assert.Empty(available.All()); + } + + [Fact] + public void TheSameStationOnAnotherBandIsStillWorthWorking() + { + (RadioPosition position, Bandmap bandmap, AvailableStations available) = Station(); + Work(position, "JA1XYZ", "25", On20); + bandmap.Add(At("JA1XYZ", Frequency.FromKilohertz(7_030))); + + AvailableStation station = Assert.Single(available.All()); + Assert.Equal(Bands.Band40M, station.Band); + Assert.True(station.IsMultiplier); + } + + [Fact] + public void AStationThatBringsNoMultiplierIsStillOfferedForItsPoints() + { + (RadioPosition position, Bandmap bandmap, AvailableStations available) = Station(); + Work(position, "JA1XYZ", "25", On20); + bandmap.Add(At("JA2ABC", On20)); + + AvailableStation station = Assert.Single(available.All()); + Assert.False(station.IsMultiplier); + Assert.True(station.Verdict.Points > 0); + } + + [Fact] + public void MultipliersComeFirst() + { + (RadioPosition position, Bandmap bandmap, AvailableStations available) = Station(); + Work(position, "JA1XYZ", "25", On20); + bandmap.Add(At("JA2ABC", Frequency.FromKilohertz(14_010))); + bandmap.Add(At("W1AW", Frequency.FromKilohertz(14_040))); + + Assert.Equal( + ["W1AW", "JA2ABC"], + available.All().Select(s => s.Spot.Call.Text).ToList()); + } + + /// A spot carries no exchange, so a section contest can only judge a + /// spotted station where the call history file says what it sends. + [Fact] + public void TheCallHistoryFillsTheExchangeOfASpottedStation() + { + CallHistory history = CallHistory.Parse(""" + !!Order!!,Call,Sect + W1AW,CT + """); + (_, Bandmap bandmap, AvailableStations available) = + Station(new Sweepstakes(ModeCategory.Cw), history); + bandmap.Add(At("W1AW", Frequency.FromKilohertz(14_080))); + + AvailableStation station = Assert.Single(available.All()); + Assert.True(station.IsMultiplier); + Assert.Equal("CT", station.MultiplierText); + } + + [Fact] + public void WithNoCallHistoryASpottedStationOfASectionContestIsJustPoints() + { + (_, Bandmap bandmap, AvailableStations available) = Station(new Sweepstakes(ModeCategory.Cw)); + bandmap.Add(At("W1AW", Frequency.FromKilohertz(14_080))); + + AvailableStation station = Assert.Single(available.All()); + Assert.False(station.IsMultiplier); + } +}