From 333d3b97c20acd7244917fc63c62e049f853a177 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 28 Aug 2026 07:44:59 +0000 Subject: [PATCH] Run the action macros in a function key message A function key message was read as text and nothing else, so a message file written for N1MM lost half of what it said: {WIPE}, {LOG}, {RUN} and the rest expanded to nothing and the key only sent characters. Reading a message now produces a plan rather than a string. MessagePlan.Read walks the template once and sorts what it finds into three parts: the actions that run before anything goes out, the text that goes on the air, and the actions that wait until it has gone. N1MM's {END} decides which side an action falls on, and text after {END} is dropped, because the message is over by then. So TU {LOG}{END}{WIPE} logs the contact, sends TU, and clears the boxes when the key has finished sending. The actions that are carried out: {WIPE}, {LOG}, {RUN}, {S&P}, {SPACE}, {SPOTME}, {STOPTX}, {FREQUP}, {FREQDN}, {PGUP}, {PGDN}, {JUMPRX}, {JUMPRXTX}, {CTRLF1} to {CTRLF12} for sending on the other radio, {TELNET sh/dx} for a command to the cluster node, and {OTRSP TX2} for one to the SO2R box. The last one wanted a way to send a command straight to the box, so So2rBox grew one. The frequency steps are two settings, with N1MM's own two numbers behind them. Waiting on the keyer is the part worth reading twice. What follows {END} runs when the keyer says the message has gone out, which is the only honest moment: timing it from the length of the text is a guess that goes wrong exactly when the contest is busy. A keyer that does not report completion is taken at its word as soon as the text is handed over, and one that goes quiet is given two minutes before the actions run anyway, because never running them is worse. Running it against a fake cwdaemon and a fake node showed the message text on the keyer, the telnet command at the node, RUN lighting up before the message and the boxes clearing after it. That is also what showed the entry window was not redrawing between the actions and the message. What is still read and passed over is written down: the CAT, audio, rotator, stereo and call-stacking families, the digital TNC macros, and the CW keyer characters < > ~ ] [ + = , which belong to the keyer rather than to the expander. {EXCHSENT} waits on ESM, which is next. Co-Authored-By: Claude Opus 5 --- README.md | 20 +- docs/unfinished.md | 29 +- src/Nonemm.App/Configuration/Settings.cs | 7 + src/Nonemm.App/Windows/EntryWindow.Macros.cs | 250 ++++++++++++++++++ src/Nonemm.App/Windows/EntryWindow.axaml.cs | 45 +--- src/Nonemm.Rig/OtrspBox.cs | 3 + src/Nonemm.Rig/So2rBox.cs | 4 + src/Nonemm.Session/MessageAction.cs | 6 + src/Nonemm.Session/MessageCommand.cs | 50 ++++ src/Nonemm.Session/MessageExpander.cs | 13 +- src/Nonemm.Session/MessagePlan.cs | 120 +++++++++ .../Nonemm.Session.Tests/MessagePlanTests.cs | 94 +++++++ 12 files changed, 582 insertions(+), 59 deletions(-) create mode 100644 src/Nonemm.App/Windows/EntryWindow.Macros.cs create mode 100644 src/Nonemm.Session/MessageAction.cs create mode 100644 src/Nonemm.Session/MessageCommand.cs create mode 100644 src/Nonemm.Session/MessagePlan.cs create mode 100644 tests/Nonemm.Session.Tests/MessagePlanTests.cs diff --git a/README.md b/README.md index e8da5cc..c45e0b0 100644 --- a/README.md +++ b/README.md @@ -113,9 +113,23 @@ macros that are filled in: | `{OTHERFREQ}`, `{OTHERFREQROUND}`, `{OTHERMHZ}`, `{OTHERBAND}`, `{LRMHZ}`, `{RRMHZ}` | the other radio, for passing a station | | `{TIMESTAMP}`, `{TIME2}` | the time now | -N1MM's action macros — `{WIPE}`, `{LOG}`, `{RUN}`, the CAT and SO2R families — -are not run; they stand for nothing, so a message that holds one still sends the -right characters. +The action macros run rather than standing for text: `{WIPE}`, `{LOG}`, +`{RUN}`, `{S&P}`, `{SPACE}`, `{SPOTME}`, `{STOPTX}`, `{FREQUP}`, `{FREQDN}`, +`{PGUP}`, `{PGDN}`, `{JUMPRX}`, `{JUMPRXTX}`, `{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 +been sent — so `TU {LOG}{END}{WIPE}` logs the contact, sends TU, and clears the +boxes when the key has finished. Text after `{END}` is dropped, as it is in +N1MM. + +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, +call stacking, the digital TNC macros — are read and passed over, so a message +that holds one still sends the right characters. ### Editing the log diff --git a/docs/unfinished.md b/docs/unfinished.md index f875d95..54b8e08 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -72,24 +72,19 @@ N1MM's to run. A page that changes shape would stop the download working; the reading only needs `telnet://` links in a table, and the old list stays in place when a download brings back something unreadable. -**The action macros do nothing.** The text macros are N1MM's, spelled as its -function-key documentation spells them, and they are listed in the README. What -is missing is the other half of N1MM's table: the macros that run a program -command rather than standing for text — `{WIPE}`, `{LOG}`, `{ENTER}`, `{RUN}`, -`{S&P}`, `{SPOTME}`, `{TOGGLE}`, `{END}`, the `{CAT…}` and `{OTRSP…}` families, -the audio, rotator and call-stacking macros. They expand to nothing, so a -message that holds one sends the right characters and takes no action. +**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}`, the call-stacking macros, `{CONDJUMP}`, +`{QSYCQ}`, `{FORCELOG}`, `{SwapContests}`, the digital TNC macros (`{ENTER}`, +`{ESC}`, `{CTRL-A}`…), and the wav-directory macros, which wait on voice keying. +`{EXCHSENT}` waits on ESM, which does not exist yet. A message holding any of +them still sends the right characters. -They cannot be added to `MessageExpander` as it stands: it turns a template into -a string, and an action macro has to reach the entry window, the keyer or the -SO2R box, some of it after the message has been sent (that is what N1MM's -`{END}` is for). That wants a different shape — a list of steps rather than a -string. - -Two more text macros are out for want of the data behind them: `{LASTEXCH}`, -which N1MM only fills for ROPOCO and LZ Open, and `{OPERATOR}`, because there is -no operator separate from the station callsign here. `@`, which voices the -receive frequency from recorded letter files, waits on voice keying. +The CW keyer macros are not read either: `<` and `>` for speed, `~` for a half +space, and the prosign characters `]`, `[`, `+` and `=`. They belong to the +keyer rather than to the expander — cwdaemon and a WinKeyer each have their own +way of saying them — and they go out as the characters they are. **The busted-spot check is one character wide.** N1MM offers two. Every call one character away from the spotted one is looked up in the callsign database, which diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 7e8ec83..aef872b 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -50,6 +50,13 @@ public sealed record Settings /// to find the station by ear. N1MM calls it randomising. public bool RandomizeSpots { get; init; } + /// How far `{FREQUP}` and `{FREQDN}` move the radio. N1MM asks for the same + /// number in its Configurer. + public int FrequencyStepHertz { get; init; } = 100; + + /// How far `{PGUP}` and `{PGDN}` move the radio. + public int PageStepHertz { get; init; } = 1_000; + /// Sent with a spot the operator puts on the cluster with Alt+P. public string SpotComment { get; init; } = ""; diff --git a/src/Nonemm.App/Windows/EntryWindow.Macros.cs b/src/Nonemm.App/Windows/EntryWindow.Macros.cs new file mode 100644 index 0000000..e304b84 --- /dev/null +++ b/src/Nonemm.App/Windows/EntryWindow.Macros.cs @@ -0,0 +1,250 @@ +using Avalonia.Threading; +using Nonemm.Core; +using Nonemm.Keying; +using Nonemm.Session; +using Nonemm.Spotting; + +namespace Nonemm.App.Windows; + +/// Running a function key message: the action macros in it, the text that goes +/// on the air, and the actions N1MM's `{END}` holds back until the message has +/// gone out. +public sealed partial class EntryWindow +{ + /// A keyer that says nothing after this long is taken as finished, so the + /// actions after `{END}` still run. + private static readonly TimeSpan SendingPatience = TimeSpan.FromMinutes(2); + + private void SendMessage(int index) + { + if (Logging is null) + { + return; + } + string template = Messages.For( + Logging.Mode.Category, + session.Settings.CwMessages, + session.Settings.PhoneMessages)[index]; + if (template.Length == 0) + { + return; + } + MessagePlan plan = MessagePlan.Read(template, Logging, session.Other(Logging)); + foreach (MessageAction action in plan.Before) + { + Run(action); + } + Refresh(); + if (plan.Text.Length == 0) + { + // a key that only acts: {WIPE}, {RUN}, {TELNET sh/dx} + return; + } + if (session.Keyer is null) + { + Status("no keyer — Config ▸ Keyer"); + return; + } + // the box has to point at this radio before the key does + _ = session.PointTransmitAtAsync(radioNumber); + Status($"sending {plan.Text}"); + _ = SendThenAsync(session.Keyer, plan.Text, plan.After); + } + + private async Task SendThenAsync(MessageSender keyer, string text, IReadOnlyList after) + { + try + { + await keyer.SendAsync(text); + } + catch (InvalidOperationException e) + { + Status(e.Message); + return; + } + if (after.Count == 0) + { + return; + } + await WhenSentAsync(keyer); + Dispatcher.UIThread.Post(() => + { + foreach (MessageAction action in after) + { + Run(action); + } + Refresh(); + }); + } + + /// Waits for the keyer to say the message has gone out. A keyer that does + /// not report completion is taken at its word as soon as the text is sent, + /// because the alternative is never running what follows `{END}`. + private static async Task WhenSentAsync(MessageSender keyer) + { + if (!keyer.ReportsCompletion) + { + return; + } + TaskCompletionSource sent = new(); + void Finished(object? sender, EventArgs e) => sent.TrySetResult(); + keyer.Finished += Finished; + try + { + await sent.Task.WaitAsync(SendingPatience); + } + catch (TimeoutException) + { + } + finally + { + keyer.Finished -= Finished; + } + } + + private void Run(MessageAction action) + { + if (Logging is null) + { + return; + } + switch (action.Command) + { + case MessageCommand.Wipe: + Logging.Wipe(); + SyncBoxes(); + boxes[0].Focus(); + break; + case MessageCommand.Log: + LogContact(); + break; + case MessageCommand.Run: + Logging.IsRunning = true; + break; + case MessageCommand.SearchAndPounce: + Logging.IsRunning = false; + break; + case MessageCommand.Space: + MoveFocus(forward: true); + break; + case MessageCommand.SpotMe: + SpotMe(); + break; + // the manual's table has the two page macros the other way round; + // the keys they are named after move the frequency this way + case MessageCommand.FrequencyUp: + Step(session.Settings.FrequencyStepHertz); + break; + case MessageCommand.FrequencyDown: + Step(-session.Settings.FrequencyStepHertz); + break; + case MessageCommand.PageUp: + Step(session.Settings.PageStepHertz); + break; + case MessageCommand.PageDown: + Step(-session.Settings.PageStepHertz); + break; + case MessageCommand.StopSending: + _ = session.Keyer?.AbortAsync(); + break; + case MessageCommand.Telnet: + SendToCluster(action.Argument); + break; + case MessageCommand.JumpToOtherRadio: + session.SwapRadio(); + break; + case MessageCommand.JumpToOtherRadioWithTransmit: + session.SwapRadio(); + _ = session.PointTransmitAtAsync(session.ActiveRadioNumber); + break; + case MessageCommand.SendOnOtherRadio: + SendOnOtherRadio(action.Argument); + break; + case MessageCommand.Otrsp: + _ = session.Box?.SendCommandAsync(action.Argument); + break; + } + } + + private void Step(int hertz) + { + if (Logging is null) + { + return; + } + Frequency moved = Frequency.FromHertz(Logging.Frequency.Hertz + hertz); + Logging.Tune(moved); + _ = session.Radio?.TuneAsync(moved); + Refresh(); + } + + /// N1MM only self-spots while running, and so does this: a station that is + /// not calling CQ has nothing to tell the cluster. + private void SpotMe() + { + if (Logging is null || session.Cluster is not { IsConnected: true } cluster) + { + Status("not connected to a cluster node"); + return; + } + if (!Logging.IsRunning) + { + Status("self-spotting is for running only"); + return; + } + Callsign me = Callsign.Parse(Logging.Me.Callsign); + string comment = MessageExpander.Expand( + session.Settings.SpotComment, Logging, session.Other(Logging)); + _ = cluster.SendSpotAsync(Logging.Frequency, me, comment); + session.Bandmap.Add(new Spot(me, Logging.Frequency, DateTime.UtcNow, SpotSource.Operator)); + Status($"spotted {me.Text} on {Logging.Frequency.Kilohertz:0.0}"); + } + + private void SendToCluster(string command) + { + if (session.Cluster is not { } cluster) + { + Status("not connected to a cluster node"); + return; + } + _ = cluster.SendAsync(command); + Status($"cluster: {command}"); + } + + /// `{CTRLFn}` sends the other radio's message n while the keyboard stays + /// here, which is how an SO2R station CQs on the other radio. + private void SendOnOtherRadio(string key) + { + if (Logging is null + || session.Other(Logging) is not { } other + || session.Keyer is null + || !int.TryParse(key, out int number)) + { + return; + } + string template = Messages.For( + other.Mode.Category, + session.Settings.CwMessages, + session.Settings.PhoneMessages)[number - 1]; + if (template.Length == 0) + { + return; + } + string text = MessageExpander.Expand(template, other, Logging); + _ = SendOnOtherRadioAsync(other.RadioNumber, text); + Status($"radio {other.RadioNumber}: {text}"); + } + + private async Task SendOnOtherRadioAsync(int radio, string text) + { + await session.PointTransmitAtAsync(radio); + try + { + await session.Keyer!.SendAsync(text); + } + catch (InvalidOperationException e) + { + Status(e.Message); + } + } +} diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index e535b7e..d93dead 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -302,6 +302,17 @@ public sealed partial class EntryWindow : Window MoveFocus(forward: true); return; } + LogContact(); + } + + /// Logs what is in the boxes, which is what Enter and N1MM's `{LOG}` macro + /// both do. + private void LogContact() + { + if (Logging is null) + { + return; + } try { Qso logged = Logging.LogContact(); @@ -544,40 +555,6 @@ public sealed partial class EntryWindow : Window Dispatcher.UIThread.Post(() => Status($"alternating CQ stopped: {reason}")); } - private void SendMessage(int index) - { - if (Logging is null || session.Keyer is null) - { - Status("no keyer — Config ▸ Keyer"); - return; - } - string template = Messages.For( - Logging.Mode.Category, - session.Settings.CwMessages, - session.Settings.PhoneMessages)[index]; - if (template.Length == 0) - { - return; - } - string text = MessageExpander.Expand(template, Logging, session.Other(Logging)); - // the box has to point at this radio before the key does - _ = session.PointTransmitAtAsync(radioNumber); - Status($"sending {text}"); - _ = SendAsync(text); - } - - private async Task SendAsync(string text) - { - try - { - await session.Keyer!.SendAsync(text); - } - catch (InvalidOperationException e) - { - Status(e.Message); - } - } - private void SpotCurrentCall() { if (Logging is null || Logging.Entry.Call.Trim().Length == 0) diff --git a/src/Nonemm.Rig/OtrspBox.cs b/src/Nonemm.Rig/OtrspBox.cs index 6dfc588..1e6c22a 100644 --- a/src/Nonemm.Rig/OtrspBox.cs +++ b/src/Nonemm.Rig/OtrspBox.cs @@ -42,6 +42,9 @@ public sealed class OtrspBox : So2rBox public Task SetAuxiliaryAsync(int radioNumber, int code, CancellationToken cancellation = default) => SendAsync($"AUX{Radio(radioNumber)}{Math.Clamp(code, 0, 99):00}", cancellation); + public Task SendCommandAsync(string command, CancellationToken cancellation = default) => + SendAsync(command, cancellation); + public void Dispose() { port?.Dispose(); diff --git a/src/Nonemm.Rig/So2rBox.cs b/src/Nonemm.Rig/So2rBox.cs index d083752..f3bcdbb 100644 --- a/src/Nonemm.Rig/So2rBox.cs +++ b/src/Nonemm.Rig/So2rBox.cs @@ -16,4 +16,8 @@ public interface So2rBox : IDisposable /// Sets a box output, which is usually an antenna or band-decoder line. Task SetAuxiliaryAsync(int radioNumber, int code, CancellationToken cancellation = default); + + /// Sends a command straight to the box, for the `{OTRSP …}` macro, which + /// lets an operator use whatever their box understands. + Task SendCommandAsync(string command, CancellationToken cancellation = default); } diff --git a/src/Nonemm.Session/MessageAction.cs b/src/Nonemm.Session/MessageAction.cs new file mode 100644 index 0000000..99b5476 --- /dev/null +++ b/src/Nonemm.Session/MessageAction.cs @@ -0,0 +1,6 @@ +namespace Nonemm.Session; + +/// One action macro out of a function key message, with whatever the operator +/// wrote after the command: the telnet command, the OTRSP command, or the +/// function key number. +public sealed record MessageAction(MessageCommand Command, string Argument = ""); diff --git a/src/Nonemm.Session/MessageCommand.cs b/src/Nonemm.Session/MessageCommand.cs new file mode 100644 index 0000000..ccade0a --- /dev/null +++ b/src/Nonemm.Session/MessageCommand.cs @@ -0,0 +1,50 @@ +namespace Nonemm.Session; + +/// What an action macro in a function key message does. N1MM's action macros +/// that this program has nothing to do — the CAT families, audio, rotators, +/// call stacking — are not here; they are read and passed over. +public enum MessageCommand +{ + /// Clears the entry boxes, as Escape and N1MM's `{WIPE}` do. + Wipe, + + /// Logs the contact, as the Log It button does. + Log, + + Run, + + SearchAndPounce, + + /// The same as pressing space in an entry box: move to the next field. + Space, + + /// Puts this station on the cluster. + SpotMe, + + FrequencyUp, + + FrequencyDown, + + PageUp, + + PageDown, + + /// Stops sending, as Escape does mid-message. + StopSending, + + /// Sends its argument to the cluster node. + Telnet, + + /// Moves the keyboard to the other radio; `WithTransmit` moves the + /// transmitter as well. + JumpToOtherRadio, + + JumpToOtherRadioWithTransmit, + + /// Sends one of the other radio's function key messages. The argument is + /// the key number, so `{CTRLF9}` sends F9 there. + SendOnOtherRadio, + + /// Sends its argument to the SO2R box. + Otrsp, +} diff --git a/src/Nonemm.Session/MessageExpander.cs b/src/Nonemm.Session/MessageExpander.cs index d171c80..aee410c 100644 --- a/src/Nonemm.Session/MessageExpander.cs +++ b/src/Nonemm.Session/MessageExpander.cs @@ -38,14 +38,14 @@ public static class MessageExpander text.Append(template[at..]); break; } - text.Append(Macro(template[(at + 1)..close], session, other)); + text.Append(TextMacro(template[(at + 1)..close], session, other) ?? ""); at = close + 1; continue; } // the single-character macros, which take no braces if (c is '*' or '!' or '#') { - text.Append(Single(c, session)); + text.Append(TextMacro(c, session)); at++; continue; } @@ -55,7 +55,8 @@ public static class MessageExpander return text.ToString(); } - private static string Single(char macro, RadioPosition session) => macro switch + /// The macros that need no braces: `*`, `!` and `#`. + public static string TextMacro(char macro, RadioPosition session) => macro switch { '*' => session.Me.Callsign, '!' => TheirCall(session), @@ -70,7 +71,9 @@ public static class MessageExpander ? last.SentNumber.ToString(CultureInfo.InvariantCulture) : session.SentNumber.ToString(CultureInfo.InvariantCulture); - private static string Macro(string name, RadioPosition session, RadioPosition? other) => + /// What the macro stands for, or null when it is not a text macro — an + /// action macro, or a name this program does not know. + public static string? TextMacro(string name, RadioPosition session, RadioPosition? other) => name.ToUpperInvariant() switch { "MYCALL" => session.Me.Callsign, @@ -102,7 +105,7 @@ 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), - _ => "", + _ => null, }; /// The call being worked, or the last one logged when the box is empty, diff --git a/src/Nonemm.Session/MessagePlan.cs b/src/Nonemm.Session/MessagePlan.cs new file mode 100644 index 0000000..9576fd2 --- /dev/null +++ b/src/Nonemm.Session/MessagePlan.cs @@ -0,0 +1,120 @@ +using System.Globalization; +using System.Text; + +namespace Nonemm.Session; + +/// What one function key message does: the actions that run before anything +/// goes on the air, the text that goes on the air, and the actions that run +/// once it has gone out. +/// +/// N1MM's rule, which this follows: an action macro runs before the message is +/// sent, unless it stands after `{END}`, and then it runs when the message has +/// finished. Text after `{END}` is dropped, because the message is over. +public sealed record MessagePlan( + IReadOnlyList Before, + string Text, + IReadOnlyList After) +{ + public static MessagePlan Read(string template, RadioPosition session, RadioPosition? other = null) + { + List before = []; + List after = []; + StringBuilder text = new(); + bool ended = false; + int at = 0; + while (at < template.Length) + { + char c = template[at]; + if (c == '{') + { + int close = template.IndexOf('}', at + 1); + if (close < 0) + { + Add(text, template[at..], ended); + break; + } + string name = template[(at + 1)..close]; + at = close + 1; + if (Same(name, "END")) + { + ended = true; + } + else if (MessageExpander.TextMacro(name, session, other) is { } filled) + { + Add(text, filled, ended); + } + else if (Action(name) is { } action) + { + (ended ? after : before).Add(action); + } + continue; + } + if (c is '*' or '!' or '#') + { + Add(text, MessageExpander.TextMacro(c, session), ended); + at++; + continue; + } + Add(text, c.ToString(), ended); + at++; + } + return new MessagePlan(before, text.ToString(), after); + } + + /// Null for a macro this program does not act on, which is then passed + /// over rather than sent as text. + private static MessageAction? Action(string name) + { + string trimmed = name.Trim(); + string upper = trimmed.ToUpperInvariant(); + if (Argument(trimmed, "TELNET") is { } command) + { + return new MessageAction(MessageCommand.Telnet, command); + } + if (Argument(trimmed, "OTRSP") is { } otrsp) + { + return new MessageAction(MessageCommand.Otrsp, otrsp); + } + if (upper.StartsWith("CTRLF", StringComparison.Ordinal) + && int.TryParse(upper[5..], NumberStyles.None, CultureInfo.InvariantCulture, out int key) + && key is >= 1 and <= 12) + { + return new MessageAction( + MessageCommand.SendOnOtherRadio, + key.ToString(CultureInfo.InvariantCulture)); + } + return upper switch + { + "WIPE" => new MessageAction(MessageCommand.Wipe), + "LOG" => new MessageAction(MessageCommand.Log), + "RUN" => new MessageAction(MessageCommand.Run), + "S&P" => new MessageAction(MessageCommand.SearchAndPounce), + "SPACE" => new MessageAction(MessageCommand.Space), + "SPOTME" => new MessageAction(MessageCommand.SpotMe), + "FREQUP" => new MessageAction(MessageCommand.FrequencyUp), + "FREQDN" => new MessageAction(MessageCommand.FrequencyDown), + "PGUP" => new MessageAction(MessageCommand.PageUp), + "PGDN" => new MessageAction(MessageCommand.PageDown), + "STOPTX" => new MessageAction(MessageCommand.StopSending), + "JUMPRX" => new MessageAction(MessageCommand.JumpToOtherRadio), + "JUMPRXTX" => new MessageAction(MessageCommand.JumpToOtherRadioWithTransmit), + _ => null, + }; + } + + /// `{TELNET sh/dx}` and `{OTRSP TX1}` carry their command after the name. + private static string? Argument(string macro, string name) => + macro.StartsWith(name + " ", StringComparison.OrdinalIgnoreCase) + ? macro[(name.Length + 1)..].Trim() + : null; + + private static void Add(StringBuilder text, string what, bool ended) + { + if (!ended) + { + text.Append(what); + } + } + + private static bool Same(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); +} diff --git a/tests/Nonemm.Session.Tests/MessagePlanTests.cs b/tests/Nonemm.Session.Tests/MessagePlanTests.cs new file mode 100644 index 0000000..cfaea67 --- /dev/null +++ b/tests/Nonemm.Session.Tests/MessagePlanTests.cs @@ -0,0 +1,94 @@ +using Nonemm.Contests; +using Nonemm.Contests.Rules; +using Nonemm.Core; +using Nonemm.Storage; + +namespace Nonemm.Session.Tests; + +public class MessagePlanTests +{ + private static RadioPosition Session() + { + FakeLogStore store = new(); + ContestInstance instance = store.AddContest(new ContestInstance + { + ContestNumber = 0, + ContestName = "CQWW", + SentExchange = "14", + }); + return new RadioPosition(new ContestSession( + store, + new CqWorldWide(ModeCategory.Cw), + instance, + new StationInfo { Callsign = "DL1ABC", CqZone = 14 }, + null)); + } + + [Fact] + public void AMessageWithNoMacrosIsJustText() + { + MessagePlan plan = MessagePlan.Read("TEST DE DL1ABC", Session()); + + Assert.Equal("TEST DE DL1ABC", plan.Text); + Assert.Empty(plan.Before); + Assert.Empty(plan.After); + } + + [Fact] + public void AnActionMacroRunsBeforeTheMessageAndDoesNotGoOnTheAir() + { + MessagePlan plan = MessagePlan.Read("{RUN}CQ TEST *", Session()); + + Assert.Equal("CQ TEST DL1ABC", plan.Text); + Assert.Equal([new MessageAction(MessageCommand.Run)], plan.Before); + } + + [Fact] + public void WhatFollowsEndRunsAfterTheMessage() + { + MessagePlan plan = MessagePlan.Read("TU {LOG}{END}{WIPE}", Session()); + + Assert.Equal("TU ", plan.Text); + Assert.Equal([new MessageAction(MessageCommand.Log)], plan.Before); + Assert.Equal([new MessageAction(MessageCommand.Wipe)], plan.After); + } + + [Fact] + public void TextAfterEndIsDropped() + { + MessagePlan plan = MessagePlan.Read("TU{END} 5NN {MYCALL}", Session()); + + Assert.Equal("TU", plan.Text); + } + + [Fact] + public void AMacroWithACommandAfterItKeepsTheCommandAsTyped() + { + MessagePlan plan = MessagePlan.Read("{Telnet sh/dx 20}{OTRSP TX2}", Session()); + + Assert.Equal( + [ + new MessageAction(MessageCommand.Telnet, "sh/dx 20"), + new MessageAction(MessageCommand.Otrsp, "TX2"), + ], + plan.Before); + Assert.Equal("", plan.Text); + } + + [Fact] + public void CtrlFSendsThatKeyOnTheOtherRadio() + { + MessagePlan plan = MessagePlan.Read("TU {CTRLF9}", Session()); + + Assert.Equal([new MessageAction(MessageCommand.SendOnOtherRadio, "9")], plan.Before); + } + + [Fact] + public void AMacroThisProgramDoesNotActOnIsPassedOver() + { + MessagePlan plan = MessagePlan.Read("CQ {STEREOOFF}TEST", Session()); + + Assert.Equal("CQ TEST", plan.Text); + Assert.Empty(plan.Before); + } +}