Add the Info window, following N1MM's source

The window shows the operator, the call-frame spot, country, bearing and
sun lines, the WWV line and a message area. It draws both rate graphs
against the goals and shows the QSO timer (seven modes), the band timer,
the band-change count and the Run/Mult box.

Goals use N1MM's `dhh goal` file format and its Import Goals rules,
including statistics and CSV files. Logic that does not need the UI is in
Nonemm.Contests (Goals, QsoTimer, RateBars) and Nonemm.Session
(InfoLines), with tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 15:43:09 +00:00
co-authored by Claude Sonnet 5.5
parent 7a728af469
commit 012b0cdc8b
26 changed files with 2055 additions and 0 deletions
@@ -0,0 +1,274 @@
using Nonemm.Core;
namespace Nonemm.Contests.Tests;
/// The figures the info window shows: rate bars, goals and the QSO timers.
public class InfoWindowTests
{
private static readonly DateTime Now = new(2026, 5, 30, 12, 20, 0, DateTimeKind.Utc);
private static readonly DateTime Start = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan Off = TimeSpan.FromMinutes(30);
private static Qso MinutesAgo(int minutes, string computer = "A", int radio = 1) =>
TestLog.Contact("OM3XYZ") with
{
TimestampUtc = Now.AddMinutes(-minutes),
StationName = computer,
RadioNumber = radio,
};
private static QsoTimerReading Read(
IReadOnlyList<Qso> log,
QsoTimerOption option,
DateTime? now = null) =>
QsoTimer.Read(log, option, Off, Start, now ?? Now, "A");
[Fact]
public void HourlyBarsFollowTheClockAndScaleEachStretchToAnHour()
{
// 12:20 starts a 20-minute stretch: 12:20 is in the last bar, 12:19 and
// 12:05 in the one before, and 11:59 in the one before that
List<Qso> log = [MinutesAgo(0), MinutesAgo(1), MinutesAgo(15), MinutesAgo(21)];
IReadOnlyList<int> bars = RateBars.Hourly(log, Now, 20);
Assert.Equal([0, 0, 3, 6, 3], bars);
}
[Fact]
public void HourlyBarsLeaveOutContactsOlderThanTheGraph()
{
Assert.All(RateBars.Hourly([MinutesAgo(500)], Now, 20), bar => Assert.Equal(0, bar));
}
[Fact]
public void HourlyBarsLeaveOutContactsAfterNow()
{
Assert.All(RateBars.Hourly([MinutesAgo(-5)], Now, 60), bar => Assert.Equal(0, bar));
}
[Fact]
public void TheClockHourStartsAtTheTopOfTheHour()
{
List<Qso> log = [MinutesAgo(5), MinutesAgo(19), MinutesAgo(21)];
Assert.Equal(2, RateBars.ThisClockHour(log, Now));
}
[Fact]
public void PointsAreSummedOverTheLastContacts()
{
List<Qso> log = [.. Enumerable.Range(1, 5).Select(m => MinutesAgo(m) with { Points = 3 })];
Assert.Equal(9, RateBars.PointsOfLast(log, 3, Now, Off));
}
[Fact]
public void AGoalStaysInForceUntilTheNextOne()
{
Goals goals = Goals.Parse("100 60\n118 90\n");
Assert.Equal(60, goals.At(Start.AddHours(5), Start));
Assert.Equal(90, goals.At(Start.AddHours(6), Start));
}
[Fact]
public void GoalsAreFoundByContestDayAndClockHour()
{
Goals goals = Goals.Parse("112 80\n113 70\n201 20\n");
Assert.Equal(80, goals.At(Start.AddMinutes(20), Start));
Assert.Equal(70, goals.At(Start.AddHours(1), Start));
Assert.Equal(20, goals.At(Start.AddHours(13), Start));
}
[Fact]
public void BeforeTheStartTheGoalIsTheFirstHours()
{
Assert.Equal(80, Goals.Parse("112 80\n").At(Start.AddHours(-5), Start));
}
[Fact]
public void NinetySixHoursAfterTheStartTheGoalIsNothing()
{
Assert.Equal(0, Goals.Parse("112 80\n").At(Start.AddHours(96), Start));
}
[Fact]
public void WithNoGoalsTheGoalIsFifty()
{
Assert.Equal(50, Goals.Default.At(Now, Start));
Assert.Equal(50, Goals.Parse("# nothing here\n").At(Now, Start));
}
[Fact]
public void AHourOnItsOwnBelongsToTheDayBeingRead()
{
// 23 is day 1 and the 2 after it is the next day, because the key went back
Goals goals = Goals.Parse("22 10\n23 20\n2 30\n");
Assert.Equal(30, goals.At(Start.AddHours(14), Start));
}
[Fact]
public void ACsvFileHasOneCommaPerLine()
{
Goals goals = Goals.Parse("112,45\n113,55\n", isCsv: true);
Assert.Equal(55, goals.At(Start.AddHours(1), Start));
}
[Fact]
public void ATypeGoalHeaderIsSkipped()
{
Goals goals = Goals.Parse("Type=GOAL SubType=\n112 45\n");
Assert.Equal(45, goals.At(Start, Start));
}
[Fact]
public void AStatisticsFileTakesTheTotalColumn()
{
string file = "Day Hour 160 80 40 20 15 10 Total\n20260530 12 0 0 5 20 1 0 26\n20260530 13 0 0 4 30 2 0 36\n";
Goals goals = Goals.Parse(file);
Assert.Equal(36, goals.At(Start.AddHours(1), Start));
}
[Fact]
public void AStatisticsFileCanTakeOneBandsColumn()
{
string file = "Day Hour 160 80 40 20 15 10 Total\n20260530 12 0 0 5 20 1 0 26\n";
// 3 back from the total is 20 m
Assert.Equal(20, Goals.Parse(file, fromEnd: 3).At(Start, Start));
}
[Fact]
public void GoalsWrittenOutCanBeReadBack()
{
Goals goals = Goals.Parse("112 80\n201 40\n");
Assert.Equal("Type=GOAL SubType=\n112 80\n201 40\n", Goals.Parse(goals.ToText()).ToText());
}
[Fact]
public void TextWithNoGoalInItIsAnError()
{
Assert.Throws<FormatException>(() => Goals.Parse("abc\ndef\n"));
}
[Fact]
public void TheCountUpTimerCountsFromTheMinuteAfterTheLastContact()
{
QsoTimerReading reading = Read([MinutesAgo(5)], QsoTimerOption.CountUp);
// 12:15, so the count starts at 12:16 and now is 12:20
Assert.Equal("00:04", reading.Text);
Assert.Equal(TimerState.Plain, reading.State);
}
[Fact]
public void TheCountUpTimerIsMetAtTheMinimumOffTime()
{
Assert.Equal(TimerState.Met, Read([MinutesAgo(32)], QsoTimerOption.CountUp).State);
}
[Fact]
public void TheCountDownTimerShowsWhatIsLeft()
{
Qso last = TestLog.Contact("OM3XYZ") with { TimestampUtc = Now.AddMinutes(-10).AddSeconds(40) };
QsoTimerReading reading = Read([last], QsoTimerOption.CountDown);
// the contact was at 12:10:40, so the count starts at 12:11 and 9 minutes have gone
Assert.Equal("00:21", reading.Text);
}
[Fact]
public void TheCountDownTimerIsMetAtZero()
{
QsoTimerReading reading = Read([MinutesAgo(40)], QsoTimerOption.CountDown);
Assert.Equal("00:00", reading.Text);
Assert.Equal(TimerState.Met, reading.State);
}
[Fact]
public void TheCumulativeTimerCountsOnlyBreaksOfTheMinimumOrMore()
{
// breaks of 100 and 60 minutes count, and the gap of 10 minutes does not
List<Qso> log = [MinutesAgo(220), MinutesAgo(120), MinutesAgo(60), MinutesAgo(50)];
// 08:40 to 10:20 leaves 99 whole minutes and 10:20 to 11:20 leaves 59
QsoTimerReading reading = QsoTimer.Read(
log,
QsoTimerOption.Cumulative,
Off,
Now.AddHours(-6),
Now,
"A");
Assert.Equal("02:38", reading.Text);
}
[Fact]
public void TimeSinceTheLastContactIsShownInMinutesAndSeconds()
{
QsoTimerReading reading = Read([MinutesAgo(2)], QsoTimerOption.AllStations);
Assert.Equal("02:00", reading.Text);
Assert.Equal(TimerState.Warning, reading.State);
}
[Fact]
public void TimeSinceTheLastContactTurnsRedAfterThreeMinutes()
{
Assert.Equal(TimerState.Over, Read([MinutesAgo(4)], QsoTimerOption.AllStations).State);
}
[Fact]
public void AnHourSinceTheLastContactAddsTheHours()
{
Assert.Equal("01:05:00", Read([MinutesAgo(65)], QsoTimerOption.AllStations).Text);
}
[Fact]
public void ADayWithoutAContactIsSaidSo()
{
Assert.Equal("> 24hrs", Read([MinutesAgo(1500)], QsoTimerOption.AllStations).Text);
}
[Fact]
public void ThisStationLeavesOutContactsFromOtherComputers()
{
List<Qso> log = [MinutesAgo(1, computer: "B"), MinutesAgo(10)];
Assert.Equal("10:00", Read(log, QsoTimerOption.ThisStation).Text);
}
[Fact]
public void TheRightRadioLooksOnlyAtRadioTwo()
{
List<Qso> log = [MinutesAgo(1, radio: 1), MinutesAgo(7, radio: 2)];
Assert.Equal("07:00", Read(log, QsoTimerOption.RightRadio).Text);
}
[Fact]
public void ALogWithNoContactsShowsNoTime()
{
Assert.Equal("0:00", Read([], QsoTimerOption.AllStations).Text);
}
[Fact]
public void TheBoxIsLabelledForTheRadiosTheStationHas()
{
Assert.Equal("Both Radios", QsoTimer.Caption(QsoTimerOption.ThisStation, 30, isSo2r: true));
Assert.Equal("VFO B", QsoTimer.Caption(QsoTimerOption.RightRadio, 30, isSo2r: false));
Assert.Equal("Off Time v (30)", QsoTimer.Caption(QsoTimerOption.CountDown, 30, isSo2r: false));
}
}
@@ -0,0 +1,81 @@
using Nonemm.Core;
using Nonemm.Core.Country;
using Nonemm.Spotting;
namespace Nonemm.Session.Tests;
public class InfoLinesTests
{
private static readonly CountryFile Countries = CountryFile.Parse(
"""
Slovak Republic: 15: 28: EU: 48.67: -19.50: -1.0: OM:
OM;
United States: 05: 08: NA: 37.60: 91.87: 5.0: K:
K,W;
""");
[Fact]
public void TheCountryLineNamesTheContinentAndTheZoneTheContestCounts()
{
CountryLookup country = Countries.Find("OM3XYZ")!;
Assert.Equal("OM: EU/Slovak Republic, Zn 15", InfoLines.Country(country, usesItuZones: false));
Assert.Equal("OM: EU/Slovak Republic, Zn 28", InfoLines.Country(country, usesItuZones: true));
}
[Fact]
public void ABearingPutsKilometresFirstOutsideNorthAmerica()
{
StationPath path = new(38, 9156, null, null);
Assert.Equal("Hdg 38° LP 218° 9156km 5689mi", InfoLines.Bearing(path, isNorthAmerica: false));
Assert.Equal("Hdg 38° LP 218° 5689mi 9156km", InfoLines.Bearing(path, isNorthAmerica: true));
}
[Fact]
public void ASunriseWithinTheHourIsSaidSo()
{
CountryLookup country = Countries.Find("OM3XYZ")!;
(DateTime rise, _) = SunTimes.For(country.Latitude, country.Longitude, new DateTime(2026, 8, 28))!.Value;
SunLine line = InfoLines.Sun(country, rise.AddMinutes(-20));
Assert.True(line.IsSoon);
Assert.Contains("[Sunrise in 20 minutes]", line.Text);
}
[Fact]
public void ASunsetThatHasJustPassedIsSaidSo()
{
CountryLookup country = Countries.Find("OM3XYZ")!;
(_, DateTime set) = SunTimes.For(country.Latitude, country.Longitude, new DateTime(2026, 8, 28))!.Value;
SunLine line = InfoLines.Sun(country, set.AddMinutes(10));
Assert.Contains("[Sunset was 10 minutes ago]", line.Text);
}
[Fact]
public void HisTimeIsLeftOutForACountryWithManyTimeZones()
{
DateTime noon = new(2026, 8, 28, 12, 0, 0, DateTimeKind.Utc);
Assert.Contains("His time: 1300(Fri)", InfoLines.Sun(Countries.Find("OM3XYZ")!, noon).Text);
Assert.DoesNotContain("His time", InfoLines.Sun(Countries.Find("W1AW")!, noon).Text);
}
[Fact]
public void ASpotShowsHowManyMinutesAgoItWasMade()
{
DateTime now = new(2026, 8, 28, 12, 10, 30, DateTimeKind.Utc);
Spot spot = new(
Callsign.Parse("K1ABC"),
Frequency.FromKilohertz(14032.3),
now.AddMinutes(-3),
SpotSource.Cluster,
"W8WWV-#",
"CW 25 dB");
Assert.Equal("K1ABC - 14032.3 [W8WWV-# @ -3 min] - CW 25 dB", InfoLines.Spot(spot, now));
}
}