using System.Text; using Nonemm.Digital; namespace Nonemm.Digital.Tests; /// The transmit buffer with the engine holding the text. The engine here is a /// stand-in that behaves the way MMTTY's help says MMTTY does: it takes /// characters, a backspace removes the last one it has not transmitted yet, and /// it says how many are left. Whether the real engine does that is what /// `tools/Nonemm.EngineProbe` is for. public class EngineTypeAheadTests { private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5); private static readonly TimeSpan Poll = TimeSpan.FromMilliseconds(5); private readonly FakeEngine engine = new(); private EngineTypeAhead Buffer() => new(engine, Poll); private static async Task WaitForAsync(Func ready) { DateTime giveUp = DateTime.UtcNow + Patience; while (!ready() && DateTime.UtcNow < giveUp) { await Task.Delay(2); } } /// The whole message goes to the engine as fast as the engine takes it, /// which is the point: an engine with characters in hand never transmits /// idle in the middle of a message. [Fact] public async Task AMessageGoesToTheEngineWhole() { using EngineTypeAhead buffer = Buffer(); buffer.Append("CQ TEST"); await WaitForAsync(() => engine.Waiting == "CQ TEST"); Assert.Equal("CQ TEST", engine.Waiting); } /// What the engine has transmitted moves out of the box, along with the one /// character held back because the count is a poll old. [Fact] public async Task WhatTheEngineHasTransmittedIsNotPendingAnyMore() { using EngineTypeAhead buffer = Buffer(); buffer.Append("CQ TEST"); await WaitForAsync(() => engine.Waiting == "CQ TEST"); engine.Transmit(3); await WaitForAsync(() => buffer.Sent.Length >= 4); Assert.Equal("CQ T", buffer.Sent); Assert.Equal("EST", buffer.Pending); } [Fact] public async Task AnEditTakesBackWhatHasNotBeenTransmitted() { using EngineTypeAhead buffer = Buffer(); buffer.Append("OM5X 599 001"); await WaitForAsync(() => engine.Waiting.Length == 12); engine.Transmit(5); await WaitForAsync(() => buffer.Sent.Length >= 6); buffer.Rewrite("99 002", TransmitBuffer.NoCursor); await WaitForAsync(() => engine.Waiting.EndsWith("599 002", StringComparison.Ordinal)); engine.Transmit(engine.Waiting.Length); await WaitForAsync(() => !buffer.IsSending); Assert.Equal("OM5X 599 002", engine.Transmitted); } /// The character the engine was last known to be holding is treated as /// gone. Taking it back would race with the engine transmitting it, and a /// backspace the engine refuses would put the text after it on the air /// twice. [Fact] public async Task TheGuardCharacterIsNotTakenBack() { using EngineTypeAhead buffer = Buffer(); buffer.Append("ABCDEF"); await WaitForAsync(() => engine.Waiting == "ABCDEF"); engine.Transmit(2); await WaitForAsync(() => buffer.Sent == "ABC"); buffer.Rewrite("", TransmitBuffer.NoCursor); await WaitForAsync(() => engine.Waiting == "C"); Assert.Equal("C", engine.Waiting); Assert.Equal("", buffer.Pending); } [Fact] public async Task NothingGoesOutFromBehindTheCursor() { using EngineTypeAhead buffer = Buffer(); buffer.Rewrite("CQ TEST", 2); await WaitForAsync(() => engine.Waiting == "CQ"); await Task.Delay(30); Assert.Equal("CQ", engine.Waiting); // the first queued character is the guard, so it shows as gone Assert.Equal("Q TEST", buffer.Pending); } [Fact] public async Task TheRestGoesOutWhenTheCursorMovesOn() { using EngineTypeAhead buffer = Buffer(); buffer.Rewrite("CQ TEST", 2); await WaitForAsync(() => engine.Waiting == "CQ"); buffer.Cursor = TransmitBuffer.NoCursor; await WaitForAsync(() => engine.Waiting == "CQ TEST"); Assert.Equal("CQ TEST", engine.Waiting); } [Fact] public async Task TheBufferSaysWhenTheMessageHasGoneOut() { using EngineTypeAhead buffer = Buffer(); int drained = 0; buffer.Drained += (_, _) => Interlocked.Increment(ref drained); buffer.Append("TU"); await WaitForAsync(() => engine.Waiting == "TU"); engine.Transmit(2); await WaitForAsync(() => Volatile.Read(ref drained) == 1); Assert.Equal(1, Volatile.Read(ref drained)); Assert.Equal("TU", buffer.Sent); } /// The engine unkeying means its buffer is empty, whatever the last count /// said. [Fact] public async Task AnIdleEngineHasTransmittedEverythingItHeld() { using EngineTypeAhead buffer = Buffer(); buffer.Append("TU"); await WaitForAsync(() => engine.Waiting == "TU"); await Task.Delay(20); buffer.EngineIdle(); Assert.Equal("TU", buffer.Sent); Assert.Equal("", buffer.Pending); } /// MMTTY, as its help describes it: characters go in, a backspace takes the /// last one back while it has not been transmitted, and the count is what /// is left. private sealed class FakeEngine : EngineBuffer { private readonly Lock gate = new(); private readonly StringBuilder waiting = new(); private readonly StringBuilder transmitted = new(); public event EventHandler? Buffered; /// What the engine holds and has not transmitted. public string Waiting { get { lock (gate) { return waiting.ToString(); } } } public string Transmitted { get { lock (gate) { return transmitted.ToString(); } } } public Task TypeAsync(char character, CancellationToken cancellation = default) { lock (gate) { if (character != '\b') { waiting.Append(character); } else if (waiting.Length > 0) { waiting.Remove(waiting.Length - 1, 1); } } return Task.CompletedTask; } public Task AskBufferedAsync(string property = "", CancellationToken cancellation = default) { int left; lock (gate) { left = waiting.Length; } Buffered?.Invoke(this, left); return Task.CompletedTask; } /// The engine transmitting, driven by the test rather than a clock. public void Transmit(int count) { lock (gate) { int going = Math.Min(count, waiting.Length); transmitted.Append(waiting.ToString(0, going)); waiting.Remove(0, going); } } } }