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 OperatingPositionTests { 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", }; /// N1MM's `{FORCELOG}`: a contact the checks refuse still goes in the log, /// because one that was never logged cannot be fixed afterwards. [Fact] public void AForcedContactIsLoggedWithTheExchangeShort() { OperatingPosition session = Session(); session.Entry.Call = "JA1XYZ"; Assert.False(session.IsEntryComplete); Assert.Equal("JA1XYZ", session.LogContactAnyway().Call.Text); Assert.Single(session.Log.Qsos); } [Fact] public void AForcedContactStillNeedsACallsign() { OperatingPosition session = Session(); Assert.Throws(() => session.LogContactAnyway()); } private static OperatingPosition 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(OperatingPosition session, string call, string zone) { session.Entry.Call = call; session.Entry.Set(ExchangeSlot.ReceivedReport, "599"); session.Entry.Set(ExchangeSlot.Zone, zone); } /// N1MM shows what this station sends beside what it copies, keeps both /// filled in and steps over them. [Fact] public void TheSentBoxesAreFilledInAndSteppedOver() { OperatingPosition session = Session(new CqWpx(ModeCategory.Cw)); Assert.Equal("599", session.Entry.ValueOf(ExchangeSlot.SentReport)); Assert.Equal("1", session.Entry.ValueOf(ExchangeSlot.SentNumber)); // the boxes are the call, the two reports, the sent serial and the // serial being copied session.Entry.Advance(); Assert.Equal(4, session.Entry.Focus); } [Fact] public void TheSentSerialGoesUpWhenAContactIsLogged() { OperatingPosition session = Session(new CqWpx(ModeCategory.Cw)); session.Entry.Call = "JA1XYZ"; session.Entry.Set(ExchangeSlot.ReceivedReport, "599"); session.Entry.Set(ExchangeSlot.SerialNumber, "12"); Assert.Equal(1, session.LogContact().SentNumber); Assert.Equal("2", session.Entry.ValueOf(ExchangeSlot.SentNumber)); } /// The BARTG contest sends the time of the exchange, and logs the time it /// sent rather than the time the contact was logged. [Fact] public void TheTimeTheExchangeWentOutIsLoggedForBartg() { OperatingPosition session = Session(new BartgSprint()); session.Entry.Call = "JA1XYZ"; session.Entry.Set(ExchangeSlot.ReceivedReport, "599"); session.Entry.Set(ExchangeSlot.SerialNumber, "12"); session.Entry.Set(ExchangeSlot.Exchange1, "1646"); session.StampSentTime(); string stamped = session.SentTime; Qso logged = session.LogContact(); Assert.Equal(4, stamped.Length); Assert.Equal(stamped, logged.MiscText); Assert.Equal("", session.SentTime); } /// A contest that does not exchange a time keeps the column empty. [Fact] public void NothingIsStampedForAContestThatSendsNoTime() { OperatingPosition session = Session(); Type(session, "JA1XYZ", "25"); session.StampSentTime(); Assert.Equal("", session.SentTime); Assert.Equal("", session.LogContact().MiscText); } [Fact] public void LoggingAContactPutsItInTheLogAndClearsTheEntry() { OperatingPosition 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() { OperatingPosition 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() { OperatingPosition session = Session(); session.Entry.Call = "JA1XYZ"; Assert.Throws(() => session.LogContact()); } [Fact] public void TheSameStationOnTheSameBandReadsAsADupe() { OperatingPosition session = Session(); Type(session, "JA1XYZ", "25"); session.LogContact(); Type(session, "JA1XYZ", "25"); Assert.True(session.Verdict()?.IsDupe); } [Fact] public void MovingBandClearsTheDupe() { OperatingPosition 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() { OperatingPosition 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 two reports and the zone OperatingPosition session = Session(); Assert.Equal(0, session.Entry.Focus); session.Entry.Advance(); Assert.Equal(3, session.Entry.Focus); session.Entry.Advance(); Assert.Equal(0, session.Entry.Focus); } [Fact] public void GoingBackStepsOverTheReportToo() { OperatingPosition session = Session(); session.Entry.FocusOn(3); session.Entry.Retreat(); Assert.Equal(0, session.Entry.Focus); } [Fact] public void TheReportBoxesAreFilledInWithWhatWouldBeSentAnyway() { OperatingPosition session = Session(); Assert.True(session.FillReports()); Assert.Equal("599", session.Entry.ValueOf(ExchangeSlot.ReceivedReport)); Assert.False(session.FillReports()); } [Fact] public void AReportTheOperatorTypedIsLeftAlone() { OperatingPosition 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) { OperatingPosition session = Session(); session.Entry.Call = typed; Assert.Equal(hertz, session.PendingQsy()?.Hertz); } [Theory] [InlineData("JA1XYZ")] [InlineData("12000")] public void ACallOrAnOutOfBandNumberIsNotAQsy(string typed) { OperatingPosition session = Session(); session.Entry.Call = typed; Assert.Null(session.PendingQsy()); } [Fact] public void AContactDeletedFromTheLogGivesItsMultiplierBack() { OperatingPosition 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() { OperatingPosition 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() { OperatingPosition 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() { OperatingPosition 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() { OperatingPosition 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() { OperatingPosition 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() { OperatingPosition 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() { OperatingPosition 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() { OperatingPosition 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(); OperatingPosition first = new(Shared(store)); Type(first, "JA1XYZ", "25"); first.LogContact(); OperatingPosition 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 OperatingPosition WithHistory(Contest contest, string file) { FakeLogStore store = new(); ContestInstance instance = store.AddContest(new ContestInstance { ContestNumber = 0, ContestName = contest.Name, }); return new OperatingPosition(new ContestSession( store, contest, instance, Me, CountryFile.Parse(Countries), CallHistory.Parse(file))); } [Fact] public void TheCallHistoryFillsTheExchangeIn() { OperatingPosition session = WithHistory( new Sweepstakes(ModeCategory.Cw), "!!Order!!,Call,Sect,CK\nW3LPL,MDC,56"); session.Entry.Call = "W3LPL"; Assert.True(session.FillFromHistory()); Assert.Equal("56MDC", session.Entry.ValueOf(ExchangeSlot.Exchange1)); } /// What the other station actually sends beats what was published, so a box /// with something in it is left alone. [Fact] public void WhatIsAlreadyTypedIsNotOverwritten() { OperatingPosition session = WithHistory( new Sweepstakes(ModeCategory.Cw), "!!Order!!,Call,Sect\nW3LPL,MDC"); session.Entry.Call = "W3LPL"; session.Entry.Set(ExchangeSlot.Exchange1, "72A 74EPA"); session.FillFromHistory(); Assert.Equal("72A 74EPA", session.Entry.ValueOf(ExchangeSlot.Exchange1)); } [Fact] public void ACallTheHistoryDoesNotHoldFillsNothing() { OperatingPosition 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.Exchange1)); } /// CQ WW exchanges the CQ zone, so the CQ zone column is the one to use. [Fact] public void TheZoneComesFromTheColumnTheContestAsksFor() { OperatingPosition 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() { OperatingPosition session = WithHistory( new Sweepstakes(ModeCategory.Cw), "!!Order!!,Call,Sect,State\nK1TTT,,CT"); session.Entry.Call = "K1TTT"; session.FillFromHistory(); Assert.Equal("00CT", session.Entry.ValueOf(ExchangeSlot.Exchange1)); } [Fact] public void TheNameAndCommentBoxesReachTheLog() { OperatingPosition 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() { OperatingPosition session = Session(); session.OtherName = "Ken"; session.Comment = "up 2"; session.Wipe(); Assert.Equal("", session.OtherName); Assert.Equal("", session.Comment); } [Fact] public void CqWwRttyStepsOverTheStateBoxForAStationOutsideTheUsAndCanada() { OperatingPosition session = Session(new CqWorldWide(ModeCategory.Digital)); session.Entry.Call = "JA1XYZ"; // the boxes are the call, the two reports, the zone and the state session.MoveFocus(forward: true); Assert.Equal(3, session.Entry.Focus); session.MoveFocus(forward: true); Assert.Equal(0, session.Entry.Focus); } [Fact] public void CqWwRttyStopsOnTheStateBoxForAUsStation() { OperatingPosition session = Session(new CqWorldWide(ModeCategory.Digital)); session.Entry.Call = "K1ABC"; session.MoveFocus(forward: true); Assert.Equal(3, session.Entry.Focus); session.MoveFocus(forward: true); Assert.Equal(4, session.Entry.Focus); } [Fact] public void QuickEditLoadsTheLastContactAndSavesTheChangeBack() { OperatingPosition session = Session(); Type(session, "JA1XYZ", "25"); Qso logged = session.LogContact(); Type(session, "DL9ZZZ", "14"); Assert.True(session.QuickEdit(forward: false)); Assert.Equal(logged.Id, session.Editing?.Id); Assert.Equal("JA1XYZ", session.Entry.Call); Assert.Equal("25", session.Entry.ValueOf(ExchangeSlot.Zone)); session.Entry.Set(ExchangeSlot.Zone, "24"); Qso? saved = session.SaveQuickEdit(); Assert.Equal(24, saved?.Zone); Assert.Equal(24, session.Log.Qsos[0].Zone); Assert.Null(session.Editing); } [Fact] public void LeavingQuickEditPutsBackWhatWasBeingTyped() { OperatingPosition session = Session(); Type(session, "JA1XYZ", "25"); session.LogContact(); Type(session, "DL9ZZZ", "14"); session.QuickEdit(forward: false); session.LeaveQuickEdit(); Assert.Null(session.Editing); Assert.Equal("DL9ZZZ", session.Entry.Call); Assert.Equal("14", session.Entry.ValueOf(ExchangeSlot.Zone)); } [Fact] public void QuickEditForwardPastTheNewestContactLeavesQuickEdit() { OperatingPosition session = Session(); Type(session, "JA1XYZ", "25"); session.LogContact(); Type(session, "DL9ZZZ", "14"); session.QuickEdit(forward: false); Assert.True(session.QuickEdit(forward: true)); Assert.Null(session.Editing); Assert.Equal("DL9ZZZ", session.Entry.Call); } [Fact] public void QuickEditStepsBackThroughTheLog() { OperatingPosition session = Session(); Type(session, "JA1XYZ", "25"); session.LogContact(); Type(session, "DL9ZZZ", "14"); session.LogContact(); session.QuickEdit(forward: false); Assert.Equal("DL9ZZZ", session.Entry.Call); session.QuickEdit(forward: false); Assert.Equal("JA1XYZ", session.Entry.Call); Assert.False(session.QuickEdit(forward: false)); } /// The entry boxes and the log window take the same values. A zone that /// does not exist is refused in both, and the box holding it is named so /// the entry window can put the cursor back in it. [Fact] public void AZoneThatDoesNotExistCannotBeLogged() { OperatingPosition session = Session(); Type(session, "JA1XYZ", "41"); EntryProblem? problem = session.EntryProblem(); Assert.Equal("CQ zones run from 1 to 40", problem!.Message); Assert.Equal("41", session.Entry[problem.Field]); Assert.Throws(session.LogContact); } [Fact] public void ArbitraryTextInTheReportBoxCannotBeLogged() { OperatingPosition session = Session(); Type(session, "JA1XYZ", "25"); session.Entry.Set(ExchangeSlot.ReceivedReport, "good copy"); Assert.Contains("signal report", session.EntryProblem()!.Message); } [Fact] public void ACallThatIsNotShapedLikeOneCannotBeLogged() { OperatingPosition session = Session(); Type(session, "12345", "25"); Assert.Equal(0, session.EntryProblem()!.Field); } [Fact] public void AnExchangeTheContestTakesGivesNoProblem() { OperatingPosition session = Session(); Type(session, "ja1xyz", "25"); Assert.Null(session.EntryProblem()); } /// Nothing is typed yet when the operator is between contacts, and that is /// not something to complain about. [Fact] public void EmptyBoxesGiveNoProblem() => Assert.Null(Session().EntryProblem()); /// N1MM's Alt+W goes both ways: it wipes the boxes, and pressed again on /// empty boxes it puts back what it wiped. [Fact] public void AltWWipesAndThenPutsTheBoxesBack() { OperatingPosition session = Session(); Type(session, "JA1XYZ", "25"); session.WipeOrRestore(); Assert.Equal("", session.Entry.Call); session.WipeOrRestore(); Assert.Equal("JA1XYZ", session.Entry.Call); Assert.Equal("25", session.Entry.ValueOf(ExchangeSlot.Zone)); } /// A wipe keeps what was in the boxes and Alt+W puts it back. [Fact] public void WhatAWipeClearedComesBack() { OperatingPosition session = Session(); Type(session, "JA1XYZ", "25"); session.Comment = "loud"; session.Wipe(); Assert.Equal("", session.Entry.Call); Assert.True(session.RestoreWiped()); Assert.Equal("JA1XYZ", session.Entry.Call); Assert.Equal("25", session.Entry.ValueOf(ExchangeSlot.Zone)); Assert.Equal("loud", session.Comment); } [Fact] public void ThereIsNothingToPutBackBeforeAWipe() => Assert.False(Session().RestoreWiped()); /// Wiping empty boxes would otherwise throw away what the wipe before it /// kept. [Fact] public void ASecondWipeKeepsTheFirstOne() { OperatingPosition session = Session(); Type(session, "JA1XYZ", "25"); session.Wipe(); session.Wipe(); Assert.True(session.RestoreWiped()); Assert.Equal("JA1XYZ", session.Entry.Call); } /// N1MM puts the boxes back only when they are empty, so typing is never /// overwritten by a restore. [Fact] public void NothingIsPutBackOverSomethingTyped() { OperatingPosition session = Session(); Type(session, "JA1XYZ", "25"); session.Wipe(); session.Entry.Call = "DL2XYZ"; Assert.False(session.RestoreWiped()); Assert.Equal("DL2XYZ", session.Entry.Call); } /// A call put on the stack has not gone anywhere, so there is nothing to /// put back and Alt+W would only make a second copy of it. [Fact] public void AStackedCallIsNotWaitingToBePutBack() { OperatingPosition session = Session(); session.IsRunning = true; Type(session, "JA1XYZ", "25"); Assert.True(session.StackAnother()); Assert.False(session.RestoreWiped()); } /// N1MM takes the serial number at the first character of the callsign, not /// at logging, so the number is on the network while the contact is still /// being worked. A second position takes the next one. [Fact] public void TypingACallTakesTheSerialNumber() { ContestSession shared = Shared(new FakeLogStore(), new CqWpx(ModeCategory.Cw)); OperatingPosition run = new(shared, 1); OperatingPosition mult = new(shared, 2); run.Entry.Call = "J"; Assert.Equal(1, run.SentNumber); Assert.Equal(2, mult.SentNumber); } /// Without the pool both positions showed the same number until one of them /// logged. The number the other station takes counts here as well. [Fact] public void ANumberAnotherStationTookIsNotSentHere() { OperatingPosition session = Session(new CqWpx(ModeCategory.Cw)); session.Session.Numbers.Note(5, Bands.Band20M); Assert.Equal(6, session.SentNumber); } /// The number is held until the contact is logged, so a wipe keeps it and /// the next contact takes a fresh one. [Fact] public void TheNumberIsHeldUntilTheContactIsLogged() { OperatingPosition session = Session(new CqWpx(ModeCategory.Cw)); session.Entry.Call = "JA1XYZ"; Assert.Equal(1, session.SentNumber); session.Wipe(); Assert.Equal(1, session.SentNumber); session.Entry.Call = "JA2XYZ"; session.Entry.Set(ExchangeSlot.ReceivedReport, "599"); session.Entry.Set(ExchangeSlot.SerialNumber, "12"); Assert.Equal(1, session.LogContact().SentNumber); session.Entry.Call = "JA3XYZ"; Assert.Equal(2, session.SentNumber); } /// A contest without serial numbers has nothing to take. [Fact] public void NothingIsTakenInAContestThatSendsNoNumbers() { OperatingPosition session = Session(); session.Entry.Call = "JA1XYZ"; Assert.Equal(1, session.Session.Numbers.Next(Bands.Band20M)); } }