The call frame. Above the callsign box, N1MM writes which spotted station the radio is sitting on, coloured like the bandmap colours it, or CQ-Frequency when this is the frequency the last CQ went out on. Space with an empty callsign box takes the call out of the frame and into the box, but only while searching: TabPressed does that when the run box is unchecked, because a running station is answering callers rather than chasing the one under its VFO. How close counts as sitting on it is N1MM's tuning tolerance, 300 Hz, now a setting. Sh/dx while unassisted. TelnetClient.Output refuses any command holding SH/DX when the entry is not assisted, and the telnet window does the same now. Whether an entry is assisted follows N1MM's own rule: assisted, multi-operator or checklog may use spots, a single operator who did not say assisted may not. The node's flavour. Click_LogonButton reads DXSpider, AR-Cluster, CC Cluster or GoCluster out of the banner; ClusterClient reads the same words and the window shows what it found. It changes nothing yet — the commands it would change are ones this does not send — but the node says so and now we know. CQ WW RTTY's state box. NextTab only stops on SectionText when the station is US or Canadian, because nobody else sends a state, so a contest can now say a box is not for this station and the walk steps over it. The box is still there to type in. The telnet button editor shows as many buttons as are defined, with an empty row to write the next one in, rather than N1MM's twelve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
475 lines
14 KiB
C#
475 lines
14 KiB
C#
using Nonemm.Contests;
|
|
using Nonemm.Contests.Rules;
|
|
using Nonemm.Core;
|
|
using Nonemm.Core.Calls;
|
|
using Nonemm.Core.Country;
|
|
using Nonemm.Storage;
|
|
|
|
namespace Nonemm.Session.Tests;
|
|
|
|
public class RadioPositionTests
|
|
{
|
|
private const string Countries = """
|
|
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
|
|
DL,DK,DJ;
|
|
Japan: 25: 45: AS: 36.40: -138.38: -9.0: JA:
|
|
JA,JH,JR;
|
|
""";
|
|
|
|
private static readonly StationInfo Me = new()
|
|
{
|
|
Callsign = "DL1ABC",
|
|
CqZone = 14,
|
|
ItuZone = 28,
|
|
Continent = "EU",
|
|
CountryPrefix = "DL",
|
|
};
|
|
|
|
private static RadioPosition Session(Contest? contest = null) =>
|
|
new(Shared(new FakeLogStore(), contest));
|
|
|
|
private static ContestSession Shared(FakeLogStore store, Contest? contest = null)
|
|
{
|
|
ContestInstance instance = store.AddContest(new ContestInstance
|
|
{
|
|
ContestNumber = 0,
|
|
ContestName = "CQWW",
|
|
});
|
|
return new ContestSession(
|
|
store,
|
|
contest ?? new CqWorldWide(ModeCategory.Cw),
|
|
instance,
|
|
Me,
|
|
CountryFile.Parse(Countries));
|
|
}
|
|
|
|
private static void Type(RadioPosition session, string call, string zone)
|
|
{
|
|
session.Entry.Call = call;
|
|
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
|
|
session.Entry.Set(ExchangeSlot.Zone, zone);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoggingAContactPutsItInTheLogAndClearsTheEntry()
|
|
{
|
|
RadioPosition session = Session();
|
|
Type(session, "JA1XYZ", "25");
|
|
Qso logged = session.LogContact();
|
|
|
|
Assert.Equal("JA1XYZ", logged.Call.Text);
|
|
Assert.Equal(25, logged.Zone);
|
|
Assert.Equal(3, logged.Points);
|
|
Assert.Equal("", session.Entry.Call);
|
|
Assert.Single(session.Log.Qsos);
|
|
}
|
|
|
|
[Fact]
|
|
public void CountryAndPrefixAreFilledInFromTheCountryFile()
|
|
{
|
|
RadioPosition session = Session();
|
|
Type(session, "JA1XYZ", "25");
|
|
Qso logged = session.LogContact();
|
|
|
|
Assert.Equal("JA", logged.CountryPrefix);
|
|
Assert.Equal("AS", logged.Continent);
|
|
Assert.Equal("JA1", logged.WpxPrefix);
|
|
}
|
|
|
|
[Fact]
|
|
public void AnIncompleteExchangeIsNotLogged()
|
|
{
|
|
RadioPosition session = Session();
|
|
session.Entry.Call = "JA1XYZ";
|
|
Assert.Throws<InvalidOperationException>(() => session.LogContact());
|
|
}
|
|
|
|
[Fact]
|
|
public void TheSameStationOnTheSameBandReadsAsADupe()
|
|
{
|
|
RadioPosition session = Session();
|
|
Type(session, "JA1XYZ", "25");
|
|
session.LogContact();
|
|
Type(session, "JA1XYZ", "25");
|
|
Assert.True(session.Verdict()?.IsDupe);
|
|
}
|
|
|
|
[Fact]
|
|
public void MovingBandClearsTheDupe()
|
|
{
|
|
RadioPosition session = Session();
|
|
Type(session, "JA1XYZ", "25");
|
|
session.LogContact();
|
|
session.Tune(Frequency.FromKilohertz(7_025));
|
|
Type(session, "JA1XYZ", "25");
|
|
Assert.False(session.Verdict()?.IsDupe);
|
|
}
|
|
|
|
[Fact]
|
|
public void SerialNumbersCountUp()
|
|
{
|
|
RadioPosition session = Session(new CqWpx(ModeCategory.Cw));
|
|
Assert.Equal(1, session.SentNumber);
|
|
session.Entry.Call = "JA1XYZ";
|
|
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
|
|
session.Entry.Set(ExchangeSlot.SerialNumber, "12");
|
|
session.LogContact();
|
|
Assert.Equal(2, session.SentNumber);
|
|
}
|
|
|
|
[Fact]
|
|
public void SpaceStepsOverTheReportAndComesBackToTheCall()
|
|
{
|
|
// CQ WW's boxes are the call, the report and the zone
|
|
RadioPosition session = Session();
|
|
|
|
Assert.Equal(0, session.Entry.Focus);
|
|
session.Entry.Advance();
|
|
Assert.Equal(2, session.Entry.Focus);
|
|
session.Entry.Advance();
|
|
Assert.Equal(0, session.Entry.Focus);
|
|
}
|
|
|
|
[Fact]
|
|
public void GoingBackStepsOverTheReportToo()
|
|
{
|
|
RadioPosition session = Session();
|
|
session.Entry.FocusOn(2);
|
|
|
|
session.Entry.Retreat();
|
|
|
|
Assert.Equal(0, session.Entry.Focus);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheReportBoxesAreFilledInWithWhatWouldBeSentAnyway()
|
|
{
|
|
RadioPosition session = Session();
|
|
|
|
Assert.True(session.FillReports());
|
|
|
|
Assert.Equal("599", session.Entry.ValueOf(ExchangeSlot.ReceivedReport));
|
|
Assert.False(session.FillReports());
|
|
}
|
|
|
|
[Fact]
|
|
public void AReportTheOperatorTypedIsLeftAlone()
|
|
{
|
|
RadioPosition session = Session();
|
|
session.Entry.Set(ExchangeSlot.ReceivedReport, "559");
|
|
|
|
Assert.False(session.FillReports());
|
|
Assert.Equal("559", session.Entry.ValueOf(ExchangeSlot.ReceivedReport));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("14025", 14_025_000)]
|
|
[InlineData("14.025", 14_025_000)]
|
|
[InlineData("7025.5", 7_025_500)]
|
|
public void AFrequencyTypedIntoTheCallBoxIsRecognised(string typed, long hertz)
|
|
{
|
|
RadioPosition session = Session();
|
|
session.Entry.Call = typed;
|
|
Assert.Equal(hertz, session.PendingQsy()?.Hertz);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("JA1XYZ")]
|
|
[InlineData("12000")]
|
|
public void ACallOrAnOutOfBandNumberIsNotAQsy(string typed)
|
|
{
|
|
RadioPosition session = Session();
|
|
session.Entry.Call = typed;
|
|
Assert.Null(session.PendingQsy());
|
|
}
|
|
|
|
[Fact]
|
|
public void AContactDeletedFromTheLogGivesItsMultiplierBack()
|
|
{
|
|
RadioPosition session = Session();
|
|
Type(session, "JA1XYZ", "25");
|
|
Qso first = session.LogContact();
|
|
Type(session, "JA2XYZ", "25");
|
|
session.LogContact();
|
|
Assert.Equal(2, session.Log.Tally.TotalMultipliers);
|
|
|
|
session.Session.Delete(first.Id);
|
|
Assert.Single(session.Log.Qsos);
|
|
Assert.Equal(2, session.Log.Tally.TotalMultipliers);
|
|
Assert.True(session.Log.Qsos.Single().IsMultiplier1);
|
|
}
|
|
|
|
[Fact]
|
|
public void AContactWorkedSplitRecordsWhereWeTransmitted()
|
|
{
|
|
RadioPosition session = Session();
|
|
session.Tune(
|
|
Frequency.FromKilohertz(14_025),
|
|
Modes.Cw,
|
|
Frequency.FromKilohertz(14_200));
|
|
Type(session, "JA1XYZ", "25");
|
|
|
|
Qso logged = session.LogContact();
|
|
|
|
Assert.Equal(14_025_000, logged.Frequency.Hertz);
|
|
Assert.Equal(14_200_000, logged.QsxFrequency.Hertz);
|
|
}
|
|
|
|
[Fact]
|
|
public void AContactRecordsWhichRadioMadeIt()
|
|
{
|
|
RadioPosition session = new(Shared(new FakeLogStore()), 2);
|
|
Type(session, "JA1XYZ", "25");
|
|
|
|
Assert.Equal(2, session.LogContact().RadioNumber);
|
|
}
|
|
|
|
/// Tuning without saying anything about split leaves it where it was, so a
|
|
/// frequency typed by hand does not clear what the radio reported.
|
|
[Fact]
|
|
public void TuningWithoutASplitLeavesTheOneAlreadySet()
|
|
{
|
|
RadioPosition session = Session();
|
|
session.Tune(Frequency.FromKilohertz(14_025), null, Frequency.FromKilohertz(14_200));
|
|
|
|
session.Tune(Frequency.FromKilohertz(14_030));
|
|
|
|
Assert.Equal(14_200_000, session.TransmitFrequency.Hertz);
|
|
}
|
|
|
|
[Fact]
|
|
public void EditingAContactRescoresTheLog()
|
|
{
|
|
RadioPosition session = Session();
|
|
Type(session, "JA1XYZ", "25");
|
|
Qso logged = session.LogContact();
|
|
Assert.Equal(3, session.Log.Tally.Points);
|
|
|
|
QsoEdit edit = session.Session.Edit(logged.Id, QsoField.Call, "DL2XYZ");
|
|
|
|
Assert.True(edit.IsAccepted);
|
|
Assert.Equal("DL2XYZ", session.Log.Qsos.Single().Call.Text);
|
|
Assert.Equal(0, session.Log.Tally.Points);
|
|
}
|
|
|
|
[Fact]
|
|
public void ARefusedEditLeavesTheLogAlone()
|
|
{
|
|
RadioPosition session = Session();
|
|
Type(session, "JA1XYZ", "25");
|
|
Qso logged = session.LogContact();
|
|
|
|
QsoEdit edit = session.Session.Edit(logged.Id, QsoField.Zone, "41");
|
|
|
|
Assert.False(edit.IsAccepted);
|
|
Assert.Equal(25, session.Log.Qsos.Single().Zone);
|
|
}
|
|
|
|
/// The other stations need the call and time the contact had before, because
|
|
/// that is how they find their own copy of it.
|
|
[Fact]
|
|
public void AnEditReportsWhatTheContactUsedToBe()
|
|
{
|
|
RadioPosition session = Session();
|
|
Type(session, "JA1XYZ", "25");
|
|
Qso logged = session.LogContact();
|
|
QsoChange? change = null;
|
|
session.Session.Edited += (_, c) => change = c;
|
|
|
|
session.Session.Edit(logged.Id, QsoField.Call, "JA2XYZ");
|
|
|
|
Assert.Equal("JA1XYZ", change?.OldCall);
|
|
Assert.Equal(logged.TimestampUtc, change?.OldTimestampUtc);
|
|
Assert.Equal("JA2XYZ", change?.Qso.Call.Text);
|
|
}
|
|
|
|
/// The country file gets a call wrong now and then. Correcting the country
|
|
/// by hand has to change the score, or the correction is decoration.
|
|
[Fact]
|
|
public void CorrectingTheCountryChangesTheScore()
|
|
{
|
|
RadioPosition session = Session();
|
|
Type(session, "JA1XYZ", "25");
|
|
Qso logged = session.LogContact();
|
|
Assert.Equal(3, session.Log.Tally.Points);
|
|
|
|
session.Session.Edit(logged.Id, QsoField.CountryPrefix, "DL");
|
|
|
|
Assert.Equal(0, session.Log.Tally.Points);
|
|
}
|
|
|
|
[Fact]
|
|
public void ADeleteReportsTheContactThatWent()
|
|
{
|
|
RadioPosition session = Session();
|
|
Type(session, "JA1XYZ", "25");
|
|
Qso logged = session.LogContact();
|
|
Qso? gone = null;
|
|
session.Session.Deleted += (_, q) => gone = q;
|
|
|
|
session.Session.Delete(logged.Id);
|
|
|
|
Assert.Equal("JA1XYZ", gone?.Call.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void StoredContactsAreReadBackWhenTheSessionStarts()
|
|
{
|
|
FakeLogStore store = new();
|
|
RadioPosition first = new(Shared(store));
|
|
Type(first, "JA1XYZ", "25");
|
|
first.LogContact();
|
|
|
|
RadioPosition second = new(new ContestSession(
|
|
store,
|
|
new CqWorldWide(ModeCategory.Cw),
|
|
first.Instance,
|
|
Me,
|
|
CountryFile.Parse(Countries)));
|
|
Assert.Single(second.Log.Qsos);
|
|
Assert.Equal(3, second.Log.Tally.Points);
|
|
}
|
|
|
|
private static RadioPosition WithHistory(Contest contest, string file)
|
|
{
|
|
FakeLogStore store = new();
|
|
ContestInstance instance = store.AddContest(new ContestInstance
|
|
{
|
|
ContestNumber = 0,
|
|
ContestName = contest.Name,
|
|
});
|
|
return new RadioPosition(new ContestSession(
|
|
store,
|
|
contest,
|
|
instance,
|
|
Me,
|
|
CountryFile.Parse(Countries),
|
|
CallHistory.Parse(file)));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheCallHistoryFillsTheExchangeIn()
|
|
{
|
|
RadioPosition session = WithHistory(
|
|
new Sweepstakes(ModeCategory.Cw),
|
|
"!!Order!!,Call,Sect,CK\nW3LPL,MDC,56");
|
|
session.Entry.Call = "W3LPL";
|
|
|
|
Assert.True(session.FillFromHistory());
|
|
Assert.Equal("MDC", session.Entry.ValueOf(ExchangeSlot.Section));
|
|
Assert.Equal("56", session.Entry.ValueOf(ExchangeSlot.Check));
|
|
}
|
|
|
|
/// What the other station actually sends beats what was published, so a box
|
|
/// with something in it is left alone.
|
|
[Fact]
|
|
public void WhatIsAlreadyTypedIsNotOverwritten()
|
|
{
|
|
RadioPosition session = WithHistory(
|
|
new Sweepstakes(ModeCategory.Cw),
|
|
"!!Order!!,Call,Sect\nW3LPL,MDC");
|
|
session.Entry.Call = "W3LPL";
|
|
session.Entry.Set(ExchangeSlot.Section, "EPA");
|
|
|
|
session.FillFromHistory();
|
|
|
|
Assert.Equal("EPA", session.Entry.ValueOf(ExchangeSlot.Section));
|
|
}
|
|
|
|
[Fact]
|
|
public void ACallTheHistoryDoesNotHoldFillsNothing()
|
|
{
|
|
RadioPosition session = WithHistory(
|
|
new Sweepstakes(ModeCategory.Cw),
|
|
"!!Order!!,Call,Sect\nW3LPL,MDC");
|
|
session.Entry.Call = "JA1XYZ";
|
|
|
|
Assert.False(session.FillFromHistory());
|
|
Assert.Equal("", session.Entry.ValueOf(ExchangeSlot.Section));
|
|
}
|
|
|
|
/// CQ WW exchanges the CQ zone, so the CQ zone column is the one to use.
|
|
[Fact]
|
|
public void TheZoneComesFromTheColumnTheContestAsksFor()
|
|
{
|
|
RadioPosition session = WithHistory(
|
|
new CqWorldWide(ModeCategory.Cw),
|
|
"!!Order!!,Call,CqZone,ITUZone\nJA1XYZ,25,45");
|
|
session.Entry.Call = "JA1XYZ";
|
|
|
|
session.FillFromHistory();
|
|
|
|
Assert.Equal("25", session.Entry.ValueOf(ExchangeSlot.Zone));
|
|
}
|
|
|
|
/// A file for a section contest often carries the state instead.
|
|
[Fact]
|
|
public void TheStateFillsASectionWhenTheFileHasNoSection()
|
|
{
|
|
RadioPosition session = WithHistory(
|
|
new Sweepstakes(ModeCategory.Cw),
|
|
"!!Order!!,Call,Sect,State\nK1TTT,,CT");
|
|
session.Entry.Call = "K1TTT";
|
|
|
|
session.FillFromHistory();
|
|
|
|
Assert.Equal("CT", session.Entry.ValueOf(ExchangeSlot.Section));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheNameAndCommentBoxesReachTheLog()
|
|
{
|
|
RadioPosition session = Session();
|
|
session.Entry.Call = "JA1XYZ";
|
|
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
|
|
session.Entry.Set(ExchangeSlot.Zone, "25");
|
|
session.OtherName = "Ken";
|
|
session.Comment = "up 2";
|
|
|
|
Qso logged = session.LogContact();
|
|
|
|
Assert.Equal("Ken", logged.Name);
|
|
Assert.Equal("up 2", logged.Comment);
|
|
Assert.Equal("", session.OtherName);
|
|
Assert.Equal("", session.Comment);
|
|
}
|
|
|
|
[Fact]
|
|
public void WipingClearsTheNameAndTheComment()
|
|
{
|
|
RadioPosition session = Session();
|
|
session.OtherName = "Ken";
|
|
session.Comment = "up 2";
|
|
|
|
session.Wipe();
|
|
|
|
Assert.Equal("", session.OtherName);
|
|
Assert.Equal("", session.Comment);
|
|
}
|
|
|
|
[Fact]
|
|
public void CqWwRttyStepsOverTheStateBoxForAStationOutsideTheUsAndCanada()
|
|
{
|
|
RadioPosition session = Session(new CqWorldWide(ModeCategory.Digital));
|
|
session.Entry.Call = "JA1XYZ";
|
|
|
|
// the boxes are the call, the report, the zone and the state
|
|
session.MoveFocus(forward: true);
|
|
Assert.Equal(2, session.Entry.Focus);
|
|
session.MoveFocus(forward: true);
|
|
Assert.Equal(0, session.Entry.Focus);
|
|
}
|
|
|
|
[Fact]
|
|
public void CqWwRttyStopsOnTheStateBoxForAUsStation()
|
|
{
|
|
RadioPosition session = Session(new CqWorldWide(ModeCategory.Digital));
|
|
session.Entry.Call = "K1ABC";
|
|
|
|
session.MoveFocus(forward: true);
|
|
Assert.Equal(2, session.Entry.Focus);
|
|
session.MoveFocus(forward: true);
|
|
Assert.Equal(3, session.Entry.Focus);
|
|
}
|
|
}
|