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
375 lines
14 KiB
C#
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, ""));
|
|
}
|