Four settings were read by nothing. MultipleSessions = 0000/30 runs the contest in half-hour sessions from 00:00 UTC. The session is part of the dupe key, so a station worked in an earlier one may be worked again, and ResetMultsEverySession and ResetSNEverySession start the multipliers and the serial numbers over. DupeQSOMinutesAgo is the other way back to a station: after so many minutes rather than in the next session. The log holds the time of the last contact per dupe key instead of only the key, and the setting's IgnoreBand and ThisMode decide the scope, because a file that uses it turns DupeType off. MinimumOffTime drives the time on and time off the score summary now shows under the score, and beside them are the band changes made against the allowance for a contest that limits them. N1MM's countdown for the minutes you have to stay on a new band needs its info window and is not here. UdcFile.Flag now takes 1 and 0 as well as True and False, which is what these settings are written as and what N1MM reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
452 lines
17 KiB
C#
452 lines
17 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);
|
|
|
|
/// `DupeQSOMinutesAgo = 10` lets the same station be worked again ten
|
|
/// minutes after the last contact, and not before.
|
|
[Fact]
|
|
public void ADupeWindowLetsAStationBeWorkedAgainLater()
|
|
{
|
|
UserDefinedContest contest = Contest("DupeType=4", "DupeQSOMinutesAgo=10");
|
|
ContestLog log = LogFor(contest);
|
|
DateTime first = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
|
|
log.Add(TestLog.Contact("OM3XYZ") with { TimestampUtc = first });
|
|
|
|
Assert.True(log.Judge(TestLog.Contact("OM3XYZ") with
|
|
{
|
|
TimestampUtc = first.AddMinutes(9),
|
|
}).IsDupe);
|
|
Assert.False(log.Judge(TestLog.Contact("OM3XYZ") with
|
|
{
|
|
TimestampUtc = first.AddMinutes(11),
|
|
}).IsDupe);
|
|
}
|
|
|
|
/// `IgnoreBand` looks for the earlier contact on every band rather than
|
|
/// this one, so the same station on another band is still a dupe.
|
|
[Fact]
|
|
public void ADupeWindowThatIgnoresTheBandCoversEveryBand()
|
|
{
|
|
UserDefinedContest contest = Contest("DupeType=4", "DupeQSOMinutesAgo=10, True, True");
|
|
ContestLog log = LogFor(contest);
|
|
DateTime first = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
|
|
log.Add(TestLog.Contact("OM3XYZ") with { TimestampUtc = first });
|
|
|
|
Assert.True(log.Judge(TestLog.Contact("OM3XYZ", 7_040) with
|
|
{
|
|
TimestampUtc = first.AddMinutes(5),
|
|
}).IsDupe);
|
|
}
|
|
|
|
/// `MultipleSessions = 0000/30` runs the contest in half-hour sessions,
|
|
/// and a station worked in one of them may be worked again in the next.
|
|
[Fact]
|
|
public void AStationWorkedInAnEarlierSessionIsNoDupe()
|
|
{
|
|
UserDefinedContest contest = Contest("MultipleSessions=0000/30");
|
|
ContestLog log = LogFor(contest);
|
|
DateTime first = new(2026, 5, 30, 12, 10, 0, DateTimeKind.Utc);
|
|
log.Add(TestLog.Contact("OM3XYZ") with { TimestampUtc = first });
|
|
|
|
Assert.True(log.Judge(TestLog.Contact("OM3XYZ") with
|
|
{
|
|
TimestampUtc = first.AddMinutes(10),
|
|
}).IsDupe);
|
|
Assert.False(log.Judge(TestLog.Contact("OM3XYZ") with
|
|
{
|
|
TimestampUtc = first.AddMinutes(25),
|
|
}).IsDupe);
|
|
}
|
|
|
|
/// `ResetMultsEverySession` counts the same multiplier again in the next
|
|
/// session. Without it the multiplier stands for the whole contest.
|
|
[Theory]
|
|
[InlineData("ResetMultsEverySession=1", 1)]
|
|
[InlineData("ResetMultsEverySession=0", 0)]
|
|
public void MultipliersStartOverEachSessionWhenTheFileSaysSo(string setting, int expected)
|
|
{
|
|
UserDefinedContest contest = Contest(
|
|
"MultipleSessions=0000/30",
|
|
"MultSqlString=CountryPrefix",
|
|
"IsMultPer=4",
|
|
setting);
|
|
ContestLog log = LogFor(contest);
|
|
DateTime first = new(2026, 5, 30, 12, 10, 0, DateTimeKind.Utc);
|
|
log.Add(TestLog.Contact("OM3XYZ") with { TimestampUtc = first });
|
|
|
|
Assert.Equal(
|
|
expected,
|
|
log.Judge(TestLog.Contact("OM5ZZZ") with { TimestampUtc = first.AddMinutes(25) })
|
|
.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")));
|
|
}
|
|
}
|