Files
Nonemm/tests/Nonemm.Contests.Tests/UdcScoringTests.cs
ericek111 e3fa979584 Read the .udc CallHist multiplier and BonusPoints2
MultSqlString = CallHist counts every station the call history file
lists, once per callsign, which is how N1MM dedups it. The file reaches
the scoring code through ContestLog.History, and a QsoContext now
carries it; without a file loaded nothing counts.

BonusPoints2 = +50, calls.txt reads the callsigns from the support-files
folder and adds 50 to the contact, *2 doubles it and a plain 50 scores
50 instead. The folder comes from the registry, which the app passes
when it reads the .udc files. N1MM's other form, where the file is
grids.txt and the bonus is looked up by grid square, is not read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
2026-08-31 13:31:25 +00:00

374 lines
14 KiB
C#

using Nonemm.Contests.Udc;
using Nonemm.Core.Calls;
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);
}
/// The received exchange of a contest where one side sends a section and
/// the other a serial number lands in the same column, and only the
/// section counts. A file that names no list this program holds falls back
/// to taking an exchange of all digits for a serial number.
[Fact]
public void ASerialNumberIsNoSectionMultiplier()
{
UserDefinedContest contest = Contest("MultSqlString=Section", "IsMultPer=1");
Assert.Empty(LogFor(contest).Judge(TestLog.Contact("IK2XYZ", section: "058")).NewMultipliers);
Assert.Single(LogFor(contest).Judge(TestLog.Contact("OM3XYZ", section: "NOV")).NewMultipliers);
}
/// `MultWindowType` names the list N1MM checks the section against. `GZS`
/// is an OK/OM district and counts; `NOV` and the US state `MA` are not on
/// that list and count for neither program.
[Theory]
[InlineData("GZS", 1)]
[InlineData("NOV", 0)]
[InlineData("MA", 0)]
public void ASectionOffTheContestsListIsNoMultiplier(string section, int expected) =>
Assert.Equal(
expected,
LogFor(Contest(
"MultSqlString=Section",
"IsMultPer=1",
"MultWindowType=OKOMDX"))
.Judge(TestLog.Contact("OM3XYZ", section: section))
.NewMultipliers.Count);
/// `BonusPoints2` names how much a station on a list is worth and the file
/// the list is in. `+50` adds to what the contact scored.
[Theory]
[InlineData("+50, bonus.txt", 53)]
[InlineData("*2, bonus.txt", 6)]
[InlineData("50, bonus.txt", 50)]
public void BonusPoints2PaysForTheCallsignsInAFile(string setting, int expected)
{
string folder = Directory.CreateTempSubdirectory().FullName;
try
{
File.WriteAllText(Path.Combine(folder, "bonus.txt"), "OM3XYZ,a club station\nOM5ZZZ,\n");
UserDefinedContest contest = new(
UdcFile.Parse($"[Contest]\nName=BONUS\nPointsPerContact=3\nBonusPoints2={setting}"),
folder);
Assert.Equal(expected, Points(contest, TestLog.Contact("OM3XYZ")));
Assert.Equal(3, Points(contest, TestLog.Contact("IK2XYZ")));
}
finally
{
Directory.Delete(folder, recursive: true);
}
}
/// A file that is not there leaves the bonus off and the contest opens.
[Fact]
public void BonusPoints2WithoutItsFileScoresTheContactAsItIs() =>
Assert.Equal(
3,
Points(
new UserDefinedContest(
UdcFile.Parse("[Contest]\nName=BONUS\nPointsPerContact=3\nBonusPoints2=+50, missing.txt"),
Path.Combine(Path.GetTempPath(), "nonemm-no-such-folder")),
TestLog.Contact("OM3XYZ")));
/// `MultSqlString = CallHist` counts every station the call history file
/// lists, once per callsign. A station the file says nothing about brings
/// no multiplier.
[Fact]
public void CallHistCountsTheStationsTheFileLists()
{
UserDefinedContest contest = Contest("MultSqlString=CallHist", "IsMultPer=4");
ContestLog log = LogFor(contest);
log.History = CallHistory.Parse("OM3XYZ,JOE\nOM5ZZZ,SAM\n");
Assert.Equal(
"OM3XYZ",
log.Judge(TestLog.Contact("OM3XYZ")).NewMultipliers.Single().Value);
Assert.Empty(log.Judge(TestLog.Contact("IK2XYZ")).NewMultipliers);
}
/// Without a call history file loaded, a `CallHist` multiplier counts
/// nothing rather than counting everybody.
[Fact]
public void CallHistCountsNothingWithoutAFile() =>
Assert.Empty(
LogFor(Contest("MultSqlString=CallHist", "IsMultPer=4"))
.Judge(TestLog.Contact("OM3XYZ"))
.NewMultipliers);
/// Published files hold `Country` in this key; the UDC editor puts
/// `CountryPrefix` there.
[Fact]
public void CountryIsTheSameSourceAsCountryPrefix() =>
Assert.Equal(
"I",
LogFor(Contest("MultSqlString=Country", "IsMultPer=4"))
.Judge(TestLog.Contact("IK2XYZ"))
.NewMultipliers.Single().Value);
[Fact]
public void OtherCountryIsEveryCountryButMine()
{
UserDefinedContest contest = Contest("PointsPerContact=MyCountry, 0, OtherCountry, 2");
Assert.Equal(0, Points(contest, TestLog.Contact("DL9XYZ")));
Assert.Equal(2, Points(contest, TestLog.Contact("JA1XYZ")));
}
}