WAE gives a point for every QTC line, so an operator needs to know what is still there to exchange. The entry window now carries N1MM's QTCinfo line in WAE: how many QTC lines have already gone with the station in the callsign box, how many more may go, and how many contacts are left to report to anybody. The QTC window shows the same line. The numbers come from the log rows, the way N1MM works them out: - exchanged is every QTC row with that call, both directions and all bands, and ten is the lot - available is every contact not reported to anybody yet, without the dupes and without the station itself, which is N1MM's QTCRecordsetCount - what is left with the station is what is free of its ten, and no more than the contacts there are to report when we are the one reporting Europe on CW and SSB only takes traffic down, so its line leaves the last number out, as N1MM's does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
348 lines
13 KiB
C#
348 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);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("87")]
|
|
[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, ""));
|
|
}
|