Read the rest of what a .udc file says, and score what N1MM scores
Two pieces of work on the scoring engine. The .udc reader now takes N1MM's published scoring vocabulary: the full PointsPerContact condition set, the PointsMultBy family, PowerMult, BonusPoints, the multiplier sources and the settings that say which stations bring a multiplier in. MultMult is a weight rather than a switch, as in N1MM's ComputeScore. The entry categories and the sent exchange reach the rules in a new ContestEntry, so a contest can score by the power category it is entered in or by what this station sends. Then every contest in a real N1MM log was scored again from these rules and compared with the points and multiplier flags N1MM wrote. That found five bugs: ARRL DX and IARU read the exchange from the wrong column, a stored " " was read as a value rather than as empty, CQ WW RTTY's own Canadian area names were not counted, a maritime mobile scored nothing, and a contact in a mode the contest does not run scored as if it were in the contest. docs/n1mm-interop.md has the check and what still differs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
@@ -12,7 +12,7 @@ public class ArrlDxTests
|
||||
public void DxWorkingWvieScoresThree()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Equal(3, log.Judge(TestLog.Contact("K1ABC", exchange: "CT")).Points);
|
||||
Assert.Equal(3, log.Judge(TestLog.Contact("K1ABC", section: "CT")).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -33,8 +33,8 @@ public class ArrlDxTests
|
||||
public void DxCountsStatesAndProvinces()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Equal("CT", log.Judge(TestLog.Contact("K1ABC", exchange: "CT")).NewMultipliers.Single().Value);
|
||||
Assert.Empty(log.Judge(TestLog.Contact("K1ABC", exchange: "XX")).NewMultipliers);
|
||||
Assert.Equal("CT", log.Judge(TestLog.Contact("K1ABC", section: "CT")).NewMultipliers.Single().Value);
|
||||
Assert.Empty(log.Judge(TestLog.Contact("K1ABC", section: "XX")).NewMultipliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -48,8 +48,28 @@ public class ArrlDxTests
|
||||
public void MultipliersCountOncePerBand()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
log.Add(TestLog.Contact("K1ABC", exchange: "CT"));
|
||||
Assert.Empty(log.Judge(TestLog.Contact("K2ABC", exchange: "CT")).NewMultipliers);
|
||||
Assert.Single(log.Judge(TestLog.Contact("K2ABC", 7_025, exchange: "CT")).NewMultipliers);
|
||||
log.Add(TestLog.Contact("K1ABC", section: "CT"));
|
||||
Assert.Empty(log.Judge(TestLog.Contact("K2ABC", section: "CT")).NewMultipliers);
|
||||
Assert.Single(log.Judge(TestLog.Contact("K2ABC", 7_025, section: "CT")).NewMultipliers);
|
||||
}
|
||||
|
||||
/// A Canadian station sends the postal code or the older abbreviation, and
|
||||
/// Newfoundland and Labrador is one province either way. N1MM counts `NF`
|
||||
/// and `LB` as two multipliers, which is two for one province.
|
||||
[Fact]
|
||||
public void NewfoundlandAndLabradorCountOnce()
|
||||
{
|
||||
ContestLog log = new(new ArrlDx(ModeCategory.Phone), TestLog.Germany, TestLog.CountryFile);
|
||||
log.Add(TestLog.Contact("VO1XYZ", section: "NF"));
|
||||
Assert.Empty(log.Judge(TestLog.Contact("VO2XYZ", section: "LB")).NewMultipliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheOlderQuebecAbbreviationIsQuebec()
|
||||
{
|
||||
ContestLog log = new(new ArrlDx(ModeCategory.Phone), TestLog.Germany, TestLog.CountryFile);
|
||||
Assert.Equal(
|
||||
"QC",
|
||||
log.Judge(TestLog.Contact("VA2XYZ", section: "QB")).NewMultipliers.Single().Value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,4 +90,24 @@ public class CqWorldWideRttyTests
|
||||
Assert.Equal(ExchangeSlot.Section, fields[2].Slot);
|
||||
Assert.False(fields[2].IsRequired);
|
||||
}
|
||||
|
||||
/// The Canadian areas this contest counts are its own list, which splits
|
||||
/// Newfoundland from Labrador.
|
||||
[Theory]
|
||||
[InlineData("NF")]
|
||||
[InlineData("LB")]
|
||||
public void TheContestsOwnCanadianAreasCount(string area) =>
|
||||
Assert.Contains(
|
||||
LogFor(TestLog.Germany).Judge(Contact("VO1XYZ", 5, area)).NewMultipliers,
|
||||
m => m.Index == 3 && m.Value == area);
|
||||
|
||||
/// A maritime mobile station belongs to no country. CQ WW counts it for the
|
||||
/// zone alone, and the contact still scores by continent.
|
||||
[Fact]
|
||||
public void MaritimeMobileScoresButBringsNoCountry()
|
||||
{
|
||||
Verdict verdict = LogFor(TestLog.Germany).Judge(Contact("IK2XYZ/MM", 15));
|
||||
Assert.Equal(2, verdict.Points);
|
||||
Assert.DoesNotContain(verdict.NewMultipliers, m => m.Index == 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,4 +66,16 @@ public class CqWpxTests
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Empty(log.Judge(TestLog.Contact("DL1ABC/MM")).NewMultipliers);
|
||||
}
|
||||
|
||||
/// A tuning carrier logged by accident, or any contact in a mode the
|
||||
/// contest does not run, scores nothing. The prefix still counts, which is
|
||||
/// what N1MM logs.
|
||||
[Fact]
|
||||
public void AContactInAnotherModeScoresNothingAndKeepsItsMultiplier()
|
||||
{
|
||||
ContestLog log = new(new CqWpx(ModeCategory.Phone), TestLog.Germany, TestLog.CountryFile);
|
||||
Verdict verdict = log.Judge(TestLog.Contact("IK2XYZ", mode: Modes.Rtty, number: 5));
|
||||
Assert.Equal(0, verdict.Points);
|
||||
Assert.Single(verdict.NewMultipliers);
|
||||
}
|
||||
}
|
||||
|
||||
264
tests/Nonemm.Contests.Tests/UdcScoringTests.cs
Normal file
264
tests/Nonemm.Contests.Tests/UdcScoringTests.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using Nonemm.Contests.Udc;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Tests;
|
||||
|
||||
/// The scoring settings a `.udc` file can carry: the points conditions, the
|
||||
/// points multipliers, the bonus, and where a multiplier comes from.
|
||||
public class UdcScoringTests
|
||||
{
|
||||
private static UserDefinedContest Contest(params string[] settings) =>
|
||||
new(UdcFile.Parse("[Contest]\nName=SCORETEST\n" + string.Join("\n", settings)));
|
||||
|
||||
private static ContestLog LogFor(
|
||||
UserDefinedContest contest,
|
||||
StationInfo? me = null,
|
||||
ContestEntry? entry = null) =>
|
||||
new(contest, me ?? TestLog.Germany, TestLog.CountryFile, entry);
|
||||
|
||||
private static int Points(UserDefinedContest contest, Qso qso, StationInfo? me = null) =>
|
||||
LogFor(contest, me).Judge(qso).Points;
|
||||
|
||||
[Theory]
|
||||
[InlineData("CT", 5)]
|
||||
[InlineData("MA", 1)]
|
||||
public void SectIsMatchesTheReceivedSection(string section, int expected) =>
|
||||
Assert.Equal(
|
||||
expected,
|
||||
Points(
|
||||
Contest("PointsPerContact=SectIs_CT, 5, OtherContinent, 1"),
|
||||
TestLog.Contact("K1ABC", section: section)));
|
||||
|
||||
[Fact]
|
||||
public void ExchIsNumSeparatesANumberFromAWord() =>
|
||||
Assert.Equal(
|
||||
3,
|
||||
Points(
|
||||
Contest("PointsPerContact=ExchIsNum, 3, ExchIsNotNum, 7"),
|
||||
TestLog.Contact("K1ABC", exchange: "129")));
|
||||
|
||||
[Fact]
|
||||
public void ExchIsEmptyScoresTheContactWithNoExchange() =>
|
||||
Assert.Equal(
|
||||
2,
|
||||
Points(Contest("PointsPerContact=ExchIs_Empty, 2, ExchIsNum, 9"), TestLog.Contact("K1ABC")));
|
||||
|
||||
[Fact]
|
||||
public void AStarScoresTheNumberInTheExchangeBox() =>
|
||||
Assert.Equal(
|
||||
42,
|
||||
Points(Contest("PointsPerContact=Exchange, *"), TestLog.Contact("K1ABC", exchange: "42")));
|
||||
|
||||
[Fact]
|
||||
public void MyCqZoneScoresAStationInMyOwnZone() =>
|
||||
Assert.Equal(
|
||||
1,
|
||||
Points(Contest("PointsPerContact=MyCQZone, 1, OtherContinent, 4"), TestLog.Contact("DL9XYZ")));
|
||||
|
||||
/// `SAMECONTINENT+160` is the same continent on one band only.
|
||||
[Fact]
|
||||
public void AContinentConditionCanCarryABand()
|
||||
{
|
||||
UserDefinedContest contest = Contest("PointsPerContact=SameContinent+160, 6, SameContinent, 2");
|
||||
Assert.Equal(6, Points(contest, TestLog.Contact("IK2XYZ", 1_830)));
|
||||
Assert.Equal(2, Points(contest, TestLog.Contact("IK2XYZ")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PointsMultByBandScalesWhatTheContactScores()
|
||||
{
|
||||
UserDefinedContest contest = Contest("PointsPerContact=3", "PointsMultByBand=160M, 3, 20M, 1");
|
||||
Assert.Equal(9, Points(contest, TestLog.Contact("IK2XYZ", 1_830)));
|
||||
Assert.Equal(3, Points(contest, TestLog.Contact("IK2XYZ")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PointsMultAtTimeGmtScalesInsideItsWindow()
|
||||
{
|
||||
UserDefinedContest contest = Contest("PointsPerContact=2", "PointsMultAtTimeGMT=2300, 0300, 2");
|
||||
DateTime midnight = new(2026, 5, 30, 0, 30, 0, DateTimeKind.Utc);
|
||||
Assert.Equal(4, Points(contest, TestLog.Contact("IK2XYZ", at: midnight)));
|
||||
Assert.Equal(2, Points(contest, TestLog.Contact("IK2XYZ")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BonusPointsAreAddedToWhatTheContactScores() =>
|
||||
Assert.Equal(
|
||||
26,
|
||||
Points(
|
||||
Contest("PointsPerContact=1", "BonusPoints=IK2XYZ, 25"),
|
||||
TestLog.Contact("IK2XYZ")));
|
||||
|
||||
/// A contest can pay the bonus for a new multiplier, so the multiplier has
|
||||
/// to be worked out before the points are.
|
||||
[Fact]
|
||||
public void BonusPointsCanBePaidForANewMultiplier()
|
||||
{
|
||||
UserDefinedContest contest = Contest(
|
||||
"PointsPerContact=1",
|
||||
"MultSqlString=CountryPrefix",
|
||||
"IsMultPer=1",
|
||||
"BonusPoints=IsMult1, 10");
|
||||
ContestLog log = LogFor(contest);
|
||||
Assert.Equal(11, log.Judge(TestLog.Contact("IK2XYZ")).Points);
|
||||
log.Add(TestLog.Contact("IK2XYZ"));
|
||||
Assert.Equal(1, log.Judge(TestLog.Contact("IK3XYZ")).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADistanceTableScoresByTheRangeTheContactFallsIn()
|
||||
{
|
||||
UserDefinedContest contest = Contest("PointsPerContact=0/100/7");
|
||||
StationInfo me = TestLog.Germany with { GridSquare = "JO60" };
|
||||
Qso near = TestLog.Contact("DL9XYZ") with { GridSquare = "JO60" };
|
||||
Qso far = TestLog.Contact("JA1XYZ") with { GridSquare = "PM95" };
|
||||
Assert.Equal(7, Points(contest, near, me));
|
||||
Assert.True(Points(contest, far, me) > 1_000);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MultSqlString=2LPREFIX", "IK")]
|
||||
[InlineData("MultSqlString=LastLetter", "Z")]
|
||||
[InlineData("MultSqlString=EU_Country", "I")]
|
||||
public void TheMultiplierValueComesFromTheSource(string setting, string expected)
|
||||
{
|
||||
UserDefinedContest contest = Contest(setting, "IsMultPer=4");
|
||||
Verdict verdict = LogFor(contest).Judge(TestLog.Contact("IK2XYZ"));
|
||||
Assert.Equal(expected, Assert.Single(verdict.NewMultipliers).Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACountryOnAnotherContinentBringsNoContinentMultiplier() =>
|
||||
Assert.Empty(
|
||||
LogFor(Contest("MultSqlString=EU_Country", "IsMultPer=4"))
|
||||
.Judge(TestLog.Contact("JA1XYZ"))
|
||||
.NewMultipliers);
|
||||
|
||||
[Fact]
|
||||
public void CountMultOnlyForTakesAContinentAsWellAsACountry()
|
||||
{
|
||||
UserDefinedContest contest = Contest(
|
||||
"MultSqlString=CountryPrefix",
|
||||
"IsMultPer=4",
|
||||
"CountMultOnlyFor=EU");
|
||||
Assert.Single(LogFor(contest).Judge(TestLog.Contact("IK2XYZ")).NewMultipliers);
|
||||
Assert.Empty(LogFor(contest).Judge(TestLog.Contact("JA1XYZ")).NewMultipliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoNotCountMultOnlyForLeavesThoseStationsOut() =>
|
||||
Assert.Empty(
|
||||
LogFor(Contest("MultSqlString=CountryPrefix", "IsMultPer=4", "DoNotCountMultOnlyFor=JA"))
|
||||
.Judge(TestLog.Contact("JA1XYZ"))
|
||||
.NewMultipliers);
|
||||
|
||||
[Fact]
|
||||
public void DoNotCountMeAsMultLeavesOutMyOwnCountry() =>
|
||||
Assert.Empty(
|
||||
LogFor(Contest("MultSqlString=CountryPrefix", "IsMultPer=4", "DoNotCountMeAsMult=True"))
|
||||
.Judge(TestLog.Contact("DL9XYZ"))
|
||||
.NewMultipliers);
|
||||
|
||||
[Fact]
|
||||
public void MultMultSaysWhatAMultiplierIsWorth()
|
||||
{
|
||||
ContestLog log = LogFor(Contest(
|
||||
"PointsPerContact=1",
|
||||
"MultSqlString=CountryPrefix",
|
||||
"IsMultPer=4",
|
||||
"MultMult=2"));
|
||||
log.Add(TestLog.Contact("JA1XYZ"));
|
||||
log.Add(TestLog.Contact("IK2XYZ"));
|
||||
Assert.Equal(8, log.TotalScore);
|
||||
}
|
||||
|
||||
/// A multiplier weighted zero is still worked, and adds nothing.
|
||||
[Fact]
|
||||
public void MultMultZeroLeavesTheScoreAtThePoints()
|
||||
{
|
||||
ContestLog log = LogFor(Contest(
|
||||
"PointsPerContact=3",
|
||||
"MultSqlString=CountryPrefix",
|
||||
"IsMultPer=4",
|
||||
"MultMult=0"));
|
||||
log.Add(TestLog.Contact("JA1XYZ"));
|
||||
Assert.Equal(3, log.TotalScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PowerMultScalesTheWholeScoreByTheEntryCategory()
|
||||
{
|
||||
UserDefinedContest contest = Contest("PointsPerContact=2", "PowerMult=QRP, 3, LP, 2, HP, 1");
|
||||
ContestLog qrp = LogFor(contest, entry: new ContestEntry { PowerCategory = "QRP" });
|
||||
ContestLog low = LogFor(contest, entry: new ContestEntry { PowerCategory = "LOW" });
|
||||
qrp.Add(TestLog.Contact("JA1XYZ"));
|
||||
low.Add(TestLog.Contact("JA1XYZ"));
|
||||
Assert.Equal(6, qrp.TotalScore);
|
||||
Assert.Equal(4, low.TotalScore);
|
||||
}
|
||||
|
||||
/// `PointsMultByCategory` is about the station being worked, not the entry:
|
||||
/// N1MM scales only a callsign signing QRP.
|
||||
[Fact]
|
||||
public void PointsMultByCategoryScalesAStationSigningQrp()
|
||||
{
|
||||
UserDefinedContest contest = Contest("PointsPerContact=2", "PointsMultByCategory=/QRP, 3");
|
||||
Assert.Equal(6, Points(contest, TestLog.Contact("JA1XYZ/QRP")));
|
||||
Assert.Equal(2, Points(contest, TestLog.Contact("JA1XYZ")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MyExchangeComparesWhatThisStationSends()
|
||||
{
|
||||
UserDefinedContest contest = Contest("PointsPerContact=MyExchange, 1, OtherContinent, 5");
|
||||
ContestEntry entry = new() { SentExchange = "14" };
|
||||
Assert.Equal(
|
||||
1,
|
||||
LogFor(contest, entry: entry).Judge(TestLog.Contact("JA1XYZ", section: "14")).Points);
|
||||
Assert.Equal(
|
||||
5,
|
||||
LogFor(contest, entry: entry).Judge(TestLog.Contact("JA1XYZ", section: "15")).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumMultsCapsHowManyAreCounted()
|
||||
{
|
||||
UserDefinedContest contest = Contest(
|
||||
"MultSqlString=CountryPrefix",
|
||||
"MultSqlString2=Continent",
|
||||
"IsMultPer=4",
|
||||
"NumMults=1");
|
||||
Assert.Single(LogFor(contest).Judge(TestLog.Contact("JA1XYZ")).NewMultipliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DupeSqlStringMakesASecondSectionADifferentContact()
|
||||
{
|
||||
UserDefinedContest contest = Contest("DupeType=1", "DupeSqlString=1");
|
||||
ContestLog log = LogFor(contest);
|
||||
log.Add(TestLog.Contact("K1ABC", section: "CT"));
|
||||
Assert.False(log.Judge(TestLog.Contact("K1ABC", section: "MA")).IsDupe);
|
||||
Assert.True(log.Judge(TestLog.Contact("K1ABC", section: "CT")).IsDupe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsWorkableLeavesOutTheStationsTheContestDoesNotCount()
|
||||
{
|
||||
UserDefinedContest contest = Contest(
|
||||
"PointsPerContact=3",
|
||||
"MultSqlString=CountryPrefix",
|
||||
"IsMultPer=4",
|
||||
"IsWorkable=EUonly");
|
||||
Verdict outside = LogFor(contest).Judge(TestLog.Contact("JA1XYZ"));
|
||||
Assert.Equal(0, outside.Points);
|
||||
Assert.Empty(outside.NewMultipliers);
|
||||
Assert.Equal(3, Points(contest, TestLog.Contact("IK2XYZ")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZoneTypeIaruPutsTheContestOnItuZones()
|
||||
{
|
||||
Assert.True(Contest("ZoneType=IARU").UsesItuZones);
|
||||
Assert.False(Contest("ZoneType=CQ").UsesItuZones);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user