Files
Nonemm/tests/Nonemm.Session.Tests/QtcTrafficTests.cs
T
erikbrockoandClaude Opus 5 9b3e10d127 Take a QTC header that is only a series number as ten lines
An operator copying a header off the air often types the series number and
nothing else. That read as a header the program could not parse, so the box
went yellow and Close refused to log the series.

N1MM fills the count in: "If he only types the batch number without the
forward slash and count, the program will assume the count is 10."

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

360 lines
13 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());
}
/// 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, ""));
}