Files
Nonemm/tests/Nonemm.Session.Tests/QtcTrafficTests.cs
T
erikbrockoandClaude Opus 5 f24cab0ae5 Work QTC traffic on RTTY the way N1MM does
The QTC window put nothing on the air on RTTY except the lines themselves,
so the operator typed the rest into the digital window's transmit pane and
copied the lines by hand. N1MM sends a message for each step and fills the
lines in from a click. Both are here now, with N1MM's messages, N1MM's
defaults and N1MM's settings behind them.

The messages, all of them settings in the QTC window's setup area:

- RX Ready, `WAERXReadyText`, `{ENTERLF}I AM QRV BK`
- All Agn, `WAEAllAgnText`, `{ENTERLF}ALL AGN ALL AGN BK`
- Agn n, `WAEAGNText`, `{ENTERLF}AGN QTC: $ $ $ `, where `$` is the line
- Save, `WAESaveQTCText`, `{ENTERLF}R R R ALL OK BK`
- R U QRV, `WAERUQRVText`, `{ENTERLF}R U QRV BK`
- Save, reading traffic out, `WAESSaveQTCText`, `{ENTERLF}GL DE {MYCALL}`

Each keys the transmitter, sends the text and drops it again, and an empty
one sends nothing. The line spacing default is N1MM's `{ENTERLF}` rather
than `{ENTER}`, which was wrong.

The buttons: R U QRV and Snd Hdr were hidden while reading traffic out on
RTTY, though that is where N1MM starts — R U QRV is the first button
pressed. All Agn is new, and so is the S button on each line, which strikes
a line through a QTC that arrived too broken to read so the rows after it
keep their places; Agn n clears the strike and asks for that line again.

Clicking a line in the digital window fills the next row, which is how the
series is copied on RTTY: the other station sends the header and all ten
lines in one transmission. A click anywhere in a line hands the whole line
to the QTC window, which reads the time, the callsign and the serial number
out of it by shape and flashes the line to show it was taken. A line that is
not a QTC is left to mean what a click always means there.

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

405 lines
15 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));
/// A line clicked in the digital window, read as the three fields. What
/// arrives is a printed line with whatever the receiver made of the noise
/// around it.
[Theory]
[InlineData("1435-DL1AAA-042", "1435", "DL1AAA", "042")]
[InlineData("1435 DL1AAA 42", "1435", "DL1AAA", "42")]
[InlineData(" 1435/DL1AAA/042 ", "1435", "DL1AAA", "042")]
[InlineData("1435-2E0GYI-1", "1435", "2E0GYI", "1")]
public void AClickedLineIsReadAsAQtc(string text, string time, string call, string number)
{
(string Time, string Call, string Number) line =
Assert.IsType<(string, string, string)>(QtcTraffic.SplitLine(text));
Assert.Equal(time, line.Time);
Assert.Equal(call, line.Call);
Assert.Equal(number, line.Number);
}
/// A line missing a field is not taken, and neither is an ordinary
/// exchange: the click then means what it always means in the digital
/// window.
[Theory]
[InlineData("")]
[InlineData("QTC 3/10")]
[InlineData("1435-DL1AAA")]
[InlineData("DL1AAA 599 042")]
[InlineData("CQ CQ DE DL1AAA DL1AAA K")]
public void ALineThatIsNotAQtcIsNotTaken(string text) =>
Assert.Null(QtcTraffic.SplitLine(text));
/// 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, ""));
}