using System.Text; using Nonemm.Digital; namespace Nonemm.Digital.Tests; /// The text waiting to go out, fed to the engine a few characters at a time. /// The pump 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 pump partway through. private const double Fast = 7500; /// A character time of 100 ms, so a test can tell the characters sent ahead /// from the ones that wait for the engine. private const double Slow = 75; private readonly StringBuilder went = new(); private string Sent { get { lock (went) { return went.ToString(); } } } 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(baud: 40); 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); } [Fact] public async Task TheOperatorRewritesWhatHasNotGoneOut() { using TypeAhead buffer = Buffer(baud: 60); buffer.Append("OM5X 599 001"); await WaitForAsync(() => buffer.Sent.Length >= 5); buffer.Rewrite("599 002", TypeAhead.NoCursor); await WaitForAsync(() => buffer.Pending.Length == 0); Assert.EndsWith("599 002", Sent, StringComparison.Ordinal); Assert.DoesNotContain("001", Sent, StringComparison.Ordinal); } [Fact] public async Task NothingGoesOutFromBehindTheCursor() { using TypeAhead buffer = Buffer(); buffer.Rewrite("CQ TEST", 2); await WaitForAsync(() => Sent.Length == 2); await Task.Delay(50); Assert.Equal("CQ", Sent); Assert.Equal(" TEST", buffer.Pending); } [Fact] public async Task TheRestGoesOutWhenTheCursorMovesOn() { using TypeAhead buffer = Buffer(); buffer.Rewrite("CQ TEST", 2); await WaitForAsync(() => Sent.Length == 2); buffer.Cursor = TypeAhead.NoCursor; await WaitForAsync(() => Sent == "CQ TEST"); Assert.Equal("CQ TEST", Sent); } [Fact] public async Task TextAddedBehindTheCursorStillGoesOut() { using TypeAhead buffer = Buffer(); buffer.Rewrite("CQ", 2); await WaitForAsync(() => Sent == "CQ"); buffer.Append(" TEST"); await WaitForAsync(() => Sent == "CQ TEST"); Assert.Equal("CQ TEST", Sent); } [Fact] public async Task TheBufferSaysWhenTheMessageHasGoneOut() { using TypeAhead buffer = Buffer(); int drained = 0; buffer.Drained += (_, _) => 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(baud: 400); 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(baud: 60); 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(baud: 1); buffer.Append("CQ TEST"); buffer.Clear(); Assert.Equal("", buffer.Pending); Assert.Equal("", buffer.Sent); } /// The engine is given the second character before it has transmitted the /// first, so it has one in hand when the first is done. An engine left with /// an empty buffer transmits idle instead, and that idle is added to how /// long the message takes. [Fact] public async Task TheEngineIsKeptOneCharacterAhead() { using TypeAhead buffer = Buffer(baud: Slow); buffer.Append("CQ TEST"); await WaitForAsync(() => Sent.Length >= 2); Assert.Equal("CQ", Sent); } /// Only the lead goes out ahead. The rest waits, which is what leaves it /// where the operator can still change it. [Fact] public async Task NoMoreThanTheLeadGoesToTheEngineAtOnce() { using TypeAhead buffer = Buffer(baud: Slow); buffer.Append("CQ TEST"); await WaitForAsync(() => Sent.Length >= 2); await Task.Delay(20); Assert.Equal(2, Sent.Length); } /// The engine's count of what it has left runs behind what it is really /// doing: the probe read 0 fifty milliseconds after twenty-one characters /// had been pushed and were already going out. A pump that believed that /// would hand over the whole message at once, so the count only ever stops /// it, and the engine is never given more than `Lead` plus `Slack`. [Fact] public async Task AnEngineBehindTheClockIsNotFedPastTheCap() { FakeEngine engine = new(); using TypeAhead buffer = new(engine, baud: Slow); // half the speed of the baud rate the pump is paced by using Timer transmitting = new(_ => engine.Transmit(1), null, 0, 200); buffer.Append("CQ TEST DE OM5M"); int most = 0; for (int look = 0; look < 50; look++) { most = Math.Max(most, engine.Waiting.Length); await Task.Delay(20); } Assert.True(most <= TypeAhead.DefaultLead + TypeAhead.Slack, $"the engine was given {most}"); Assert.True(engine.Transmitted.Length > 0, "nothing was transmitted at all"); } /// The clock is the fallback for an engine that will not answer. [Fact] public async Task AnEngineThatWillNotCountIsPacedByTheClock() { 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 void ACharacterTakesAsLongAsTheBaudRateSays() { using TypeAhead buffer = Buffer(baud: TypeAhead.DefaultBaud); Assert.Equal(165, buffer.CharacterTime.TotalMilliseconds, 0.5); } /// 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. 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; 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 ? 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); } } } }