using System.Text; using Nonemm.Digital; namespace Nonemm.Digital.Tests; /// The message sender over a digital engine. The engine here reports what it /// was given and when it was told to stop; the pace is a baud rate no radio /// uses so the tests do not wait for RTTY. public class DigitalEngineSenderTests { /// Long enough for the stop, which waits for the engine to empty and then /// one character time on top of it. private static readonly TimeSpan Patience = TimeSpan.FromSeconds(15); private const double Fast = 1500; /// A quarter of a second per character, so a test can catch the engine /// still holding what it was handed. private const double Slow = 30; [Fact] public async Task TheEngineDroppingTheTransmitterOnItsOwnDoesNotEndTheMessage() { FakeEngine engine = new(); using DigitalEngineSender sender = new(engine, baud: Fast); await sender.SendAsync("CQ TEST DE OM5M"); await WaitForAsync(() => engine.Sent.Length >= 2); engine.Drop(); await WaitForAsync(() => engine.Sent == "CQ TEST DE OM5M"); Assert.Equal("CQ TEST DE OM5M", engine.Sent); } [Fact] public async Task TheDropAfterReturnToReceiveEndsTheMessage() { FakeEngine engine = new(); using DigitalEngineSender sender = new(engine, baud: Fast); bool finished = false; sender.Finished += (_, _) => finished = true; await sender.SendAsync("CQ TEST"); sender.ReturnToReceiveWhenSent(); await WaitForAsync(() => engine.Stopped); engine.Drop(); await WaitForAsync(() => finished && sender.Buffer.Sent.Length == 0); Assert.True(finished); Assert.Equal("", sender.Buffer.Sent); Assert.Equal("CQ TEST", engine.Sent); } [Fact] public async Task TheEngineDroppingInTheMiddleOfAMessagePutsTheTransmitterBackUp() { FakeEngine engine = new(); using DigitalEngineSender sender = new(engine, baud: Fast); sender.Transmit(); await sender.SendAsync("CQ TEST DE OM5M"); await WaitForAsync(() => engine.Sent.Length >= 2); int keyed = engine.Keyed; engine.Drop(); await WaitForAsync(() => engine.Keyed > keyed); Assert.Equal(keyed + 1, engine.Keyed); } /// MMTTY's stop does nothing when its buffer is empty already, so the /// stop has to reach the engine while it still holds the last characters. [Fact] public async Task ReturnToReceiveReachesTheEngineWhileItStillHoldsTheLastCharacters() { FakeEngine engine = new(); using DigitalEngineSender sender = new(engine, baud: Slow); sender.Transmit(); await sender.SendAsync("AB"); sender.ReturnToReceiveWhenSent(); await WaitForAsync(() => engine.Stopped); Assert.True(engine.Stopped); Assert.True(sender.Buffer.OnAir < 2, "the engine had already transmitted everything"); } /// MMTTY's stop leaves the transmitter up, so `{RX}` puts the key down /// itself once the engine has transmitted what it holds. [Fact] public async Task ReturnToReceivePutsTheKeyDownAfterTheEngineHasEmptied() { FakeEngine engine = new(); using DigitalEngineSender sender = new(engine, baud: Slow); bool finished = false; sender.Finished += (_, _) => finished = true; sender.Transmit(); await sender.SendAsync("AB"); sender.ReturnToReceiveWhenSent(); await WaitForAsync(() => engine.Released); Assert.True(engine.Released); Assert.Equal("AB", engine.Sent); Assert.False(engine.Aborted, "the message was cut off instead of being let finish"); await WaitForAsync(() => finished); Assert.True(finished); } /// N1MM's ending: what is left of the message goes to the engine in one /// piece and the engine is asked to stop with its buffer full, because /// `SetMmttyPTT(1)` does nothing at an engine that has been fed one /// character at a time and is therefore nearly empty. [Fact] public async Task ReturnToReceiveHandsTheRestOfTheMessageOverInOnePiece() { FakeEngine engine = new(); using DigitalEngineSender sender = new(engine, baud: Slow); sender.Transmit(); await sender.SendAsync("CQ TEST DE OM5M"); sender.ReturnToReceiveWhenSent(); await WaitForAsync(() => engine.Stopped); Assert.Equal("CQ TEST DE OM5M", engine.Sent); Assert.Equal("", sender.Buffer.Pending); Assert.Contains(engine.Pushes, push => push.Length > 1); } /// The stop reaches the engine while the message is still in it, which at /// 30 baud is most of the four seconds the message takes. [Fact] public async Task TheStopReachesTheEngineWhileItStillHoldsTheMessage() { FakeEngine engine = new(); using DigitalEngineSender sender = new(engine, baud: Slow); sender.Transmit(); await sender.SendAsync("CQ TEST DE OM5M"); sender.ReturnToReceiveWhenSent(); await WaitForAsync(() => engine.Stopped); Assert.True(sender.Buffer.OnAir < "CQ TEST DE OM5M".Length, "the engine had transmitted the whole message before it was told to stop"); } /// A message takes longer to transmit than the engine is given to make /// progress, and the two are not the same thing. Measured as one, the key /// went down partway through a CQ. [Fact] public async Task TheKeyWaitsForTheWholeMessageAndNotForTheStopPatience() { const string message = "CQ CQ DE OM5M OM5M K"; FakeEngine engine = new(); using DigitalEngineSender sender = new(engine, baud: Slow); sender.Transmit(); await sender.SendAsync(message); DateTime from = DateTime.UtcNow; sender.ReturnToReceiveWhenSent(); await WaitForAsync(() => engine.Released); TimeSpan waited = DateTime.UtcNow - from; Assert.Equal(message, engine.Sent); Assert.True( waited > DigitalEngineSender.StopPatience, $"the key went down after {waited.TotalMilliseconds:0} ms, before the message was out"); } /// Transmitting again while the last message is still ending. The ending /// takes as long as the engine takes to transmit what it holds, and the /// operator can key inside that time; the old ending must not put the key /// down in the middle of the new message. [Fact] public async Task TransmittingAgainAbandonsTheEndingOfTheMessageBeforeIt() { FakeEngine engine = new(); using DigitalEngineSender sender = new(engine, baud: Slow); sender.Transmit(); await sender.SendAsync("CQ"); sender.ReturnToReceiveWhenSent(); await WaitForAsync(() => engine.Stopped); sender.Transmit(); await sender.SendAsync("TU"); await WaitForAsync(() => engine.Sent == "CQTU"); Assert.Equal("CQTU", engine.Sent); Assert.False(engine.Released, "the old ending put the key down during the new message"); Assert.True(engine.IsTransmitting); } /// The same through a message alone, with no `{TX}` in front of it: text is /// the operator asking for the transmitter. [Fact] public async Task AMessageDuringAnEndingKeysAgainAndGoesOut() { FakeEngine engine = new(); using DigitalEngineSender sender = new(engine, baud: Slow); sender.Transmit(); await sender.SendAsync("CQ"); sender.ReturnToReceiveWhenSent(); await WaitForAsync(() => engine.Stopped); int keyed = engine.Keyed; await sender.SendAsync("TU"); await WaitForAsync(() => engine.Sent == "CQTU"); Assert.Equal("CQTU", engine.Sent); Assert.True(engine.Keyed > keyed, "the transmitter was not keyed for the new message"); Assert.False(engine.Released); } /// The engine dropping ends the message it was ending, and nothing else. [Fact] public async Task TheKeyStaysDownAfterAMessageHasEnded() { FakeEngine engine = new(); using DigitalEngineSender sender = new(engine, baud: Fast); sender.Transmit(); await sender.SendAsync("CQ TEST"); sender.ReturnToReceiveWhenSent(); await WaitForAsync(() => engine.Stopped); engine.Drop(); await WaitForAsync(() => !sender.Buffer.IsTransmitting); int keyed = engine.Keyed; await Task.Delay(500); Assert.Equal(keyed, engine.Keyed); Assert.False(engine.IsTransmitting); } /// A macro pressed just as the last one finishes. The stop for that message /// is inside the engine, waiting for its buffer to empty, and feeding an /// engine with that standing left MMTTY keyed and transmitting nothing. The /// stop is cleared before the new message is fed. [Fact] public async Task AMessageStartedWhileTheLastOneIsEndingClearsTheStopFirst() { FakeEngine engine = new(); using DigitalEngineSender sender = new(engine, baud: Fast); sender.Transmit(); await sender.SendAsync("CQ"); sender.ReturnToReceiveWhenSent(); await WaitForAsync(() => engine.Stopped); await sender.SendAsync("TU"); await WaitForAsync(() => engine.Sent == "CQTU"); Assert.Equal("CQTU", engine.Sent); Assert.True(engine.Aborted, "the engine was fed with its stop still pending"); Assert.True(engine.IsTransmitting); } /// Two macros pressed one after the other keep the transmitter up, so the /// engine never reports the drop that ends a message. The pane still starts /// again on each of them. Text that has gone to the engine cannot be /// edited, and keeping the whole run in the pane left none of it editable. [Fact] public async Task AMessageStartedAfterTheLastOneLeavesOnlyItsOwnTextInThePane() { FakeEngine engine = new(); using DigitalEngineSender sender = new(engine, baud: Fast); sender.Transmit(); await sender.SendAsync("CQ"); sender.ReturnToReceiveWhenSent(); await WaitForAsync(() => engine.Stopped); await sender.SendAsync("TU"); await WaitForAsync(() => engine.Sent == "CQTU"); Assert.Equal("TU", sender.Buffer.Sent + sender.Buffer.Pending); } private static async Task WaitForAsync(Func ready) { DateTime giveUp = DateTime.UtcNow + Patience; while (!ready() && DateTime.UtcNow < giveUp) { await Task.Delay(2); } } /// An engine with no buffer of its own: it takes what it is given, says /// when it was told to stop, and drops the transmitter when the test says /// so. private sealed class FakeEngine : DigitalEngine { private readonly StringBuilder sent = new(); private readonly List pushes = []; private readonly Lock gate = new(); public bool IsConnected => true; public bool IsTransmitting { get; private set; } = true; /// True once `{RX}` told the engine to stop. public bool Stopped { get; private set; } /// True once the engine was stopped the hard way. public bool Aborted { get; private set; } /// True once the key was put down, which is what ends a message on /// MMTTY. public bool Released { get; private set; } /// How many times the transmitter has been keyed. public int Keyed { get; private set; } public string Sent { get { lock (gate) { return sent.ToString(); } } } /// Every call, so a test can tell a message pushed in one piece from /// the same text fed one character at a time. public IReadOnlyList Pushes { get { lock (gate) { return [.. pushes]; } } } // nothing here decodes or disconnects, so these two are declared to // satisfy the interface and never raised public event EventHandler? Received { add { } remove { } } public event EventHandler? TransmitChanged; public event EventHandler? ConnectionChanged { add { } remove { } } /// The engine reporting the transmitter down. public void Drop() { IsTransmitting = false; TransmitChanged?.Invoke(this, false); } public Task StartAsync(CancellationToken cancellation = default) => Task.CompletedTask; public Task KeyAsync(CancellationToken cancellation = default) { Keyed++; Stopped = false; IsTransmitting = true; return Task.CompletedTask; } public Task SendAsync(string text, CancellationToken cancellation = default) { lock (gate) { sent.Append(text); pushes.Add(text); } return Task.CompletedTask; } public Task AbortAsync(CancellationToken cancellation = default) { Aborted = true; return Task.CompletedTask; } public Task ReturnToReceiveAsync(CancellationToken cancellation = default) { Stopped = true; return Task.CompletedTask; } /// MMTTY drops the transmitter as soon as the key goes down, and this /// does the same. public Task ReleaseKeyAsync(CancellationToken cancellation = default) { Released = true; Drop(); return Task.CompletedTask; } public void Dispose() { } } }