using System.Text; using Nonemm.Digital; namespace Nonemm.Digital.Tests; /// The text waiting to go out, fed to the engine one character per character /// time. The feeder is run at a baud rate no radio uses so the tests do not /// wait for RTTY. public class TypeAheadTests { private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5); /// Fast enough that a message goes out in milliseconds, slow enough that a /// test can still catch the feeder partway through. private const double Fast = 1500; /// A quarter of a second per character, which is long enough to read what /// the engine is holding before it has transmitted it. private const double Slow = 30; private readonly StringBuilder went = new(); private string Sent { get { lock (went) { return went.ToString(); } } } /// An engine with no count of its own, so the clock alone paces it. private TypeAhead Buffer(double baud = Fast) => new( (character, _) => { lock (went) { went.Append(character); } return Task.CompletedTask; }, baud); private static async Task WaitForAsync(Func ready) { DateTime giveUp = DateTime.UtcNow + Patience; while (!ready() && DateTime.UtcNow < giveUp) { await Task.Delay(2); } } [Fact] public async Task AMessageGoesOut() { using TypeAhead buffer = Buffer(); buffer.Append("CQ TEST"); await WaitForAsync(() => Sent == "CQ TEST"); Assert.Equal("CQ TEST", Sent); Assert.Equal("CQ TEST", buffer.Sent); Assert.Equal("", buffer.Pending); } [Fact] public async Task WhatHasGoneOutIsNotPendingAnyMore() { using TypeAhead buffer = Buffer(); buffer.Append("CQ TEST DE OM5M"); await WaitForAsync(() => Sent.Length >= 2); Assert.StartsWith(buffer.Sent, "CQ TEST DE OM5M", StringComparison.Ordinal); Assert.Equal("CQ TEST DE OM5M", buffer.Sent + buffer.Pending); } /// The pane is edited as a whole: what has gone out and what is still to /// go. What has gone out is dropped, and the rest replaces what was /// waiting. [Fact] public async Task TheOperatorRewritesWhatHasNotGoneOut() { using TypeAhead buffer = Buffer(); buffer.Append("OM5X 599 001"); await WaitForAsync(() => buffer.Sent.Length >= 5); buffer.Edit(buffer.Sent + "599 002"); await WaitForAsync(() => buffer.Pending.Length == 0); Assert.EndsWith("599 002", Sent, StringComparison.Ordinal); Assert.DoesNotContain("001", Sent, StringComparison.Ordinal); } /// What is typed stays off the air until the transmitter is keyed. [Fact] public async Task NothingTypedGoesOutBeforeTheTransmitterIsKeyed() { using TypeAhead buffer = Buffer(); buffer.Edit("CQ TEST"); await Task.Delay(50); Assert.Equal("", Sent); Assert.Equal("CQ TEST", buffer.Pending); } [Fact] public async Task TheWholePaneGoesOutOnTransmit() { using TypeAhead buffer = Buffer(); buffer.Edit("CQ TEST"); await Task.Delay(20); buffer.Transmit(); await WaitForAsync(() => Sent == "CQ TEST"); Assert.Equal("CQ TEST", Sent); } /// The workflow the pane is for: keyed up, what is typed goes out behind /// what is already going, with no second key to press. [Fact] public async Task TextTypedWhileTransmittingGoesOutBehindIt() { using TypeAhead buffer = Buffer(); buffer.Edit("CQ "); buffer.Transmit(); await WaitForAsync(() => Sent == "CQ "); buffer.Edit(buffer.Sent + "DE OM5M"); await WaitForAsync(() => Sent == "CQ DE OM5M"); Assert.Equal("CQ DE OM5M", Sent); } /// A function key pressed while the operator is typing goes on the end of /// what has been typed, and keys the transmitter itself. [Fact] public async Task AMacroFollowsWhatWasTypedAndKeysTheTransmitter() { using TypeAhead buffer = Buffer(); buffer.Edit("CQ"); buffer.Append(" TEST"); await WaitForAsync(() => Sent == "CQ TEST"); Assert.Equal("CQ TEST", Sent); } /// Text taken out of the pane before it goes out is never transmitted. [Fact] public async Task TextDeletedBeforeItGoesOutIsNotSent() { using TypeAhead buffer = Buffer(); buffer.Edit("CQ TEST DE OM5X"); buffer.Transmit(); await WaitForAsync(() => buffer.Sent.Length >= 3); buffer.Edit(buffer.Sent + "DE OM5M"); await WaitForAsync(() => buffer.Pending.Length == 0); Assert.EndsWith("DE OM5M", Sent, StringComparison.Ordinal); Assert.DoesNotContain("OM5X", Sent, StringComparison.Ordinal); } /// The transmitter dropping ends the message: what went out is cleared off /// the pane and nothing goes out again until the transmitter is keyed. [Fact] public async Task TheEndOfAMessageClearsWhatWentOutAndShutsTheGate() { using TypeAhead buffer = Buffer(); buffer.Append("TU"); await WaitForAsync(() => Sent == "TU"); buffer.Ended(); buffer.Edit(" NEXT"); Assert.Equal("", buffer.Sent); Assert.Equal(" NEXT", buffer.Pending); await Task.Delay(50); Assert.Equal("TU", Sent); } /// `Aired` is the end of the message: the engine has been given /// everything and has transmitted it too. [Fact] public async Task DrainedComesWhenTheMessageHasGoneOut() { using TypeAhead buffer = Buffer(); int drained = 0; buffer.Aired += (_, _) => Interlocked.Increment(ref drained); buffer.Append("CQ TEST DE OM5M"); Assert.Equal(0, Volatile.Read(ref drained)); await WaitForAsync(() => Volatile.Read(ref drained) == 1); Assert.Equal("CQ TEST DE OM5M", Sent); } /// The red text in the pane is `OnAir`, and it ran ahead of the /// transmission because it advanced one character per symbol time. A digit /// in a callsign costs a shift to figures and the letter after it a shift /// back, so `OM5M` is six symbols and not four. [Fact] public async Task WhatIsOnTheAirIsPricedInSymbolsAndNotInCharacters() { using TypeAhead buffer = Buffer(); buffer.Append("OM5M"); // four symbol times is what the old clock allowed the whole callsign await WaitForAsync(() => buffer.Sent.Length == 4); await Task.Delay(buffer.SymbolTime * 4.5); Assert.True( buffer.OnAir < 4, $"{buffer.OnAir} of 4 characters were called transmitted in four symbol times"); } /// Letters with nothing to shift for cost one symbol each, so the two /// agree there. [Fact] public async Task PlainLettersGoOutAtOneSymbolEach() { using TypeAhead buffer = Buffer(); buffer.Append("CQ TEST"); await WaitForAsync(() => buffer.OnAir >= 4); Assert.True(buffer.OnAir <= 7); } /// The engine's count is what says how much is left, whatever the clock /// thinks. An engine that says it is still holding the whole message keeps /// the pane from marking any of it as gone out. [Fact] public async Task TheEnginesOwnCountHoldsThePaneBack() { FakeEngine engine = new() { Answer = 30 }; using TypeAhead buffer = new(engine, Fast); buffer.Append("CQ TEST DE OM5M"); await WaitForAsync(() => buffer.Sent.Length == 15); await Task.Delay(buffer.SymbolTime * 20); Assert.Equal(0, buffer.OnAir); } /// And it lets go as the count falls. [Fact] public async Task ThePaneCatchesUpAsTheCountFalls() { FakeEngine engine = new() { Answer = 30 }; using TypeAhead buffer = new(engine, Fast); buffer.Append("CQ TEST DE OM5M"); await WaitForAsync(() => buffer.Sent.Length == 15); engine.Answer = 0; await WaitForAsync(() => buffer.OnAir == 15); Assert.Equal(15, buffer.OnAir); } /// The engine's count is what says how much has gone out. It answers in /// symbols and they fall as they are transmitted, so what it drops between /// two answers is what went on the air between them. [Fact] public async Task WhatTheEngineHasTransmittedComesFromItsOwnCount() { FakeEngine engine = new() { Answer = 20 }; using TypeAhead buffer = new(engine, Fast); buffer.Append("CQ TEST DE OM5M"); await WaitForAsync(() => buffer.Sent.Length == 15); await WaitForAsync(() => buffer.EngineHolds == 20); // four symbols out of the engine, which is the first four characters engine.Answer = 16; await WaitForAsync(() => buffer.OnAir == 4); Assert.Equal(4, buffer.OnAir); } /// A count that has not moved leaves the pane where it is, however long /// the clock runs. [Fact] public async Task ACountThatDoesNotMoveHoldsThePaneStill() { FakeEngine engine = new() { Answer = 20 }; using TypeAhead buffer = new(engine, Fast); buffer.Append("CQ TEST DE OM5M"); await WaitForAsync(() => buffer.Sent.Length == 15); await Task.Delay(buffer.SymbolTime * 20); Assert.Equal(0, buffer.OnAir); } /// An empty engine has transmitted everything it was given, whatever the /// symbols added up to along the way. A wrong guess about the shift /// corrects itself at the end of every message rather than accumulating. [Fact] public async Task AnEmptyEngineHasTransmittedEverything() { FakeEngine engine = new() { Answer = 20 }; using TypeAhead buffer = new(engine, Fast); buffer.Append("CQ TEST DE OM5M"); await WaitForAsync(() => buffer.Sent.Length == 15); await WaitForAsync(() => buffer.EngineHolds == 20); engine.Answer = 0; await WaitForAsync(() => buffer.OnAir == 15); Assert.Equal(15, buffer.OnAir); } /// The count reads 0 for the first 150 ms after a push, before the engine /// has caught up with what it was given. That 0 does not mean the message /// is over. [Fact] public async Task ACountThatHasNotCaughtUpIsNotAnEmptyEngine() { FakeEngine engine = new() { Answer = 0 }; using TypeAhead buffer = new(engine, Fast); buffer.Append("CQ TEST DE OM5M"); await WaitForAsync(() => buffer.Sent.Length == 15); await Task.Delay(buffer.SymbolTime * 8); Assert.Equal(0, buffer.OnAir); } /// Nothing has been given to the engine, so nothing is outstanding. [Fact] public void NothingIsOutstandingWhenNothingIsGoingOut() { using TypeAhead buffer = Buffer(); Assert.Equal(0, buffer.Outstanding); } [Fact] public async Task TheBufferSaysWhenTheMessageHasGoneOut() { using TypeAhead buffer = Buffer(); int drained = 0; buffer.Aired += (_, _) => Interlocked.Increment(ref drained); buffer.Append("TU"); await WaitForAsync(() => Volatile.Read(ref drained) == 1); Assert.Equal(1, Volatile.Read(ref drained)); } [Fact] public async Task AMessageAddedWhileOneIsGoingOutFollowsIt() { using TypeAhead buffer = Buffer(); buffer.Append("CQ "); buffer.Append("DE OM5M"); await WaitForAsync(() => buffer.Pending.Length == 0); Assert.Equal("CQ DE OM5M", Sent); } [Fact] public async Task DroppingLeavesWhatHasAlreadyGone() { using TypeAhead buffer = Buffer(); buffer.Append("CQ TEST DE OM5M"); await WaitForAsync(() => buffer.Sent.Length >= 3); buffer.Drop(); string gone = Sent; await Task.Delay(60); Assert.Equal(gone, Sent); Assert.Equal("", buffer.Pending); Assert.Equal(gone, buffer.Sent); } [Fact] public void ClearingEmptiesBothHalves() { using TypeAhead buffer = Buffer(); buffer.Append("CQ TEST"); buffer.Clear(); Assert.Equal("", buffer.Pending); Assert.Equal("", buffer.Sent); } /// MMTTY answers 0 while it is still transmitting: for the first 150 ms /// after a push, and for as long as it holds a word on Word out. A feeder /// that read that as room fed on it and ran ahead of the air, so the clock /// is the pace and the count only ever holds it back. This one runs at the /// real RTTY speed, because the number that matters is how many characters /// go out in a second at 45.45 baud. [Fact] public async Task AnEngineAnsweringZeroDoesNotPullThePumpForward() { FakeEngine engine = new() { Answer = 0 }; using TypeAhead buffer = new(engine, baud: TypeAhead.DefaultBaud); buffer.Append("CQ TEST DE OM5M OM5M K"); await Task.Delay(TimeSpan.FromSeconds(1)); // 45.45 baud is 6.06 characters a second, plus the `Ahead` characters // the engine is primed with and one for the poll granularity Assert.InRange(engine.Waiting.Length, 7, 9); } /// The engine is given the next character before it has transmitted the one /// on the air, so it has one in hand when that one finishes. An engine left /// with an empty buffer transmits the idle tone instead, which is audible /// between the characters of a long word. [Fact] public async Task TheEngineIsNeverLeftWithNothingInHand() { FakeEngine engine = new(); using TypeAhead buffer = new(engine, baud: TypeAhead.DefaultBaud); // transmits one character every character time, as a real engine does using Timer transmitting = new(_ => engine.Transmit(1), null, 165, 165); buffer.Append(new string('N', 30)); int emptied = 0; for (int look = 0; look < 60; look++) { await Task.Delay(30); if (engine.Waiting.Length == 0 && buffer.Pending.Length > 0) { emptied++; } } Assert.Equal(0, emptied); } /// The engine transmitting makes room, and the feeder fills it. Nothing /// here guesses how fast the engine is going: it says, and it is believed. [Fact] public async Task TheEngineIsFedAsItMakesRoom() { FakeEngine engine = new(); using TypeAhead buffer = new(engine); using Timer transmitting = new(_ => engine.Transmit(1), null, 0, 10); buffer.Append("CQ TEST DE OM5M"); await WaitForAsync(() => engine.Transmitted == "CQ TEST DE OM5M"); Assert.Equal("CQ TEST DE OM5M", engine.Transmitted); } /// An engine that will not say how much it holds is fed anyway: there is /// nothing to pace against, so it gets the message. [Fact] public async Task AnEngineThatWillNotCountIsFedAnyway() { FakeEngine engine = new() { Counts = false }; using TypeAhead buffer = new(engine, baud: Fast); buffer.Append("CQ TEST"); await WaitForAsync(() => engine.Waiting == "CQ TEST"); Assert.False(buffer.Counts); Assert.Equal("CQ TEST", engine.Waiting); } [Fact] public async Task WhatTheEngineIsStillHoldingHasNotGoneOutYet() { using TypeAhead buffer = Buffer(Slow); buffer.Append("AB"); await WaitForAsync(() => buffer.Sent == "AB"); Assert.Equal(0, buffer.OnAir); await WaitForAsync(() => buffer.OnAir == 2); Assert.Equal(2, buffer.OnAir); } [Fact] public async Task TheMessageIsNotOverUntilTheEngineHasTransmittedWhatItHolds() { using TypeAhead buffer = Buffer(Slow); int atTheEnd = -1; buffer.Aired += (_, _) => atTheEnd = buffer.OnAir; buffer.Append("AB"); await WaitForAsync(() => atTheEnd >= 0); Assert.Equal(2, atTheEnd); } /// An engine with a buffer of its own: it takes characters, holds them /// until the test says they have been transmitted, and says how many it /// has. `Counts` false is the engine that will not answer, and `Answer` is /// the engine that answers a number of its own rather than what it holds: /// MMTTY reads 0 while it is still transmitting, and reads high while it is /// holding a word. private sealed class FakeEngine : EngineBuffer { private readonly Lock gate = new(); private readonly StringBuilder waiting = new(); private readonly StringBuilder transmitted = new(); public bool Counts { get; init; } = true; /// What to answer instead of what is really waiting, or -1 to answer /// what is waiting. A test can move it while the buffer is running, the /// way a real engine's count falls as it transmits. public int Answer { get; set; } = -1; public event EventHandler? Buffered; 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) { waiting.Append(character); } return Task.CompletedTask; } public Task AskBufferedAsync(string property = "", CancellationToken cancellation = default) { int left; lock (gate) { left = Counts ? (Answer >= 0 ? Answer : waiting.Length) : -1; } Buffered?.Invoke(this, left); return Task.CompletedTask; } public void Transmit(int count) { lock (gate) { int going = Math.Min(count, waiting.Length); transmitted.Append(waiting.ToString(0, going)); waiting.Remove(0, going); } } } }