Files
Nonemm/tests/Nonemm.Session.Tests/QtcTrafficTests.cs
T
erikbrockoandClaude Opus 5 e0f307968e Mark a dupe in the contact type column, as N1MM does
N1MM writes a contact type on every contact it logs: `D` for a station
already worked, `N` for one the entry may not work at all, and empty for an
ordinary contact. Nothing here wrote the column, so a dupe counted as a
contact worth no points instead of dropping out of the score, and the two
places that read the column did nothing: the QTC pile offered dupes that
N1MM leaves out, and the statistics window could not leave dupes out either.

The marking is in one place, `ContestLog.ApplyVerdict`, and every contest is
marked the same way. What changes between contests is what counts as a dupe
and which stations are workable, and both come from the contest, as they do
in N1MM's `Contact.IsDupe`. No contest module in N1MM marks differently. Our
own callsign is not workable either, which is what N1MM adds on top of the
contest rule.

Two details follow N1MM as well: QTC traffic keeps an empty column, because
its `IsDupe` only marks a row while the program is logging contacts; and a
mark that came with the contact stands, because N1MM marks a contact when it
logs or edits one and never clears marks in a pass over the log. Editing a
contact clears the column so it is worked out again, which is what N1MM does
at the same point: an edit can move a contact to a band where it is no
longer a dupe.

A dupe is now out of the score altogether — contacts, points and multipliers
— which is N1MM's `ScoreClass.AddContact`. The QSO count still counts WAE
QTC rows as contacts; that is the other half of the scoring audit's item 14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
2026-09-10 19:38:57 +00:00

375 lines
14 KiB
C#

