Ctrl+Z opens the QTC window for the station being worked, which is the key N1MM puts it on, and the window is laid out the way N1MM lays it out: the header, ten lines of time, callsign and serial number, an Agn and a Cfm button beside each, Clear, Close and Cancel underneath, and the same colours — green for a line that will be saved, red for one still empty, yellow for one that cannot be read. Enter and Tab move forward and space moves within a line, so a line can be walked while listening to it. Sending, the lines are filled from the log and cannot be edited: the contacts not reported to anybody yet, oldest first, never the station being worked, and never past the ten any station may have. The rule for what has been reported is N1MM's — a contact counts as reported when a QTC row carries its call and the serial we sent it — so the pair is kept in the row the way N1MM keeps it. Which way traffic goes is not the operator's choice on CW and SSB: Europe receives and the rest of the world sends. On RTTY it goes both ways, and there the window carries a switch. That is the one piece of the workflow that is ours rather than N1MM's, which toggles a QTC mode outside the window instead. Nothing here transmits. N1MM sends the lines on CW and RTTY and plays recorded voice messages on SSB; we have no QTC message templates, no voice keyer and no digital window, so Agn, Cfm and RX Ready move the cursor and the operator sends by hand. Running it caught what the tests could not: focus set in the constructor does not stick, so the first thing typed into a freshly opened window was dropped and every field after it took the value of the one before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
7.4 KiB
C#
211 lines
7.4 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 (RadioPosition 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));
|
|
RadioPosition position = new(session);
|
|
return (position, new QtcTraffic(position));
|
|
}
|
|
|
|
private static Qso Contact(
|
|
RadioPosition 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,
|
|
});
|
|
|
|
[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()
|
|
{
|
|
(RadioPosition 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()
|
|
{
|
|
(RadioPosition 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()
|
|
{
|
|
(RadioPosition 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()
|
|
{
|
|
(RadioPosition 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());
|
|
}
|
|
|
|
[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));
|
|
}
|