diff --git a/README.md b/README.md index 99ff2ed..7a4e01b 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ macros that are filled in: | `*`, `{MYCALL}` | your callsign | | `!`, `{CALL}` | the call being worked, or the last one logged when the box is empty | | `#` | the serial number for this contact, or the last one when the box is empty | -| `{LASTCALL}`, `{PREVNR}` | the call and the serial of the contact just logged | +| `{LASTCALL}`, `{PREVNR}`, `{LASTEXCH}` | the call, the serial and the exchange of the contact just logged | | `{EXCH}` | the sent exchange from the contest setup | | `{SENTRST}`, `{SENTRSTCUT}` | the report you send, plain or in cut numbers | | `{NAME}`, `{NAMEANDSPACE}`, `{CHNAME}` | the other operator's name, from the entry window or the call history file | @@ -164,13 +164,20 @@ macros that are filled in: | `{GRIDBEARING}`, `{REVGRIDBEARING}`, `{KMGRIDDISTANCE}` | bearing and distance between the two | | `{FREQ}`, `{FREQROUND}` | this radio's frequency in kilohertz, with `R` for the decimal point on CW | | `{OTHERFREQ}`, `{OTHERFREQROUND}`, `{OTHERMHZ}`, `{OTHERBAND}`, `{LRMHZ}`, `{RRMHZ}` | the other radio, for passing a station | -| `{TIMESTAMP}`, `{TIME2}` | the time now | +| `{TIMESTAMP}`, `{TIME2}` | the time now, as `2026-09-11 07:45` and as `0745` | +| `{TIME}`, `{DATE}`, `{TIMEGMT}`, `{DATEGMT}` | the clock and the calendar the way this machine writes them, local or UTC | +| `{ROVERQTH}` | where a rover is now, from the station data | +| `{ESC}`, `{CTRL-A}` to `{CTRL-Z}` | the control characters a TNC reads as commands | | `{WAVDIR}` | the folder the voice keyer plays from, which is named after whoever is at the microphone | The action macros run rather than standing for text: `{WIPE}`, `{LOG}`, `{RUN}`, `{S&P}`, `{SPACE}`, `{SPOTME}`, `{STOPTX}`, `{FREQUP}`, `{FREQDN}`, `{PGUP}`, `{PGDN}`, `{JUMPRX}`, `{JUMPRXTX}`, `{STACKANOTHER}`, `{SOCALLSTACK}`, -`{LOGTHENPOP}`, `{LOGTHENNEXT}`, `{CLRSTACK}`, `{CTRLF1}` to `{CTRLF12}` to send +`{LOGTHENPOP}`, `{LOGTHENNEXT}`, `{CLRSTACK}`, `{CTRLHOME}` to put the cursor +back in the callsign box, `{CLEARRIT}` to put the receive offset back to zero, +`{FORCELOG}` to log a contact the checks refuse, `{CQ}` to make a key a CQ key, +`{DIGQTCS}` and `{DIGQTCR}` to open the WAE QTC window, `{F1}` to `{F12}` to +send another of this radio's keys, `{CTRLF1}` to `{CTRLF12}` to send on the other radio, `{TELNET sh/dx}` to put a command on the cluster, and `{OTRSP TX2}` to send one to the SO2R box. An action runs before the message goes out, unless it stands after `{END}`, and then it runs once the message has @@ -182,9 +189,17 @@ How far `{FREQUP}` and the rest move the radio is `FrequencyStepHertz` and `PageStepHertz` in `settings.json`, 100 Hz and 1 kHz out of the box; N1MM asks for the same two numbers in its Configurer. -The rest of N1MM's action macros — the CAT families, audio, rotators, stereo, -the digital TNC macros, `{SETWAVDIR}` — are read and passed over, so a message -that holds one still sends the right characters. +`{CQ}` is for a station whose CQ is not on F1: the key it stands in starts +running and carries alternating CQ on, which F1 does by itself. `{FORCELOG}` +logs whatever is in the boxes, holes and all — it still needs a callsign, and +the status line says the row will need fixing before the log goes in. + +The rest of N1MM's macros — the CAT families, audio, rotators, stereo, +`{SETWAVDIR}`, `{SwapContests}`, `{VARYMSG1}` and `{VARYMSG2}`, `{COMMENT}`, +`{FORCELOGNOTE}`, `{CONDJUMP}`, `{QSYCQ}`, `{CLUSTER}` and the two needed-QSO +macros — are read and passed over, so a message that holds one still sends the right +characters. So are `{TIME1}`, `{DATE1}` and `{DAYTIME}`, which are packet BBS +date formats this has no way to check. ### The voice keyer diff --git a/docs/unfinished.md b/docs/unfinished.md index b73941f..d7740c7 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -591,10 +591,22 @@ when a download brings back something unreadable. **Not every action macro is acted on.** The text macros and the action macros that this program can carry out are listed in the README. What is read and passed over: the `{CAT…}` and radio-hex families, the audio and rotator macros, -`{STEREOON}` and `{STEREOOFF}`, `{CONDJUMP}`, -`{QSYCQ}`, `{FORCELOG}`, `{SwapContests}`, the digital TNC macros (`{ENTER}`, -`{ESC}`, `{CTRL-A}`…), and `{SETWAVDIR}`, which moves the voice keyer's folder -in the middle of a contest. +`{STEREOON}` and `{STEREOOFF}`, `{CONDJUMP}`, `{QSYCQ}`, `{FORCELOGNOTE}`, +`{COMMENT}`, `{SwapContests}`, `{VARYMSG1}` and `{VARYMSG2}`, the two +needed-QSO macros, and `{SETWAVDIR}`, which moves the voice keyer's folder in +the middle of a contest. + +`{TIME1}`, `{DATE1}` and `{DAYTIME}` are passed over as well. They are the +Nordlink-TF, WA8DED and TAPR packet date formats, and there is nothing here to +check a guess at them against; the ordinary `{TIME}`, `{DATE}`, `{TIMEGMT}`, +`{DATEGMT}`, `{TIMESTAMP}` and `{TIME2}` are all filled in. + +**Advanced voicing is not written.** N1MM says a callsign or a serial number by +playing a recording per letter and per number out of `LettersFiles` — `A.wav` +to `Z.wav`, `0.wav` to `19.wav`, the tens, `HUNDRED.wav`, `THOUSAND.wav`, +`STROKE.wav`, `QUERY.wav` and `POINT.wav`. Without it `!`, `#` and `@` in a +phone message have nothing to say, which is why F5 is empty in N1MM's own +default SSB file and in this program's. A message holding any of them still sends the right characters. The CW keyer macros are not read either: `<` and `>` for speed, `~` for a half diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 889758e..7e80105 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -715,6 +715,7 @@ public sealed record StoredStation Name = Name, Power = Power, Club = Club, + RoverQth = RoverQth, Check = Check, Precedence = Precedence, Latitude = Latitude, diff --git a/src/Nonemm.App/Windows/EntryWindow.Macros.cs b/src/Nonemm.App/Windows/EntryWindow.Macros.cs index 887a3aa..76212ad 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Macros.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Macros.cs @@ -80,6 +80,11 @@ public sealed partial class EntryWindow Refresh(); } + /// True while a key is sending another key's message, so a message that + /// names its own key — or two that name each other — stops there instead + /// of going round for ever. + private bool sendingAnotherKey; + private void SendMessage(int index) => _ = SendKeyAsync(index); /// Sends one function key's message. The task finishes when the text has @@ -359,6 +364,45 @@ public sealed partial class EntryWindow _ = session.Digital?.ReturnToReceiveAsync(); } break; + case MessageCommand.CursorToCall: + boxes[0].Focus(); + break; + case MessageCommand.SendKey: + if (!sendingAnotherKey + && int.TryParse(action.Argument, out int key) + && key is >= 1 and <= MessageFile.KeyCount) + { + sendingAnotherKey = true; + try + { + SendMessage(key - 1); + } + finally + { + sendingAnotherKey = false; + } + } + break; + case MessageCommand.CallCq: + // what F1 does by itself: a CQ puts the station in run mode and + // carries alternating CQ on to the other radio + Logging.IsRunning = true; + RememberCqFrequency(); + session.Alternating?.CarryOn(); + CqSent?.Invoke(this, EventArgs.Empty); + break; + case MessageCommand.SendQtc: + _ = OpenQtcWindow(QtcDirection.Send); + break; + case MessageCommand.ReceiveQtc: + _ = OpenQtcWindow(QtcDirection.Receive); + break; + case MessageCommand.ClearRit: + _ = session.Radio?.ClearRitAsync(); + break; + case MessageCommand.LogAnyway: + LogContactAnyway(); + break; } } diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index 59f4595..e63cbb2 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -543,6 +543,30 @@ public sealed partial class EntryWindow : Window boxes[0].Focus(); } + /// N1MM's `{FORCELOG}`: the contact goes in the log with whatever is in the + /// boxes, holes and all. The status line says it was forced, because the + /// row will need fixing before the log is sent in. + private void LogContactAnyway() + { + if (Logging is null) + { + return; + } + try + { + Qso logged = Logging.LogContactAnyway(); + Status($"logged {logged.Call} as it stands — check it before sending the log in"); + } + catch (Exception e) when (e is InvalidOperationException or IOException) + { + Status($"could not log the contact: {e.Message}"); + return; + } + ResetEsm(); + SyncBoxes(); + boxes[0].Focus(); + } + /// True when everything typed can go in the log. A value the contest will /// not take puts the reason in the status line and the cursor in its box, /// which is where the operator has to fix it. @@ -1136,7 +1160,11 @@ public sealed partial class EntryWindow : Window /// WAE QTC traffic, on Ctrl+Z as in N1MM. The contact is logged first if it /// is still in the boxes: the traffic belongs to a station that has been /// worked. - private async Task OpenQtcWindow() + /// + /// `wanted` is the way N1MM's `{DIGQTCS}` and `{DIGQTCR}` ask for, which + /// is only allowed where the contest and the station leave it open. Ctrl+Z + /// passes null and the rules decide. + private async Task OpenQtcWindow(QtcDirection? wanted = null) { if (Logging is null) { @@ -1165,6 +1193,15 @@ public sealed partial class EntryWindow : Window Status(why); return; } + if (wanted is { } asked) + { + if (direction is not QtcDirection.Either && direction != asked) + { + Status($"traffic with {station.Text} can only go the other way"); + return; + } + direction = asked; + } if (direction is QtcDirection.Send && traffic.ToSend(station).Count == 0) { Status("nothing left to report to " + station.Text); diff --git a/src/Nonemm.Core/StationInfo.cs b/src/Nonemm.Core/StationInfo.cs index 1bf245e..6ed3a2d 100644 --- a/src/Nonemm.Core/StationInfo.cs +++ b/src/Nonemm.Core/StationInfo.cs @@ -31,6 +31,9 @@ public sealed record StationInfo public string Club { get; init; } = ""; + /// Where a rover is at the moment, which is what `{ROVERQTH}` sends. + public string RoverQth { get; init; } = ""; + /// The last two digits of the year first licensed, which Sweepstakes calls /// the check. public int Check { get; init; } diff --git a/src/Nonemm.Rig/Radio.cs b/src/Nonemm.Rig/Radio.cs index 28a3398..5094904 100644 --- a/src/Nonemm.Rig/Radio.cs +++ b/src/Nonemm.Rig/Radio.cs @@ -27,4 +27,8 @@ public interface Radio : IDisposable /// Null turns split off. Not every radio can do this; one that cannot says /// so and the logger carries on. Task SetSplitAsync(Frequency? transmitFrequency, CancellationToken cancellation = default); + + /// Puts the receive offset back to zero, which is what an operator does + /// after working a station he had to tune off frequency for. + Task ClearRitAsync(CancellationToken cancellation = default); } diff --git a/src/Nonemm.Rig/RigctldRadio.cs b/src/Nonemm.Rig/RigctldRadio.cs index 6b442f7..adf40d5 100644 --- a/src/Nonemm.Rig/RigctldRadio.cs +++ b/src/Nonemm.Rig/RigctldRadio.cs @@ -67,6 +67,11 @@ public sealed class RigctldRadio : Radio await SendAsync("S 1 VFOB", cancellation).ConfigureAwait(false); } + /// `J` is rigctld's set_rit, in hertz. Zero is the offset turned off, + /// which is what the radio's own RIT clear button does. + public async Task ClearRitAsync(CancellationToken cancellation = default) => + await SendAsync("J 0", cancellation).ConfigureAwait(false); + public void Dispose() { stopping.Cancel(); diff --git a/src/Nonemm.Session/ExchangeSlots.cs b/src/Nonemm.Session/ExchangeSlots.cs index 2f4a517..593b118 100644 --- a/src/Nonemm.Session/ExchangeSlots.cs +++ b/src/Nonemm.Session/ExchangeSlots.cs @@ -29,6 +29,28 @@ public static class ExchangeSlots _ => qso, }; + /// What a logged contact holds in one exchange box, which is the other way + /// round from `Apply`. + public static string ValueOf(Qso qso, ExchangeSlot slot) => slot switch + { + ExchangeSlot.SentReport => qso.SentReport, + ExchangeSlot.SentNumber => Digits(qso.SentNumber), + ExchangeSlot.ReceivedReport => qso.ReceivedReport, + ExchangeSlot.SerialNumber => Digits(qso.ReceivedNumber), + ExchangeSlot.Zone => Digits(qso.Zone), + ExchangeSlot.Section => qso.Section, + ExchangeSlot.Check => Digits(qso.Check), + ExchangeSlot.Precedence => qso.Precedence, + ExchangeSlot.Exchange1 => qso.Exchange1, + ExchangeSlot.MiscText => qso.MiscText, + ExchangeSlot.Name => qso.Name, + ExchangeSlot.Qth => qso.Qth, + ExchangeSlot.GridSquare => qso.GridSquare, + ExchangeSlot.Power => qso.Power, + ExchangeSlot.Comment => qso.Comment, + _ => "", + }; + /// What the call history file says this station sends in one exchange box, /// or an empty string when the file holds nothing for it. public static string ValueFrom(CallHistoryEntry known, ExchangeField field) => diff --git a/src/Nonemm.Session/MessageCommand.cs b/src/Nonemm.Session/MessageCommand.cs index e365f6c..aea8a8a 100644 --- a/src/Nonemm.Session/MessageCommand.cs +++ b/src/Nonemm.Session/MessageCommand.cs @@ -73,4 +73,28 @@ public enum MessageCommand /// N1MM's `{RX}`: the engine goes back to receive once the buffer is /// empty. ReturnToReceive, + + /// Puts the cursor back in the callsign box, wherever it was. N1MM's + /// `{CTRLHOME}`. + CursorToCall, + + /// Sends one of this radio's own function key messages. The argument is + /// the key number, so `{F9}` sends F9. + SendKey, + + /// Says this key is a CQ, for a station whose CQ is not on F1: it starts + /// running and carries alternating CQ on, which F1 does by itself. + CallCq, + + /// Opens the WAE QTC window to read traffic out, or to take it down. + SendQtc, + + ReceiveQtc, + + /// Puts the radio's receive offset back to zero. + ClearRit, + + /// Logs the contact even though the exchange is short or a box holds + /// something the contest will not take. N1MM's `{FORCELOG}`. + LogAnyway, } diff --git a/src/Nonemm.Session/MessageExpander.cs b/src/Nonemm.Session/MessageExpander.cs index 4830a96..215f349 100644 --- a/src/Nonemm.Session/MessageExpander.cs +++ b/src/Nonemm.Session/MessageExpander.cs @@ -89,6 +89,10 @@ public static class MessageExpander ? last.SentNumber.ToString("000", CultureInfo.InvariantCulture) : "", "EXCH" => session.Instance.SentExchange, + // what the last station sent, put together out of the boxes this + // contest asks for, in the order it asks for them + "LASTEXCH" => LastExchange(session), + "ROVERQTH" => session.Me.RoverQth, "SENTRST" => session.DefaultReport(), "SENTRSTCUT" => Cut(session.DefaultReport()), "GRID" => session.Me.GridSquare, @@ -111,15 +115,46 @@ public static class MessageExpander "RRMHZ" => Megahertz(session, Radio(session, other, 2)), "TIMESTAMP" => DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture), "TIME2" => DateTime.UtcNow.ToString("HHmm", CultureInfo.InvariantCulture), + // the local clock, in the shapes this machine writes them. N1MM + // calls these the Windows formats, which is the same thing there + "TIME" => DateTime.Now.ToString("t", CultureInfo.CurrentCulture), + "DATE" => DateTime.Now.ToString("d", CultureInfo.CurrentCulture), + "TIMEGMT" => DateTime.UtcNow.ToString("t", CultureInfo.CurrentCulture), + "DATEGMT" => DateTime.UtcNow.ToString("g", CultureInfo.CurrentCulture), // the digital window's carriage return, which starts a new line on // the other station's screen "ENTER" => "\r", // the same with a line feed behind it, which is what N1MM's own // RTTY message defaults are written with "ENTERLF" => "\r\n", - _ => null, + "ESC" => "\u001b", + _ => Control(name.ToUpperInvariant()), }; + /// N1MM's `{CTRL-A}` to `{CTRL-Z}`, which are the control characters a TNC + /// reads as commands. They are characters, so they are text here. + private static string? Control(string name) => + name.Length == 6 && name.StartsWith("CTRL-", StringComparison.Ordinal) + && name[5] is >= 'A' and <= 'Z' + ? ((char)(name[5] - 'A' + 1)).ToString() + : null; + + /// The exchange of the last contact logged, which is every received box + /// the contest asks for with a space between them. N1MM's `{LASTEXCH}`. + private static string LastExchange(OperatingPosition session) + { + if (LastLogged(session) is not { } last) + { + return ""; + } + IEnumerable values = session.Contest + .ExchangeFieldsFor(session.Me) + .Where(field => field.Slot is not (ExchangeSlot.SentReport or ExchangeSlot.SentNumber)) + .Select(field => ExchangeSlots.ValueOf(last, field.Slot).Trim()) + .Where(value => value.Length > 0); + return string.Join(' ', values); + } + /// The call being worked, or the last one logged when the box is empty, /// which is what N1MM sends for both `{CALL}` and `!`. private static string TheirCall(OperatingPosition session) => diff --git a/src/Nonemm.Session/MessagePlan.cs b/src/Nonemm.Session/MessagePlan.cs index beba5ce..b402b8d 100644 --- a/src/Nonemm.Session/MessagePlan.cs +++ b/src/Nonemm.Session/MessagePlan.cs @@ -90,6 +90,17 @@ public sealed record MessagePlan( MessageCommand.SendOnOtherRadio, key.ToString(CultureInfo.InvariantCulture)); } + // `{F9}` sends this radio's F9, where `{CTRLF9}` sends the other + // radio's, so the check for this one comes after that one + if (upper.Length is 2 or 3 + && upper[0] == 'F' + && int.TryParse(upper[1..], NumberStyles.None, CultureInfo.InvariantCulture, out int own) + && own is >= 1 and <= 12) + { + return new MessageAction( + MessageCommand.SendKey, + own.ToString(CultureInfo.InvariantCulture)); + } return upper switch { "WIPE" => new MessageAction(MessageCommand.Wipe), @@ -113,6 +124,12 @@ public sealed record MessagePlan( "CLRSTACK" => new MessageAction(MessageCommand.ClearStack), "TX" => new MessageAction(MessageCommand.StartTransmit), "RX" => new MessageAction(MessageCommand.ReturnToReceive), + "CTRLHOME" => new MessageAction(MessageCommand.CursorToCall), + "CQ" => new MessageAction(MessageCommand.CallCq), + "DIGQTCS" => new MessageAction(MessageCommand.SendQtc), + "DIGQTCR" => new MessageAction(MessageCommand.ReceiveQtc), + "CLEARRIT" => new MessageAction(MessageCommand.ClearRit), + "FORCELOG" => new MessageAction(MessageCommand.LogAnyway), _ => null, }; } diff --git a/src/Nonemm.Session/OperatingPosition.cs b/src/Nonemm.Session/OperatingPosition.cs index 32f3233..8239a63 100644 --- a/src/Nonemm.Session/OperatingPosition.cs +++ b/src/Nonemm.Session/OperatingPosition.cs @@ -261,6 +261,25 @@ public sealed class OperatingPosition { throw new InvalidOperationException(problem.Message); } + return Store(); + } + + /// Logs what is typed although the exchange is short or a box holds + /// something the contest will not take, which is N1MM's `{FORCELOG}`. A + /// contact in the log with a hole in it can be filled in afterwards; one + /// that was never logged cannot. The callsign is still required, because a + /// row with no callsign in it is not a contact. + public Qso LogContactAnyway() + { + if (Entry.Call.Trim().Length == 0) + { + throw new InvalidOperationException("there is no callsign to log"); + } + return Store(); + } + + private Qso Store() + { StampSentTime(); Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim()))); // the number went with the contact, so the next one starts again from diff --git a/tests/Nonemm.Session.Tests/MessageExpanderTests.cs b/tests/Nonemm.Session.Tests/MessageExpanderTests.cs index 0e8f456..ad070ba 100644 --- a/tests/Nonemm.Session.Tests/MessageExpanderTests.cs +++ b/tests/Nonemm.Session.Tests/MessageExpanderTests.cs @@ -8,7 +8,10 @@ namespace Nonemm.Session.Tests; public class MessageExpanderTests { - private static OperatingPosition Session(CallHistory? history = null, int radioNumber = 1) + private static OperatingPosition Session( + CallHistory? history = null, + int radioNumber = 1, + string roverQth = "") { FakeLogStore store = new(); ContestInstance instance = store.AddContest(new ContestInstance @@ -22,7 +25,14 @@ public class MessageExpanderTests store, new CqWorldWide(ModeCategory.Cw), instance, - new StationInfo { Callsign = "DL1ABC", CqZone = 14, Name = "Erik", GridSquare = "JN88" }, + new StationInfo + { + Callsign = "DL1ABC", + CqZone = 14, + Name = "Erik", + GridSquare = "JN88", + RoverQth = roverQth, + }, null, history), radioNumber); @@ -77,6 +87,39 @@ public class MessageExpanderTests Assert.Equal("JA1XYZ JA1XYZ", MessageExpander.Expand("{CALL} {LASTCALL}", session)); } + /// N1MM's `{LASTEXCH}`: what the last station sent, which is every + /// received box the contest asks for. CQ WW asks for the report and the + /// zone. + [Fact] + public void TheLastExchangeIsWhatTheLastStationSent() + { + OperatingPosition session = Session(); + session.Entry.Call = "JA1XYZ"; + session.Entry.Set(ExchangeSlot.ReceivedReport, "599"); + session.Entry.Set(ExchangeSlot.Zone, "25"); + session.LogContact(); + + Assert.Equal("599 25", MessageExpander.Expand("{LASTEXCH}", session)); + } + + [Fact] + public void TheLastExchangeIsEmptyBeforeAnythingIsLogged() => + Assert.Equal("", MessageExpander.Expand("{LASTEXCH}", Session())); + + /// The control characters a TNC reads as commands. `{CTRL-A}` is 0x01 and + /// `{ESC}` is 0x1B. + [Fact] + public void TheTncControlCharactersAreSentAsThemselves() => + Assert.Equal("\u0001\u001a\u001b", MessageExpander.Expand("{CTRL-A}{CTRL-Z}{ESC}", Session())); + + [Fact] + public void ARoverSendsWhereItIs() + { + OperatingPosition session = Session(roverQth: "JN99"); + + Assert.Equal("QTH JN99", MessageExpander.Expand("QTH {ROVERQTH}", session)); + } + [Fact] public void TheFrequencySendsRForTheDecimalPointOnCw() { diff --git a/tests/Nonemm.Session.Tests/MessagePlanTests.cs b/tests/Nonemm.Session.Tests/MessagePlanTests.cs index 67b0d67..ebf4b3b 100644 --- a/tests/Nonemm.Session.Tests/MessagePlanTests.cs +++ b/tests/Nonemm.Session.Tests/MessagePlanTests.cs @@ -24,6 +24,48 @@ public class MessagePlanTests null)); } + /// `{CTRLF9}` sends the other radio's F9 and `{F9}` sends this radio's, so + /// the two are told apart by the prefix alone. + [Fact] + public void AFunctionKeyMacroSendsThatKeyOnThisRadio() + { + MessagePlan plan = MessagePlan.Read("{F9}", Session()); + + MessageAction action = Assert.Single(plan.Before); + Assert.Equal(MessageCommand.SendKey, action.Command); + Assert.Equal("9", action.Argument); + } + + [Fact] + public void TheOtherRadiosKeyIsStillTheCtrlOne() + { + MessagePlan plan = MessagePlan.Read("{CTRLF9}", Session()); + + Assert.Equal(MessageCommand.SendOnOtherRadio, Assert.Single(plan.Before).Command); + } + + [Fact] + public void AKeyPastTheTwelfthIsNotAFunctionKeyMacro() + { + MessagePlan plan = MessagePlan.Read("{F13}", Session()); + + Assert.Empty(plan.Before); + Assert.Equal("", plan.Text); + } + + [Theory] + [InlineData("{CTRLHOME}", MessageCommand.CursorToCall)] + [InlineData("{CQ}", MessageCommand.CallCq)] + [InlineData("{DIGQTCS}", MessageCommand.SendQtc)] + [InlineData("{DIGQTCR}", MessageCommand.ReceiveQtc)] + public void TheMacrosThatRunSomething(string macro, MessageCommand command) + { + MessagePlan plan = MessagePlan.Read(macro, Session()); + + Assert.Equal(command, Assert.Single(plan.Before).Command); + Assert.Equal("", plan.Text); + } + [Fact] public void AMessageWithNoMacrosIsJustText() { diff --git a/tests/Nonemm.Session.Tests/OperatingPositionTests.cs b/tests/Nonemm.Session.Tests/OperatingPositionTests.cs index 3d104f9..6ffae66 100644 --- a/tests/Nonemm.Session.Tests/OperatingPositionTests.cs +++ b/tests/Nonemm.Session.Tests/OperatingPositionTests.cs @@ -25,6 +25,27 @@ public class OperatingPositionTests 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));