diff --git a/README.md b/README.md index e2d26be..e60bc02 100644 --- a/README.md +++ b/README.md @@ -428,6 +428,26 @@ 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. +### Sessions, time off and band changes + +A contest can be run in sessions, and a `.udc` file says so with +`MultipleSessions = 0000/30` — sessions of thirty minutes from 00:00 UTC. A +station worked in an earlier session may be worked again, and +`ResetMultsEverySession` and `ResetSNEverySession` start the multipliers and the +serial numbers over as well. + +`DupeQSOMinutesAgo` is the other way a contest lets a station be worked again: +after so many minutes rather than in the next session. Its `IgnoreBand` and +`ThisMode` settings decide whether the earlier contact is looked for on this +band only and in this mode only. + +The score summary shows the time on and the time off under the score. A break +counts as time off once it is as long as the contest's `MinimumOffTime`, which +is 30 minutes unless the file says otherwise. Next to them, for a contest that +limits band changes, are the changes made and the allowance — +`SingleOpCountableBandChange` and the rest of that family. N1MM's countdown for +the minutes you have to stay on a new band is not here. + ### Radios **Config → Radios** takes a `rigctld` address per radio. Each radio needs its diff --git a/docs/unfinished.md b/docs/unfinished.md index 7046271..6c941ab 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -111,7 +111,9 @@ with the CW and SSB running of the contest. `.udc` settings that are still passed over. `MultiplierBands` and `QsoErrorString`, which are about the windows rather than the score. `GenericPrintString`, which is the layout of a printed log rather than a -Cabrillo one. +Cabrillo one. `SOBandChangeTimerDuration` and `MOBandChangeTimerDuration`, the +minutes a station has to stay on a new band, which N1MM shows as a countdown in +its info window; this program has no info window and no countdown. `MultWindowType` is read as far as the score goes: it names the list a section multiplier is checked against. Four of N1MM's lists are held here — the ARRL @@ -131,11 +133,7 @@ alone, which writes only half of a two-box exchange. Layout 6, the ARRL RTTY Roundup line, is not read, and a file asking for it gets the default line; no published `.udc` file asks for it, because the Roundup has a contest class of its own. `CabrilloFormat = 0` means the sponsor takes no Cabrillo log at all, -and nothing here stops the operator writing one. The -session, off-time and band-change settings -(`MultipleSessions`, `MinimumOffTime`, the `…BandChange…` family, -`DupeQSOMinutesAgo`) are read by nothing, so a contest with periods is logged -as one long session. +and nothing here stops the operator writing one. N1MM's `.udc` format has no way to say that the exchange itself differs by the other station's country. OK/OM DX is written in code for that reason — it asks diff --git a/src/Nonemm.App/Windows/ScoreWindow.axaml.cs b/src/Nonemm.App/Windows/ScoreWindow.axaml.cs index 700d78f..4a8d41b 100644 --- a/src/Nonemm.App/Windows/ScoreWindow.axaml.cs +++ b/src/Nonemm.App/Windows/ScoreWindow.axaml.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Nonemm.Contests; using Nonemm.Core; namespace Nonemm.App.Windows; @@ -59,9 +60,32 @@ public sealed partial class ScoreWindow : RefreshableWindow session.Logging.Log.Tally.TotalMultipliers.ToString(), header: true); - TotalText.Text = $"Claimed score {session.Logging.Log.TotalScore:N0}"; + TimeSpan on = OperatingTime.On( + session.Logging.Log.Qsos, + session.Logging.Contest.MinimumOffTime); + TimeSpan off = OperatingTime.Off( + session.Logging.Log.Qsos, + session.Logging.Contest.MinimumOffTime); + TotalText.Text = + $"Claimed score {session.Logging.Log.TotalScore:N0}" + + $" on {Hours(on)} off {Hours(off)}" + + BandChanges(); } + /// The band changes made and the allowance, for a contest that limits + /// them, and nothing at all for the contests that do not. + private string BandChanges() + { + BandChangeRules rules = session.Logging!.Contest.BandChangesFor(session.Logging.Log.Entry); + return rules.IsCounted + ? $" band changes {rules.CountedIn(session.Logging.Log.Qsos, DateTime.UtcNow)}/{rules.Max}" + : ""; + } + + /// Hours and minutes, which is how a sponsor asks for operating time. + private static string Hours(TimeSpan time) => + $"{(int)time.TotalHours}:{time.Minutes:00}"; + private static int MultiplierCount(Qso qso) => (qso.IsMultiplier1 ? 1 : 0) + (qso.IsMultiplier2 ? 1 : 0) + (qso.IsMultiplier3 ? 1 : 0); diff --git a/src/Nonemm.Contests/BandChangeRules.cs b/src/Nonemm.Contests/BandChangeRules.cs new file mode 100644 index 0000000..7d37465 --- /dev/null +++ b/src/Nonemm.Contests/BandChangeRules.cs @@ -0,0 +1,48 @@ +using Nonemm.Core; + +namespace Nonemm.Contests; + +/// What a contest allows a station in the way of band changes, and how to +/// count the ones already made. N1MM shows the same count in its info window. +/// +/// `Max` is how many changes the entry is allowed. The window the count is +/// taken over is the clock hour, unless `PerContest` counts the whole contest +/// or `PerPeriod` counts the hour up to now. +public sealed record BandChangeRules( + int Max, + bool PerContest = false, + bool PerPeriod = false, + bool CountsModeChanges = false, + TimeSpan MinimumStay = default) +{ + /// A contest that does not limit band changes at all. + public static readonly BandChangeRules None = new(0); + + public bool IsCounted => Max > 0; + + /// How many band changes fall in the window that ends at `now`. The first + /// contact of the log is not a change. + public int CountedIn(IEnumerable qsos, DateTime now) + { + List ordered = qsos.OrderBy(q => q.TimestampUtc).ToList(); + int changes = 0; + for (int at = 1; at < ordered.Count; at++) + { + if (IsChange(ordered[at - 1], ordered[at]) && IsInWindow(ordered[at].TimestampUtc, now)) + { + changes++; + } + } + return changes; + } + + private bool IsChange(Qso before, Qso after) => + before.Band != after.Band + || (CountsModeChanges && before.Mode.Category != after.Mode.Category); + + private bool IsInWindow(DateTime when, DateTime now) => + PerContest + || (PerPeriod + ? when <= now && now - when < TimeSpan.FromHours(1) + : when.Date == now.Date && when.Hour == now.Hour); +} diff --git a/src/Nonemm.Contests/Contest.cs b/src/Nonemm.Contests/Contest.cs index 8f6d20a..ec83b97 100644 --- a/src/Nonemm.Contests/Contest.cs +++ b/src/Nonemm.Contests/Contest.cs @@ -60,6 +60,29 @@ public interface Contest /// the message goes out. Only the BARTG RTTY contest asks for it. bool StampsSentTime => false; + /// How long a station stays a dupe. Null for the usual rule, where a + /// station worked once cannot be worked again for the rest of the contest. + /// N1MM's `DupeQSOMinutesAgo`. + TimeSpan? DupeWindow => null; + + /// Which session of the contest a time belongs to. A contest run in + /// sessions allows a station to be worked again in each of them, and can + /// start its multipliers and its serial numbers over as well. Everything + /// is session 0 for a contest run in one go. + int SessionOf(DateTime utc) => 0; + + bool ResetsMultipliersEachSession => false; + + bool ResetsSerialNumbersEachSession => false; + + /// How many band changes the entry may make, and over what stretch of time + /// they are counted. Most contests do not limit them. + BandChangeRules BandChangesFor(ContestEntry entry) => BandChangeRules.None; + + /// How long a gap between contacts has to be before it counts as time off. + /// N1MM asks each contest and takes 30 minutes when it says nothing. + TimeSpan MinimumOffTime => TimeSpan.FromMinutes(30); + /// True for a contest whose log carries QTC traffic as rows of its own. /// The log window then shows the series they belong to. bool HasQtcTraffic => false; diff --git a/src/Nonemm.Contests/ContestLog.cs b/src/Nonemm.Contests/ContestLog.cs index 3c83e50..629d6df 100644 --- a/src/Nonemm.Contests/ContestLog.cs +++ b/src/Nonemm.Contests/ContestLog.cs @@ -12,7 +12,7 @@ public sealed class ContestLog private readonly StationInfo me; private readonly CountryFile? countries; private readonly List qsos = []; - private readonly HashSet workedKeys = new(StringComparer.Ordinal); + private readonly Dictionary workedKeys = new(StringComparer.Ordinal); private readonly HashSet claimedMultipliers = new(StringComparer.Ordinal); private ScoreTally tally = new(); @@ -52,14 +52,14 @@ public sealed class ContestLog { return new Verdict(false, contest.PointsFor(context), []); } - if (contest.DupeScope != DupeScope.Never && workedKeys.Contains(DupeKey(candidate))) + if (IsWorked(candidate)) { return Verdict.Dupe; } List newOnes = []; foreach (Multiplier multiplier in contest.MultipliersFor(context)) { - if (!claimedMultipliers.Contains(MultiplierKey(multiplier))) + if (!claimedMultipliers.Contains(MultiplierKey(multiplier, candidate))) { newOnes.Add(multiplier); } @@ -114,10 +114,22 @@ public sealed class ContestLog Rebuild(); } - public bool IsWorked(Qso candidate) => - contest.DupeScope != DupeScope.Never - && contest.IsContact(candidate) - && workedKeys.Contains(DupeKey(candidate)); + /// A station worked before, under the contest's dupe rule. A contest with + /// a dupe window — N1MM's `DupeQSOMinutesAgo` — lets the same station be + /// worked again once the window has passed. + public bool IsWorked(Qso candidate) + { + if (contest.DupeScope == DupeScope.Never || !contest.IsContact(candidate)) + { + return false; + } + if (!workedKeys.TryGetValue(DupeKey(candidate), out DateTime last)) + { + return false; + } + return contest.DupeWindow is not { } window + || (candidate.TimestampUtc - last).Duration() < window; + } /// Every contact with this call, newest first. public IReadOnlyList WorkedBefore(string call) => @@ -159,10 +171,10 @@ public sealed class ContestLog { return; } - workedKeys.Add(DupeKey(qso)); + workedKeys[DupeKey(qso)] = qso.TimestampUtc; foreach (Multiplier multiplier in verdict.NewMultipliers) { - claimedMultipliers.Add(MultiplierKey(multiplier)); + claimedMultipliers.Add(MultiplierKey(multiplier, qso)); tally.AddMultiplier(multiplier); } } @@ -197,9 +209,13 @@ public sealed class ContestLog DupeScope.PerBandAndMode => $"{call}|{qso.Band?.Name}|{qso.Mode.Category}", _ => call, }; + // a station worked in an earlier session may be worked again + key = $"{key}|{contest.SessionOf(qso.TimestampUtc)}"; return extra.Length == 0 ? key : $"{key}|{extra}"; } - private static string MultiplierKey(Multiplier multiplier) => - $"{multiplier.Index}|{multiplier.Value}|{multiplier.Scope}"; + private string MultiplierKey(Multiplier multiplier, Qso qso) => + contest.ResetsMultipliersEachSession + ? $"{multiplier.Index}|{multiplier.Value}|{multiplier.Scope}|{contest.SessionOf(qso.TimestampUtc)}" + : $"{multiplier.Index}|{multiplier.Value}|{multiplier.Scope}"; } diff --git a/src/Nonemm.Contests/OperatingTime.cs b/src/Nonemm.Contests/OperatingTime.cs new file mode 100644 index 0000000..5d4ecc8 --- /dev/null +++ b/src/Nonemm.Contests/OperatingTime.cs @@ -0,0 +1,48 @@ +using Nonemm.Core; + +namespace Nonemm.Contests; + +/// Time on and time off, which the sponsors of a contest with a rest rule ask +/// the entrant to declare. A gap between two contacts counts as time off when +/// it is at least the contest's `MinimumOffTime`; anything shorter is time +/// spent operating. This is how N1MM's off-time window reads a log. +public static class OperatingTime +{ + /// The gaps that count as time off, in the order they happened. + public static IReadOnlyList<(DateTime From, DateTime To)> Breaks( + IEnumerable qsos, + TimeSpan minimum) + { + List<(DateTime, DateTime)> found = []; + DateTime? last = null; + foreach (Qso qso in qsos.OrderBy(q => q.TimestampUtc)) + { + if (last is { } before && qso.TimestampUtc - before >= minimum) + { + found.Add((before, qso.TimestampUtc)); + } + last = qso.TimestampUtc; + } + return found; + } + + public static TimeSpan Off(IEnumerable qsos, TimeSpan minimum) + { + TimeSpan total = TimeSpan.Zero; + foreach ((DateTime from, DateTime to) in Breaks(qsos, minimum)) + { + total += to - from; + } + return total; + } + + /// From the first contact to the last, less the time off. A log of one + /// contact or none is no time at all. + public static TimeSpan On(IEnumerable qsos, TimeSpan minimum) + { + List ordered = qsos.OrderBy(q => q.TimestampUtc).ToList(); + return ordered.Count < 2 + ? TimeSpan.Zero + : ordered[^1].TimestampUtc - ordered[0].TimestampUtc - Off(ordered, minimum); + } +} diff --git a/src/Nonemm.Contests/Udc/UdcDupeWindow.cs b/src/Nonemm.Contests/Udc/UdcDupeWindow.cs new file mode 100644 index 0000000..4ee28d0 --- /dev/null +++ b/src/Nonemm.Contests/Udc/UdcDupeWindow.cs @@ -0,0 +1,48 @@ +namespace Nonemm.Contests.Udc; + +/// `DupeQSOMinutesAgo = 10, True, False, True, False`: a station worked less +/// than ten minutes ago is a dupe, and after that it can be worked again. The +/// settings after the minutes are N1MM's, and two of them decide how wide to +/// look for that earlier contact: +/// +/// | Setting | Default | What it does here | +/// |---|---|---| +/// | CompareToSecond | True | not read: the times are compared to the second | +/// | IgnoreBand | False | true looks on every band rather than this one | +/// | InfoStatus | True | not read: it turns N1MM's info window message on | +/// | ThisMode | False | true looks only at contacts in the same mode | +/// +/// A file that sets this also sets `DupeType = 4`, which is N1MM's way of +/// saying the time decides on its own, so the scope here comes from these two +/// settings rather than from `DupeType`. +public sealed class UdcDupeWindow +{ + private readonly int minutes; + private readonly bool ignoresBand; + private readonly bool thisModeOnly; + + public UdcDupeWindow(string setting) + { + string[] parts = setting.Split(',', StringSplitOptions.TrimEntries); + if (parts.Length == 0 || !int.TryParse(parts[0], out minutes)) + { + return; + } + ignoresBand = Flag(parts, 2, false); + thisModeOnly = Flag(parts, 4, false); + } + + public bool IsDefined => minutes > 0; + + public TimeSpan? Window => IsDefined ? TimeSpan.FromMinutes(minutes) : null; + + public DupeScope Scope => + ignoresBand + ? thisModeOnly ? DupeScope.PerMode : DupeScope.Once + : thisModeOnly ? DupeScope.PerBandAndMode : DupeScope.PerBand; + + private static bool Flag(string[] parts, int at, bool absent) => + parts.Length > at && parts[at].Length > 0 + ? parts[at].Equals("TRUE", StringComparison.OrdinalIgnoreCase) + : absent; +} diff --git a/src/Nonemm.Contests/Udc/UdcFile.cs b/src/Nonemm.Contests/Udc/UdcFile.cs index 7422fdb..1ba40b2 100644 --- a/src/Nonemm.Contests/Udc/UdcFile.cs +++ b/src/Nonemm.Contests/Udc/UdcFile.cs @@ -55,8 +55,17 @@ public sealed class UdcFile ? value : fallback; - public bool Flag(string key, bool fallback) => - bool.TryParse(Lookup(key), out bool value) ? value : fallback; + /// A yes-or-no setting. Files write these as `True`/`False` and as `1`/`0`, + /// and N1MM takes both. + public bool Flag(string key, bool fallback) + { + string? value = Lookup(key)?.Trim(); + if (bool.TryParse(value, out bool said)) + { + return said; + } + return int.TryParse(value, out int number) ? number != 0 : fallback; + } /// A comma-separated value with the spaces trimmed off each part. public IReadOnlyList List(string key) => diff --git a/src/Nonemm.Contests/Udc/UdcSessions.cs b/src/Nonemm.Contests/Udc/UdcSessions.cs new file mode 100644 index 0000000..8bb4bdb --- /dev/null +++ b/src/Nonemm.Contests/Udc/UdcSessions.cs @@ -0,0 +1,66 @@ +using System.Globalization; + +namespace Nonemm.Contests.Udc; + +/// `MultipleSessions = 0000/30`: the contest runs in sessions, the first one +/// starting at 00:00 UTC and each lasting 30 minutes. A station worked in an +/// earlier session may be worked again, and `ResetMultsEverySession` and +/// `ResetSNEverySession` say whether the multipliers and the serial numbers +/// start over as well. +/// +/// N1MM writes the length as minutes up to two digits and as hours and minutes +/// above that: `30` is half an hour, `200` is two hours. The shortest session +/// N1MM takes is ten minutes. +public sealed class UdcSessions +{ + private static readonly TimeSpan Shortest = TimeSpan.FromMinutes(10); + + private readonly TimeSpan start; + private readonly TimeSpan length; + + public UdcSessions(UdcFile file) + { + ResetsMultipliers = file.Flag("ResetMultsEverySession", false); + ResetsSerialNumbers = file.Flag("ResetSNEverySession", false); + string[] parts = file.Text("MultipleSessions").Split('/', StringSplitOptions.TrimEntries); + if (parts.Length != 2) + { + return; + } + TimeSpan? first = HoursAndMinutes(parts[0]); + TimeSpan? each = HoursAndMinutes(parts[1]); + if (first is null || each is null || each < Shortest) + { + return; + } + start = first.Value; + length = each.Value; + IsDefined = true; + } + + public bool IsDefined { get; } + + public bool ResetsMultipliers { get; } + + public bool ResetsSerialNumbers { get; } + + /// Which session a contact belongs to. The sessions are counted off a + /// fixed date so that two days of a contest never share a number, which is + /// what N1MM's own stepping from the start time gives. Every contact is in + /// session 0 for a contest without sessions. + public int SessionOf(DateTime utc) => + IsDefined ? (int)((utc - (DateTime.UnixEpoch + start)).Ticks / length.Ticks) : 0; + + /// `30` is thirty minutes, `200` two hours, `0000` midnight. A value that + /// is not a number at all leaves the setting off. + private static TimeSpan? HoursAndMinutes(string value) + { + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int number)) + { + return null; + } + return value.Length > 2 + ? TimeSpan.FromMinutes((number / 100 * 60) + (number % 100)) + : TimeSpan.FromMinutes(number); + } +} diff --git a/src/Nonemm.Contests/Udc/UserDefinedContest.cs b/src/Nonemm.Contests/Udc/UserDefinedContest.cs index 78a588d..c94894c 100644 --- a/src/Nonemm.Contests/Udc/UserDefinedContest.cs +++ b/src/Nonemm.Contests/Udc/UserDefinedContest.cs @@ -17,6 +17,8 @@ public sealed class UserDefinedContest : Contest private readonly UdcCabrillo cabrillo; private readonly UdcCabrilloFormat cabrilloFormat; private readonly UdcBonusCalls bonusCalls; + private readonly UdcSessions sessions; + private readonly UdcDupeWindow dupeWindow; /// `supportFolder` is where `BonusPoints2` reads its list of callsigns /// from, which is N1MM's support-files folder. Without it that setting is @@ -33,6 +35,8 @@ public sealed class UserDefinedContest : Contest cabrillo = new UdcCabrillo(file.Text("CabrilloString")); cabrilloFormat = new UdcCabrilloFormat(file.Number("CabrilloFormat", 1)); bonusCalls = new UdcBonusCalls(file.Text("BonusPoints2"), supportFolder); + sessions = new UdcSessions(file); + dupeWindow = new UdcDupeWindow(file.Text("DupeQSOMinutesAgo")); } public static UserDefinedContest Load(string path) => @@ -55,13 +59,46 @@ public sealed class UserDefinedContest : Contest .Where(name => name.Length > 0) .ToList(); - public DupeScope DupeScope => file.Number("DupeType", 2) switch + /// `DupeQSOMinutesAgo` decides the scope itself when it is set, because a + /// file that uses it turns `DupeType` off. + public DupeScope DupeScope => dupeWindow.IsDefined + ? dupeWindow.Scope + : file.Number("DupeType", 2) switch + { + 1 => DupeScope.Once, + 3 => DupeScope.PerBandAndMode, + 4 => DupeScope.Never, + _ => DupeScope.PerBand, + }; + + public TimeSpan? DupeWindow => dupeWindow.Window; + + public int SessionOf(DateTime utc) => sessions.SessionOf(utc); + + public bool ResetsMultipliersEachSession => sessions.ResetsMultipliers; + + public bool ResetsSerialNumbersEachSession => sessions.ResetsSerialNumbers; + + public TimeSpan MinimumOffTime => TimeSpan.FromMinutes(file.Number("MinimumOffTime", 30)); + + /// The `…BandChange…` family. A multi-op entry reads the `MultiOp…` + /// settings and everyone else the `SingleOp…` ones, which is the split + /// N1MM makes. + public BandChangeRules BandChangesFor(ContestEntry entry) { - 1 => DupeScope.Once, - 3 => DupeScope.PerBandAndMode, - 4 => DupeScope.Never, - _ => DupeScope.PerBand, - }; + bool multiOp = entry.OperatorCategory.StartsWith("MULTI", StringComparison.OrdinalIgnoreCase); + string side = multiOp ? "MultiOp" : "SingleOp"; + string max = multiOp ? "MOBandChangeCountMax" : "SOBandChangeCountMax"; + string stay = multiOp ? "MOBandChangeTimerDuration" : "SOBandChangeTimerDuration"; + return file.Flag($"{side}CountableBandChange", false) + ? new BandChangeRules( + file.Number(max, 0), + file.Flag("CountBandChangesPerContest", false), + file.Flag("CountBandChangesPerPeriod", false), + file.Flag("CountBandOrModeChange", false), + TimeSpan.FromMinutes(file.Number(stay, 0))) + : BandChangeRules.None; + } /// `DupeSqlString` names one more field that makes a contact distinct: a /// station worked again with a different section, exchange, mode or grid diff --git a/src/Nonemm.Session/ContestSession.cs b/src/Nonemm.Session/ContestSession.cs index 373e032..cd52c3f 100644 --- a/src/Nonemm.Session/ContestSession.cs +++ b/src/Nonemm.Session/ContestSession.cs @@ -180,6 +180,15 @@ public sealed class ContestSession return edit; } - private int NextSentNumber() => - Log.Qsos.Count == 0 ? 1 : Log.Qsos.Max(q => q.SentNumber) + 1; + /// The next serial number to send. A contest whose `ResetSNEverySession` + /// is set starts over in each session, so only the contacts of the session + /// we are in count. + private int NextSentNumber() + { + IEnumerable counted = Contest.ResetsSerialNumbersEachSession + ? Log.Qsos.Where(q => + Contest.SessionOf(q.TimestampUtc) == Contest.SessionOf(DateTime.UtcNow)) + : Log.Qsos; + return counted.Select(q => q.SentNumber).DefaultIfEmpty(0).Max() + 1; + } } diff --git a/tests/Nonemm.Contests.Tests/BandChangeRulesTests.cs b/tests/Nonemm.Contests.Tests/BandChangeRulesTests.cs new file mode 100644 index 0000000..53fb4df --- /dev/null +++ b/tests/Nonemm.Contests.Tests/BandChangeRulesTests.cs @@ -0,0 +1,64 @@ +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +/// The `…BandChange…` settings: what counts as a change, and over what stretch +/// of time the changes are counted. +public class BandChangeRulesTests +{ + private static readonly DateTime Start = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc); + + private static Qso At(int minutes, double kilohertz, Mode? mode = null) => + TestLog.Contact("OM3XYZ", kilohertz, mode: mode) with + { + TimestampUtc = Start.AddMinutes(minutes), + }; + + [Fact] + public void EachMoveToAnotherBandIsOneChange() => + Assert.Equal( + 2, + new BandChangeRules(8).CountedIn( + [At(0, 14_025), At(5, 7_040), At(10, 7_045), At(15, 14_030)], + Start.AddMinutes(20))); + + /// The count is per clock hour unless the file says otherwise, so a change + /// made in the hour before does not count against this one. + [Fact] + public void ChangesMadeInAnEarlierHourAreNotCounted() => + Assert.Equal( + 1, + new BandChangeRules(8).CountedIn( + [At(0, 14_025), At(5, 7_040), At(65, 14_025)], + Start.AddMinutes(70))); + + [Fact] + public void CountingPerContestKeepsEveryChange() => + Assert.Equal( + 2, + new BandChangeRules(8, PerContest: true).CountedIn( + [At(0, 14_025), At(5, 7_040), At(65, 14_025)], + Start.AddMinutes(70))); + + /// `CountBandChangesPerPeriod` counts the hour up to now rather than the + /// clock hour. + [Fact] + public void CountingPerPeriodLooksBackAnHour() => + Assert.Equal( + 1, + new BandChangeRules(8, PerPeriod: true).CountedIn( + [At(0, 14_025), At(5, 7_040), At(65, 14_025)], + Start.AddMinutes(70))); + + /// `CountBandOrModeChange` counts a move to another mode on the same band. + [Fact] + public void ModeChangesCountWhenTheContestSaysSo() => + Assert.Equal( + 1, + new BandChangeRules(8, CountsModeChanges: true).CountedIn( + [At(0, 14_025), At(5, 14_250, Modes.Usb)], + Start.AddMinutes(10))); + + [Fact] + public void AContestWithNoLimitCountsNothing() => Assert.False(BandChangeRules.None.IsCounted); +} diff --git a/tests/Nonemm.Contests.Tests/OperatingTimeTests.cs b/tests/Nonemm.Contests.Tests/OperatingTimeTests.cs new file mode 100644 index 0000000..6f9b08f --- /dev/null +++ b/tests/Nonemm.Contests.Tests/OperatingTimeTests.cs @@ -0,0 +1,35 @@ +using Nonemm.Core; + +namespace Nonemm.Contests.Tests; + +/// Time on and time off, as a sponsor with a rest rule asks for it. +public class OperatingTimeTests +{ + private static readonly DateTime Start = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc); + + private static Qso At(int minutes) => + TestLog.Contact("OM3XYZ") with { TimestampUtc = Start.AddMinutes(minutes) }; + + [Fact] + public void AGapAsLongAsTheMinimumIsTimeOff() => + Assert.Equal( + TimeSpan.FromMinutes(30), + OperatingTime.Off([At(0), At(10), At(40), At(50)], TimeSpan.FromMinutes(30))); + + [Fact] + public void AShorterGapIsTimeOperating() => + Assert.Equal( + TimeSpan.Zero, + OperatingTime.Off([At(0), At(20), At(40)], TimeSpan.FromMinutes(30))); + + /// The time from the first contact to the last, less the breaks. + [Fact] + public void TimeOnIsWhatIsLeftOfTheContest() => + Assert.Equal( + TimeSpan.FromMinutes(20), + OperatingTime.On([At(0), At(10), At(40), At(50)], TimeSpan.FromMinutes(30))); + + [Fact] + public void OneContactIsNoTimeAtAll() => + Assert.Equal(TimeSpan.Zero, OperatingTime.On([At(0)], TimeSpan.FromMinutes(30))); +} diff --git a/tests/Nonemm.Contests.Tests/UdcScoringTests.cs b/tests/Nonemm.Contests.Tests/UdcScoringTests.cs index 49e8546..fcc4253 100644 --- a/tests/Nonemm.Contests.Tests/UdcScoringTests.cs +++ b/tests/Nonemm.Contests.Tests/UdcScoringTests.cs @@ -292,6 +292,84 @@ public class UdcScoringTests .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]