Files
Nonemm/tests/Nonemm.Digital.Tests/EngineTypeAheadTests.cs
ericek111 54b9181c06 Let MMTTY hold the text waiting to go out, behind a setting
MMTTY has a type-ahead buffer of its own: characters go into it, a backspace
takes back one it has not transmitted, and TxBufLen says how many are left. Used
that way it paces itself, so there is no gap between characters to tune and no
baud rate to keep in step with the engine.

EngineTypeAhead does that, and Config > Digital picks between it and the pump
that is there now. Off is still the default: three things it rests on have never
been seen with a real engine.

tools/Nonemm.EngineProbe asks the engine those three questions and writes the
answers to a file. It starts MMTTY through the bridge, pushes a message, polls
TxBufLen while it goes out, backspaces over text that has and has not been
transmitted, and logs what came back on the receive side and when.

The bridge learns one verb for it: `buffer` reads TxBufLen and answers with the
count, or -1 when the control will not say. A property the control does not know
is a log line rather than an error, since it stops nothing.

TypeAhead and EngineTypeAhead share the TransmitBuffer interface, which is what
the digital window now works through, so the window does not know which one it
has.

docs/unfinished.md states what each buffer assumes and how to run the probe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
2026-09-01 22:41:18 +00:00

228 lines
7.1 KiB
C#

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<bool> 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<int>? 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);
}
}
}
}