diff --git a/README.md b/README.md index 169e136..90b32dd 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ scorer: red for a dupe, green for a new multiplier, blue for points. | | | |---|---| -| Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), WAE (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, general logging, and user-defined `.udc` contests | +| Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), WAE (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, OK/OM DX, YOTA, 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, available mults and Qs, score summary, telnet | diff --git a/docs/unfinished.md b/docs/unfinished.md index 75b8084..02b45af 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -92,7 +92,7 @@ which is too much work per spot for the little it would add. the contest rules score it, but there is no digital window: no decoding, no transmitting, no interface to fldigi, MMTTY or similar. -**Contest coverage.** Eight families are built in, plus whatever `.udc` files +**Contest coverage.** Ten families are built in, plus whatever `.udc` files are in the user-defined folder. N1MM ships well over a hundred. Opening a log from a contest that is in neither place fails with the contest name, which is the right answer but it is still a wall. @@ -117,12 +117,17 @@ default shape. The session, off-time and band-change settings as one long session. N1MM's `.udc` format has no way to say that the exchange itself differs by the -other station's country, as the OK/OM DX contest asks a district from OK and OM -stations and a serial number from everyone else. That one needs code, as it -does in N1MM, and `Contest.SkipsField` is where it goes. A contest scored by -what this station sends — YOTA counts the sent exchange — needs code too, and -reads `QsoContext.Entry`, which carries the entry categories and the sent -exchange. +other station's country. OK/OM DX is written in code for that reason — it asks +a county from OK and OM stations and a serial number from everyone else — and +`Contest.SkipsField` steps over the box that does not apply. The same is true +of REF, ARRL 10M, the Ukrainian DX contest and the Russian DX contest, which +are not written yet. + +What is left in the station's own logs, in the order it works them: IOTA, EU +DXC, CQ 160, XE RTTY, REF, SARTG, BARTG, Field Day Region 1, Oceania, All +Asian, RDAC and ARRL 10M. Everything but IOTA, REF and ARRL 10M is an exchange +we already store — a serial, an age, a zone or a district — so a `.udc` file +covers it. ## Known rough edges diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs index c12a336..880ee0d 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs @@ -107,7 +107,7 @@ public sealed partial class EntryWindow { return; } - CabrilloWriter writer = new(Logging.Contest, Logging.Me); + CabrilloWriter writer = new(Logging.Contest, Logging.Me, Logging.Instance.Entry); CabrilloHeader header = new() { Contest = Logging.Contest.CabrilloName, diff --git a/src/Nonemm.Contests/Contest.cs b/src/Nonemm.Contests/Contest.cs index 7eb42ca..883c0de 100644 --- a/src/Nonemm.Contests/Contest.cs +++ b/src/Nonemm.Contests/Contest.cs @@ -70,6 +70,8 @@ public interface Contest /// the whole score by the power category the operator entered in. int TotalScore(ScoreTally tally, ContestEntry entry); - /// The exchange columns of this contact's Cabrillo line. - CabrilloExchange CabrilloExchange(Qso qso, StationInfo me); + /// The exchange columns of this contact's Cabrillo line. The entry is here + /// because the sent side can be what the operator typed into the contest + /// setup rather than anything the station record holds. + CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry); } diff --git a/src/Nonemm.Contests/ContestRegistry.cs b/src/Nonemm.Contests/ContestRegistry.cs index 61af224..ef41de1 100644 --- a/src/Nonemm.Contests/ContestRegistry.cs +++ b/src/Nonemm.Contests/ContestRegistry.cs @@ -18,6 +18,8 @@ public sealed class ContestRegistry new("WAE", "Worked All Europe DX", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new Wae(m)), new("ARRLRTTY", "ARRL RTTY Roundup", [ModeCategory.Digital], _ => new RttyRoundup()), new("NAQP", "North American QSO Party", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new NorthAmericanQsoParty(m)), + new("OKOMDX", "Czech and Slovak Republics DX", [ModeCategory.Cw, ModeCategory.Phone], m => new OkOmDx(m)), + new("YOTA", "YOTA Contest", [ModeCategory.Cw, ModeCategory.Phone], _ => new Yota()), new("DX", "General logging", [], _ => new GeneralLogging()), ]; diff --git a/src/Nonemm.Contests/N1mmContestNames.cs b/src/Nonemm.Contests/N1mmContestNames.cs index 5ec1e44..d0ffbab 100644 --- a/src/Nonemm.Contests/N1mmContestNames.cs +++ b/src/Nonemm.Contests/N1mmContestNames.cs @@ -6,6 +6,11 @@ namespace Nonemm.Contests; /// never plain `CQWW`. Our registry is keyed by the family, so a log written by /// N1MM has to have its contest name turned back into a family and a mode /// before the rules can be built. +/// +/// The `.udc` files people use for OK/OM DX name it per mode as well, and one +/// file per side of the contest — `OKOMDXS_DX` is the one a station outside OK +/// and OM runs. Both sides are the same rules here, so both names resolve to +/// the same contest. public static class N1mmContestNames { private static readonly Dictionary Known = @@ -27,6 +32,10 @@ public static class N1mmContestNames ["NAQPCW"] = ("NAQP", ModeCategory.Cw), ["NAQPSSB"] = ("NAQP", ModeCategory.Phone), ["NAQPRTTY"] = ("NAQP", ModeCategory.Digital), + ["OKOMDXC"] = ("OKOMDX", ModeCategory.Cw), + ["OKOMDXS"] = ("OKOMDX", ModeCategory.Phone), + ["OKOMDXC_DX"] = ("OKOMDX", ModeCategory.Cw), + ["OKOMDXS_DX"] = ("OKOMDX", ModeCategory.Phone), }; public static bool TryResolve(string name, out string family, out ModeCategory mode) diff --git a/src/Nonemm.Contests/Rules/ArrlDx.cs b/src/Nonemm.Contests/Rules/ArrlDx.cs index 49fc1dd..8bc0115 100644 --- a/src/Nonemm.Contests/Rules/ArrlDx.cs +++ b/src/Nonemm.Contests/Rules/ArrlDx.cs @@ -64,7 +64,7 @@ public sealed class ArrlDx : Contest public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers; - public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => + public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => new( [ new CabrilloField(me.Callsign, 13), diff --git a/src/Nonemm.Contests/Rules/CqWorldWide.cs b/src/Nonemm.Contests/Rules/CqWorldWide.cs index bdeb6e9..f5694f0 100644 --- a/src/Nonemm.Contests/Rules/CqWorldWide.cs +++ b/src/Nonemm.Contests/Rules/CqWorldWide.cs @@ -113,7 +113,7 @@ public sealed class CqWorldWide : Contest public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers; - public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => IsRtty + public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => IsRtty ? new CabrilloExchange( [ new CabrilloField(me.Callsign, 13), diff --git a/src/Nonemm.Contests/Rules/CqWpx.cs b/src/Nonemm.Contests/Rules/CqWpx.cs index eb73a66..23a9e5b 100644 --- a/src/Nonemm.Contests/Rules/CqWpx.cs +++ b/src/Nonemm.Contests/Rules/CqWpx.cs @@ -76,7 +76,7 @@ public sealed class CqWpx : Contest public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers; - public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => + public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => new( [ new CabrilloField(me.Callsign, 13), diff --git a/src/Nonemm.Contests/Rules/GeneralLogging.cs b/src/Nonemm.Contests/Rules/GeneralLogging.cs index 572a40e..e17e8bb 100644 --- a/src/Nonemm.Contests/Rules/GeneralLogging.cs +++ b/src/Nonemm.Contests/Rules/GeneralLogging.cs @@ -37,7 +37,7 @@ public sealed class GeneralLogging : Contest public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Qsos; - public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => + public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => new( [new CabrilloField(me.Callsign, 13), new CabrilloField(qso.SentReport, 3)], [new CabrilloField(qso.Call.Text, 13), new CabrilloField(qso.ReceivedReport, 3)]); diff --git a/src/Nonemm.Contests/Rules/IaruHf.cs b/src/Nonemm.Contests/Rules/IaruHf.cs index be99a74..5bc1588 100644 --- a/src/Nonemm.Contests/Rules/IaruHf.cs +++ b/src/Nonemm.Contests/Rules/IaruHf.cs @@ -60,7 +60,7 @@ public sealed class IaruHf : Contest public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers; - public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => + public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => new( [ new CabrilloField(me.Callsign, 13), diff --git a/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs b/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs index 5874906..5859da9 100644 --- a/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs +++ b/src/Nonemm.Contests/Rules/NorthAmericanQsoParty.cs @@ -56,7 +56,7 @@ public sealed class NorthAmericanQsoParty : Contest public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers; - public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => + public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => new( [ new CabrilloField(me.Callsign, 13), diff --git a/src/Nonemm.Contests/Rules/OkOmDx.cs b/src/Nonemm.Contests/Rules/OkOmDx.cs new file mode 100644 index 0000000..e6f29ef --- /dev/null +++ b/src/Nonemm.Contests/Rules/OkOmDx.cs @@ -0,0 +1,109 @@ +using Nonemm.Core; +using Nonemm.Core.Country; + +namespace Nonemm.Contests.Rules; + +/// OK/OM DX. An OK or OM station sends a county code and everyone else a +/// serial number, and which side the operator is on decides what a contact +/// scores. +/// +/// The received exchange lands in the section column for an OK or OM station +/// and in the exchange column for everyone else, which is where N1MM keeps +/// each of them. +public sealed class OkOmDx : Contest +{ + private readonly ModeCategory mode; + + public OkOmDx(ModeCategory mode) => this.mode = mode; + + public string Name => "OKOMDX"; + + public string DisplayName => "Czech and Slovak Republics DX"; + + public string CabrilloName => "OK-OM-DX"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("County", ExchangeSlot.Section, ExchangeFieldKind.Text) { Width = 4 }, + new ExchangeField("Nr", ExchangeSlot.Exchange1, ExchangeFieldKind.Number), + ]; + + /// An OK or OM station sends a county and no serial; everyone else the + /// other way round, so one of the two boxes is stepped over. + public bool SkipsField(ExchangeField field, CountryLookup? their) => + field.Slot switch + { + ExchangeSlot.Section => their is not null && !IsOkOm(their.Entity.PrimaryPrefix), + ExchangeSlot.Exchange1 => their is not null && IsOkOm(their.Entity.PrimaryPrefix), + _ => false, + }; + + public IReadOnlyList MultiplierNames => ["Countries", "Counties"]; + + public DupeScope DupeScope => DupeScope.PerBand; + + public bool HasSerialNumbers => true; + + public IReadOnlyList Modes => [mode]; + + public string SentExchangeFor(StationInfo me) => IsOkOm(me.CountryPrefix) ? me.County : "001"; + + /// A maritime mobile is 5 points to either side. An OK or OM operator gets + /// 2 points at home, 3 elsewhere on the continent and 5 from another — + /// Czechia and Slovakia are two countries, so they are 3 points to each + /// other. Everyone else gets 10 points for an OK or OM station, 1 at home, + /// 3 on their own continent and 5 from another. + public int PointsFor(QsoContext qso) + { + if (qso.Qso.Call.IsMaritimeMobile) + { + return 5; + } + if (!IsOkOm(qso.Me.CountryPrefix) && IsOkOm(qso.CountryPrefix)) + { + return 10; + } + int home = IsOkOm(qso.Me.CountryPrefix) ? 2 : 1; + return qso.IsSameCountry ? home : qso.IsSameContinent ? 3 : 5; + } + + /// Both sides count the same two, once per band each: every country, and + /// every OK or OM county. N1MM's own class counts prefixes for an OK or OM + /// operator, which was the rule before the counties came in. + public IReadOnlyList MultipliersFor(QsoContext qso) + { + string band = qso.Band?.Name ?? ""; + List found = []; + if (qso.CountryPrefix.Length > 0) + { + found.Add(new Multiplier(1, qso.CountryPrefix, band)); + } + string county = County(qso.Qso); + if (IsOkOm(qso.CountryPrefix) && county.Length > 0) + { + found.Add(new Multiplier(2, county, band)); + } + return found; + } + + public int TotalScore(ScoreTally tally, ContestEntry entry) => + tally.Points * tally.TotalMultipliers; + + public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => + new( + [ + new CabrilloField(me.Callsign, 13), + new CabrilloField(qso.SentReport, 3), + new CabrilloField(IsOkOm(me.CountryPrefix) ? me.County : $"{qso.SentNumber:000}", 6), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField(qso.ReceivedReport, 3), + new CabrilloField(County(qso).Length > 0 ? County(qso) : qso.Exchange1, 6), + ]); + + private static string County(Qso qso) => qso.Section.Trim().ToUpperInvariant(); + + private static bool IsOkOm(string countryPrefix) => countryPrefix is "OK" or "OM"; +} diff --git a/src/Nonemm.Contests/Rules/RttyRoundup.cs b/src/Nonemm.Contests/Rules/RttyRoundup.cs index 15bffbb..01ae5eb 100644 --- a/src/Nonemm.Contests/Rules/RttyRoundup.cs +++ b/src/Nonemm.Contests/Rules/RttyRoundup.cs @@ -46,7 +46,7 @@ public sealed class RttyRoundup : Contest public int TotalScore(ScoreTally tally, ContestEntry entry) => tally.Points * tally.TotalMultipliers; - public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => + public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => new( [ new CabrilloField(me.Callsign, 13), diff --git a/src/Nonemm.Contests/Rules/Sweepstakes.cs b/src/Nonemm.Contests/Rules/Sweepstakes.cs index 3154506..4429126 100644 --- a/src/Nonemm.Contests/Rules/Sweepstakes.cs +++ b/src/Nonemm.Contests/Rules/Sweepstakes.cs @@ -48,7 +48,7 @@ public sealed class Sweepstakes : Contest /// Sweepstakes puts the callsign after the serial number and precedence /// rather than first, which is why the writer takes the whole column list. - public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => + public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => new( [ new CabrilloField($"{qso.SentNumber}", 4), diff --git a/src/Nonemm.Contests/Rules/Wae.cs b/src/Nonemm.Contests/Rules/Wae.cs index bd85c59..440c556 100644 --- a/src/Nonemm.Contests/Rules/Wae.cs +++ b/src/Nonemm.Contests/Rules/Wae.cs @@ -96,7 +96,7 @@ public sealed class Wae : Contest /// A QTC is written as its own Cabrillo record: the station that received /// the traffic, the series, the station that sent it, and then the contact /// being reported. - public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) + public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) { if (WaeQtc.From(qso) is not { } qtc) { diff --git a/src/Nonemm.Contests/Rules/Yota.cs b/src/Nonemm.Contests/Rules/Yota.cs new file mode 100644 index 0000000..db5140c --- /dev/null +++ b/src/Nonemm.Contests/Rules/Yota.cs @@ -0,0 +1,70 @@ +using Nonemm.Core; + +namespace Nonemm.Contests.Rules; + +/// The YOTA contest. The exchange is the operator's age, a contact with a +/// young operator is worth more, and every age worked on a band is a +/// multiplier. +/// +/// N1MM has no class for this one — it is logged there through a `.udc` file, +/// which is why the contest name is plain `YOTA`. +public sealed class Yota : Contest +{ + public string Name => "YOTA"; + + public string DisplayName => "YOTA Contest"; + + public string CabrilloName => "YOTA"; + + public IReadOnlyList ExchangeFieldsFor(StationInfo me) => + [ + new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report), + new ExchangeField("Age", ExchangeSlot.Exchange1, ExchangeFieldKind.Number) { Width = 3 }, + ]; + + public IReadOnlyList MultiplierNames => ["Ages"]; + + public DupeScope DupeScope => DupeScope.PerBandAndMode; + + public bool HasSerialNumbers => false; + + public IReadOnlyList Modes => [ModeCategory.Cw, ModeCategory.Phone]; + + /// The operator's own age, which the contest setup asks for. + public string SentExchangeFor(StationInfo me) => ""; + + /// A contact with an operator of 25 or under is worth 10 to 13 points by + /// age, whatever the continent. Everyone else is worth 1 point on your own + /// continent and 3 from another. + public int PointsFor(QsoContext qso) => Age(qso) switch + { + null or > 25 => qso.IsSameContinent ? 1 : 3, + <= 12 => 13, + <= 16 => 12, + <= 21 => 11, + _ => 10, + }; + + /// Each age worked on a band, whatever the mode. + public IReadOnlyList MultipliersFor(QsoContext qso) => + Age(qso) is { } age ? [new Multiplier(1, age.ToString(), qso.Band?.Name ?? "")] : []; + + public int TotalScore(ScoreTally tally, ContestEntry entry) => + tally.Points * tally.TotalMultipliers; + + public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => + new( + [ + new CabrilloField(me.Callsign, 13), + new CabrilloField(qso.SentReport, 3), + new CabrilloField(entry.SentExchange, 3), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField(qso.ReceivedReport, 3), + new CabrilloField(qso.Exchange1, 3), + ]); + + private static int? Age(QsoContext qso) => + int.TryParse(qso.Qso.Exchange1.Trim(), out int age) && age > 0 ? age : null; +} diff --git a/src/Nonemm.Contests/Udc/UserDefinedContest.cs b/src/Nonemm.Contests/Udc/UserDefinedContest.cs index c7c857a..1e991b9 100644 --- a/src/Nonemm.Contests/Udc/UserDefinedContest.cs +++ b/src/Nonemm.Contests/Udc/UserDefinedContest.cs @@ -115,7 +115,7 @@ public sealed class UserDefinedContest : Contest return (int)Math.Round(total * pointsMultiplier.ForEntry(entry)); } - public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) => + public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => new( [ new CabrilloField(me.Callsign, 13), diff --git a/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs b/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs index dfb57d9..3e3708f 100644 --- a/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs +++ b/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs @@ -11,11 +11,13 @@ public sealed class CabrilloWriter { private readonly Contest contest; private readonly StationInfo me; + private readonly ContestEntry entry; - public CabrilloWriter(Contest contest, StationInfo me) + public CabrilloWriter(Contest contest, StationInfo me, ContestEntry? entry = null) { this.contest = contest; this.me = me; + this.entry = entry ?? new ContestEntry(); } public string Write(CabrilloHeader header, IEnumerable qsos) @@ -62,7 +64,7 @@ public sealed class CabrilloWriter public string QsoLine(Qso qso) { - CabrilloExchange exchange = contest.CabrilloExchange(qso, me); + CabrilloExchange exchange = contest.CabrilloExchange(qso, me, entry); StringBuilder line = new(contest.IsContact(qso) ? "QSO: " : "QTC: "); line.Append(CabrilloBands.Designator(qso.Frequency).PadLeft(5)).Append(' '); line.Append(qso.Mode.CabrilloCode.PadRight(2)).Append(' '); diff --git a/tests/Nonemm.Contests.Tests/OkOmDxTests.cs b/tests/Nonemm.Contests.Tests/OkOmDxTests.cs new file mode 100644 index 0000000..8c55a24 --- /dev/null +++ b/tests/Nonemm.Contests.Tests/OkOmDxTests.cs @@ -0,0 +1,75 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; +using Nonemm.Core.Country; + +namespace Nonemm.Contests.Tests; + +public class OkOmDxTests +{ + private static readonly Contest Ssb = new OkOmDx(ModeCategory.Phone); + + private static ContestLog LogFor(StationInfo me) => new(Ssb, me, TestLog.CountryFile); + + private static Qso FromOkOm(string call, string county, double kilohertz = 14_025) => + TestLog.Contact(call, kilohertz, mode: Modes.Usb, section: county); + + private static Qso FromDx(string call, string number, double kilohertz = 14_025) => + TestLog.Contact(call, kilohertz, mode: Modes.Usb, exchange: number); + + [Theory] + [InlineData("OM3XYZ", 2)] + [InlineData("OK1XYZ", 3)] + public void AtHomeCzechiaAndSlovakiaAreTwoCountries(string call, int expected) => + Assert.Equal(expected, LogFor(TestLog.Slovakia).Judge(FromOkOm(call, "NOV")).Points); + + [Theory] + [InlineData("IK2XYZ", 3)] + [InlineData("JA1XYZ", 5)] + public void TheRestOfTheWorldIsScoredByContinent(string call, int expected) => + Assert.Equal(expected, LogFor(TestLog.Slovakia).Judge(FromDx(call, "014")).Points); + + [Theory] + [InlineData("OM3XYZ", 10)] + [InlineData("DL9XYZ", 1)] + [InlineData("IK2XYZ", 3)] + [InlineData("JA1XYZ", 5)] + public void AStationOutsideScoresTenForAnOkOmContact(string call, int expected) => + Assert.Equal(expected, LogFor(TestLog.Germany).Judge(FromOkOm(call, "NOV")).Points); + + [Fact] + public void CountriesAndCountiesBothCountOncePerBand() + { + ContestLog log = LogFor(TestLog.Slovakia); + Assert.Equal( + [1, 2], + log.Judge(FromOkOm("OM3XYZ", "NOV")).NewMultipliers.Select(m => m.Index).ToList()); + log.Add(FromOkOm("OM3XYZ", "NOV")); + Assert.Empty(log.Judge(FromOkOm("OM4XYZ", "NOV")).NewMultipliers); + Assert.Single(log.Judge(FromOkOm("OM4XYZ", "PAR")).NewMultipliers); + } + + [Fact] + public void AStationOutsideOkOmBringsItsCountryAndNoCounty() => + Assert.Equal( + [1], + LogFor(TestLog.Slovakia).Judge(FromDx("IK2XYZ", "014")).NewMultipliers + .Select(m => m.Index) + .ToList()); + + /// The entry window walks the county box for an OK or OM station and the + /// serial box for everyone else. + [Fact] + public void OnlyOneOfTheTwoBoxesIsWalked() + { + IReadOnlyList fields = Ssb.ExchangeFieldsFor(TestLog.Slovakia); + CountryLookup? okom = TestLog.CountryFile.Find("OM3XYZ"); + CountryLookup? dx = TestLog.CountryFile.Find("IK2XYZ"); + + Assert.Equal( + [ExchangeSlot.ReceivedReport, ExchangeSlot.Exchange1], + fields.Where(f => !Ssb.SkipsField(f, dx)).Select(f => f.Slot).ToList()); + Assert.Equal( + [ExchangeSlot.ReceivedReport, ExchangeSlot.Section], + fields.Where(f => !Ssb.SkipsField(f, okom)).Select(f => f.Slot).ToList()); + } +} diff --git a/tests/Nonemm.Contests.Tests/TestLog.cs b/tests/Nonemm.Contests.Tests/TestLog.cs index cd83bc9..cb9076e 100644 --- a/tests/Nonemm.Contests.Tests/TestLog.cs +++ b/tests/Nonemm.Contests.Tests/TestLog.cs @@ -22,6 +22,10 @@ public static class TestLog PY,PP; Asiatic Russia: 17: 30: AS: 55.88: -84.10: -7.0: UA9: RM8,RM9,RM0,UA8,UA9,UA0,R8,R9,R0; + Slovak Republic: 15: 28: EU: 48.67: -19.50: -1.0: OM: + OM; + Czech Republic: 15: 28: EU: 49.80: -15.47: -1.0: OK: + OK,OL; """; public static readonly CountryFile CountryFile = CountryFile.Parse(Countries); @@ -46,6 +50,16 @@ public static class TestLog ArrlSection = "CT", }; + public static readonly StationInfo Slovakia = new() + { + Callsign = "OM5M", + CqZone = 15, + ItuZone = 28, + Continent = "EU", + CountryPrefix = "OM", + County = "BRA", + }; + public static Qso Contact( string call, double kilohertz = 14_025, diff --git a/tests/Nonemm.Contests.Tests/YotaTests.cs b/tests/Nonemm.Contests.Tests/YotaTests.cs new file mode 100644 index 0000000..e441bb9 --- /dev/null +++ b/tests/Nonemm.Contests.Tests/YotaTests.cs @@ -0,0 +1,49 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +public class YotaTests +{ + private static ContestLog LogFor(StationInfo me) => new(new Yota(), me, TestLog.CountryFile); + + private static Qso Contact(string call, string age, double kilohertz = 14_025, Mode? mode = null) => + TestLog.Contact(call, kilohertz, mode: mode, exchange: age); + + [Theory] + [InlineData("9", 13)] + [InlineData("12", 13)] + [InlineData("13", 12)] + [InlineData("16", 12)] + [InlineData("17", 11)] + [InlineData("21", 11)] + [InlineData("22", 10)] + [InlineData("25", 10)] + [InlineData("26", 1)] + public void AYoungOperatorIsWorthMore(string age, int expected) => + Assert.Equal(expected, LogFor(TestLog.Germany).Judge(Contact("IK2XYZ", age)).Points); + + /// The age pays the same from anywhere; only the contacts with everyone + /// else are scored by continent. + [Fact] + public void AgeBeatsTheContinent() + { + ContestLog log = LogFor(TestLog.Germany); + Assert.Equal(13, log.Judge(Contact("JA1XYZ", "11")).Points); + Assert.Equal(3, log.Judge(Contact("JA1XYZ", "44")).Points); + Assert.Equal(1, log.Judge(Contact("IK2XYZ", "44")).Points); + } + + [Fact] + public void AnAgeCountsOncePerBandWhateverTheMode() + { + ContestLog log = LogFor(TestLog.Germany); + log.Add(Contact("IK2XYZ", "19")); + Assert.Empty(log.Judge(Contact("DL9XYZ", "19", mode: Modes.Usb)).NewMultipliers); + Assert.Single(log.Judge(Contact("DL9XYZ", "19", 7_025)).NewMultipliers); + } + + [Fact] + public void AnExchangeThatIsNoAgeBringsNoMultiplier() => + Assert.Empty(LogFor(TestLog.Germany).Judge(Contact("IK2XYZ", "")).NewMultipliers); +}