using Nonemm.Contests;
using Nonemm.Contests.Rules;
using Nonemm.Core;
using Nonemm.Core.Country;
using Nonemm.Storage;
namespace Nonemm.Session.Tests;
public class QtcTrafficTests
{
private const string Countries = """
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
DL,DK,DJ;
Slovakia: 15: 28: EU: 48.70: -19.50: -1.0: OM:
OM;
United States: 05: 08: NA: 37.53: 91.67: 5.0: K:
K,W,N,AA,NE;
Japan: 25: 45: AS: 36.40: -138.38: -9.0: JA:
JA,JH,JR;
""";
private static readonly StationInfo Slovakia = new()
{
Callsign = "OM5M",
CqZone = 15,
ItuZone = 28,
Continent = "EU",
CountryPrefix = "OM",
};
private static readonly StationInfo UnitedStates = new()
{
Callsign = "NE1C",
CqZone = 5,
ItuZone = 8,
Continent = "NA",
CountryPrefix = "K",
};
private static (OperatingPosition Position, QtcTraffic Traffic) Station(
ModeCategory mode,
StationInfo? me = null)
{
FakeLogStore store = new();
ContestInstance instance = store.AddContest(new ContestInstance
{
ContestNumber = 0,
ContestName = "WAERTTY",
});
ContestSession session = new(
store, new Wae(mode), instance, me ?? Slovakia, CountryFile.Parse(Countries));
OperatingPosition position = new(session);
return (position, new QtcTraffic(position));
}
private static Qso Contact(
OperatingPosition position,
string call,
int receivedNumber,
int sentNumber,
int minute) =>
position.Session.Add(new Qso
{
Id = Qso.NewId(),
TimestampUtc = new DateTime(2026, 11, 14, 13, minute, 0, DateTimeKind.Utc),
Call = Callsign.Parse(call),
Frequency = Frequency.FromKilohertz(14_080),
Mode = Modes.Rtty,
ContestName = "WAERTTY",
ReceivedNumber = receivedNumber,
SentNumber = sentNumber,
Exchange1 = WaeQtc.Contact,
});
/// One line of traffic with a station, as a log row of its own. What is
/// reported does not matter to the counts, only that the line is there.
private static Qso Traffic(
OperatingPosition position,
string station,
bool isSent,
int number,
int minute) =>
position.Session.Add(new WaeQtc(
Callsign.Parse(station), isSent, 1, 1, "1300", Callsign.Parse("W1XX"), number)
.ApplyTo(new Qso
{
Id = Qso.NewId(),
TimestampUtc = new DateTime(2026, 11, 14, 13, minute, 0, DateTimeKind.Utc),
Call = Callsign.Parse(station),
Frequency = Frequency.FromKilohertz(14_080),
Mode = Modes.Rtty,
ContestName = "WAERTTY",
}));
[Fact]
public void OnCwEuropeReceivesAndTheRestOfTheWorldSends()
{
(_, QtcTraffic european) = Station(ModeCategory.Cw);
(_, QtcTraffic american) = Station(ModeCategory.Cw, UnitedStates);
Assert.Equal(QtcDirection.Receive, european.DirectionFor(Callsign.Parse("NE1C"), out _));
Assert.Equal(QtcDirection.Send, american.DirectionFor(Callsign.Parse("DL9XYZ"), out _));
}
[Fact]
public void OnCwThereIsNoTrafficWithinEurope()
{
(_, QtcTraffic traffic) = Station(ModeCategory.Cw);
Assert.Equal(QtcDirection.None, traffic.DirectionFor(Callsign.Parse("DL9XYZ"), out string why));
Assert.Contains("Europe", why);
}
[Fact]
public void OnRttyTrafficGoesBothWaysBetweenContinents()
{
(_, QtcTraffic traffic) = Station(ModeCategory.Digital);
Assert.Equal(QtcDirection.Either, traffic.DirectionFor(Callsign.Parse("JA1XYZ"), out _));
}
[Fact]
public void OnRttyThereIsNoTrafficWithinOneContinent()
{
(_, QtcTraffic traffic) = Station(ModeCategory.Digital);
Assert.Equal(QtcDirection.None, traffic.DirectionFor(Callsign.Parse("DL9XYZ"), out string why));
Assert.Contains("continent", why);
}
[Fact]
public void TenLinesIsAllAnyStationGets()
{
(OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital);
for (int at = 1; at <= 10; at++)
{
position.Session.Add(new WaeQtc(
Callsign.Parse("JA1XYZ"), IsSent: true, 1, 10, "1300", Callsign.Parse("DL9XYZ"), at)
.ApplyTo(Contact(position, "K1ABC", at, at, at)));
}
Assert.Equal(0, traffic.Remaining(Callsign.Parse("JA1XYZ")));
Assert.Equal(QtcDirection.None, traffic.DirectionFor(Callsign.Parse("JA1XYZ"), out string why));
Assert.Contains("ten QTCs", why);
}
[Fact]
public void WhatIsSentIsTheContactsNotReportedYetOldestFirst()
{
(OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital);
Contact(position, "DL1AAA", receivedNumber: 16, sentNumber: 1, minute: 46);
Contact(position, "DL2BBB", receivedNumber: 40, sentNumber: 2, minute: 47);
Contact(position, "JA1XYZ", receivedNumber: 7, sentNumber: 3, minute: 48);
IReadOnlyList<WaeQtc> lines = traffic.ToSend(Callsign.Parse("JA1XYZ"));
Assert.Equal(["DL1AAA", "DL2BBB"], lines.Select(l => l.Call.Text).ToList());
Assert.Equal(["1346", "1347"], lines.Select(l => l.TimeUtc).ToList());
Assert.Equal([16, 40], lines.Select(l => l.Number).ToList());
Assert.All(lines, line => Assert.Equal("1/2", line.SeriesText));
}
[Fact]
public void AContactAlreadyReportedIsNotReportedAgain()
{
(OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital);
Contact(position, "DL1AAA", receivedNumber: 16, sentNumber: 1, minute: 46);
Contact(position, "DL2BBB", receivedNumber: 40, sentNumber: 2, minute: 47);
WaeQtc first = traffic.ToSend(Callsign.Parse("JA1XYZ"))[0];
position.Session.Add(first.ApplyTo(Contact(position, "JA1XYZ", 1, 3, 50)));
Assert.Equal(["DL2BBB"], traffic.ToSend(Callsign.Parse("JA1XYZ")).Select(l => l.Call.Text).ToList());
}
/// Series are numbered across the contest, so the next one carries on from
/// the last series sent to anybody.
[Fact]
public void SeriesAreNumberedAcrossTheContest()
{
(OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital);
Assert.Equal(1, traffic.NextSeries());
position.Session.Add(new WaeQtc(
Callsign.Parse("JA1XYZ"), IsSent: true, 86, 10, "1300", Callsign.Parse("DL9XYZ"), 5)
.ApplyTo(Contact(position, "JA1XYZ", 1, 1, 55)));
Assert.Equal(87, traffic.NextSeries());
}
/// A contact that is a dupe is not worth reporting, and it is what N1MM
/// leaves out of the pile with `Trim(ContactType) = ''`.
[Fact]
public void ADupeContactIsNotReported()
{
(OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital);
Contact(position, "DL1AAA", receivedNumber: 16, sentNumber: 1, minute: 46);
Contact(position, "DL1AAA", receivedNumber: 16, sentNumber: 2, minute: 47);
IReadOnlyList<WaeQtc> lines = traffic.ToSend(Callsign.Parse("JA1XYZ"));
Assert.Equal(["DL1AAA"], lines.Select(l => l.Call.Text).ToList());
Assert.Equal(1, traffic.Available(Callsign.Parse("JA1XYZ")));
}
/// Europe on CW and SSB only takes traffic down, so there is no pile of
/// contacts to report and the line says how many of the ten are left.
[Fact]
public void EuropeCountsWhatItHasTakenDown()
{
(OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Cw);
Contact(position, "K1ABC", receivedNumber: 5, sentNumber: 1, minute: 40);
Traffic(position, "NE1C", isSent: false, number: 7, minute: 41);
QtcCounts counts = traffic.CountsFor(Callsign.Parse("NE1C"));
Assert.Equal(QtcDirection.Receive, counts.Role);
Assert.Equal(1, counts.Exchanged);
Assert.Equal(9, counts.LeftWith);
Assert.Equal(0, counts.Available);
Assert.Equal("QTCs: 1 rcvd/9 left to exch.", counts.Text);
}
/// Outside Europe the traffic goes the other way, so what may still be
/// exchanged is capped by the contacts there are left to report.
[Fact]
public void OutsideEuropeWhatIsLeftIsCappedByWhatThereIsToReport()
{
(OperatingPosition position, QtcTraffic traffic) =
Station(ModeCategory.Cw, UnitedStates);
Contact(position, "DL1AAA", receivedNumber: 16, sentNumber: 1, minute: 46);
Contact(position, "DL2BBB", receivedNumber: 40, sentNumber: 2, minute: 47);
Contact(position, "DL9XYZ", receivedNumber: 7, sentNumber: 3, minute: 48);
QtcCounts counts = traffic.CountsFor(Callsign.Parse("DL9XYZ"));
Assert.Equal(QtcDirection.Send, counts.Role);
Assert.Equal(0, counts.Exchanged);
// the station in front of us is not reported to itself
Assert.Equal(2, counts.Available);
Assert.Equal(2, counts.LeftWith);
Assert.Equal("QTCs: 0 sent/2 left to exch/2 total QTCs avail", counts.Text);
}
/// A contact already reported to somebody is out of the pile, and a line
/// already sent to this station is off its ten.
[Fact]
public void ReportingContactsTakesThemOutOfTheCounts()
{
(OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital);
Contact(position, "DL1AAA", receivedNumber: 16, sentNumber: 1, minute: 46);
Contact(position, "DL2BBB", receivedNumber: 40, sentNumber: 2, minute: 47);
WaeQtc first = traffic.ToSend(Callsign.Parse("JA1XYZ"))[0];
position.Session.Add(first.ApplyTo(new Qso
{
Id = Qso.NewId(),
TimestampUtc = new DateTime(2026, 11, 14, 13, 50, 0, DateTimeKind.Utc),
Call = Callsign.Parse("JA1XYZ"),
Frequency = Frequency.FromKilohertz(14_080),
Mode = Modes.Rtty,
ContestName = "WAERTTY",
}));
QtcCounts counts = traffic.CountsFor(Callsign.Parse("JA1XYZ"));
Assert.Equal(QtcDirection.Either, counts.Role);
Assert.Equal(1, counts.Exchanged);
Assert.Equal(1, counts.Available);
Assert.Equal("QTCs: 1 exch/9 left to exch/1 total QTCs avail", counts.Text);
}
/// On RTTY traffic goes both ways, so what is left with a station is what
/// is free of its ten whether or not there is anything to report.
[Fact]
public void OnRttyWhatIsLeftIsNotCappedByWhatThereIsToReport()
{
(_, QtcTraffic traffic) = Station(ModeCategory.Digital);
QtcCounts counts = traffic.CountsFor(Callsign.Parse("JA1XYZ"));
Assert.Equal(0, counts.Available);
Assert.Equal(10, counts.LeftWith);
}
[Fact]
public void AStationThatHasHadItsTenIsFull()
{
(OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital);
for (int at = 1; at <= 10; at++)
{
Traffic(position, "JA1XYZ", isSent: true, number: at, minute: at);
}
QtcCounts counts = traffic.CountsFor(Callsign.Parse("JA1XYZ"));
Assert.True(counts.IsFull);
Assert.Equal(0, counts.LeftWith);
}
[Theory]
[InlineData("1346", "DL2UH", "40")]
[InlineData("0001", "2E0GYI", "1")]
public void AWellFormedLineReadsAsAQtc(string time, string call, string number) =>
Assert.NotNull(QtcTraffic.Read(
Callsign.Parse("NE1C"), isSent: false, 87, 10, time, call, number));
[Theory]
[InlineData("134", "DL2UH", "40")]
[InlineData("2401", "DL2UH", "40")]
[InlineData("1360", "DL2UH", "40")]
[InlineData("1346", "DL", "40")]
[InlineData("1346", "DL2UH", "")]
[InlineData("1346", "DL2UH", "abc")]
public void ALineThatIsNotAQtcReadsAsNothing(string time, string call, string number) =>
Assert.Null(QtcTraffic.Read(
Callsign.Parse("NE1C"), isSent: false, 87, 10, time, call, number));
/// The header is copied off the air, so whatever separates the two numbers
/// is taken as the separator.
[Theory]
[InlineData("87/10")]
[InlineData("87 10")]
[InlineData("87-10")]
[InlineData("QTC 87/10")]
public void AHeaderIsASeriesAndACount(string text)
{
(int series, int count) = Assert.IsType<(int, int)>(QtcTraffic.ReadHeader(text));
Assert.Equal(87, series);
Assert.Equal(10, count);
}
/// N1MM fills in the count when the operator types the series number
/// alone, because ten is the usual series.
[Theory]
[InlineData("87")]
[InlineData("QTC 87")]
public void ASeriesNumberOnItsOwnMeansTenLines(string text)
{
(int series, int count) = Assert.IsType<(int, int)>(QtcTraffic.ReadHeader(text));
Assert.Equal(87, series);
Assert.Equal(10, count);
}
[Theory]
[InlineData("")]
[InlineData("87/11")]
[InlineData("abc/10")]
public void AHeaderThatIsNotOneReadsAsNothing(string text) =>
Assert.Null(QtcTraffic.ReadHeader(text));
/// A time typed short takes the front off the line above, as many
/// characters as it is missing.
[Theory]
[InlineData("52", "1148", "1152")]
[InlineData("2", "1148", "1142")]
[InlineData("152", "1148", "1152")]
[InlineData("1207", "1148", "1207")]
[InlineData("12:07", "1148", "1207")]
public void ATimeTypedShortIsFilledInFromTheLineAbove(
string typed,
string above,
string filled) =>
Assert.Equal(filled, QtcTraffic.FillTime(typed, above));
/// The first line has nothing above it, so what was typed stands and is
/// judged as it is.
[Theory]
[InlineData("52", "52")]
[InlineData("", "")]
[InlineData(" 1148 ", "1148")]
public void ATimeOnTheFirstLineIsLeftAlone(string typed, string filled) =>
Assert.Equal(filled, QtcTraffic.FillTime(typed, ""));
}