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); + } +}