Pace the pump on what MMTTY says it has left

The engine probe answered two questions on the air, and they point opposite
ways.

TxBufLen is the number of characters left to transmit. Seventeen characters
pushed, five decoded back, and it read 12, counting down to 0 as the message
went out. So the pump no longer counts character times off the clock: it asks
the engine, feeds while the answer is under Lead, and asks again. The baud rate
is now only the fallback for an engine that will not answer.

A backspace is not an edit. Pushed in as a character it made the count go up by
four and the text went out unchanged, so text the engine has been given cannot
be taken back, only aborted. EngineTypeAhead was built on the opposite belief
and is gone, along with the setting that chose it and the interface that existed
to switch between the two.

A count that stops going down means the engine is holding what it has: MMTTY set
to Word out keeps a word until the space after it. The pump feeds one character
per look while that lasts, so the space arrives and the word goes out rather
than the message sitting there.

The probe kept two bugs of its own that this run showed: it read the count a
millisecond after pushing, saw 0 and called the message finished, and it started
a step while the previous unkey was still in flight, so that step ran with the
engine down. It now needs two empty answers in a row and waits for the engine to
say it has stopped. It also asks what {RX} does to a word the engine is holding,
which decides whether a macro without a trailing space loses its last word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
This commit is contained in:
2026-09-01 23:02:00 +00:00
parent 917a05b898
commit 4118cd6d33
14 changed files with 403 additions and 896 deletions

View File

@@ -1,227 +0,0 @@
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);
}
}
}
}

View File

@@ -84,7 +84,7 @@ public class TypeAheadTests
buffer.Append("OM5X 599 001");
await WaitForAsync(() => buffer.Sent.Length >= 5);
buffer.Rewrite("599 002", TransmitBuffer.NoCursor);
buffer.Rewrite("599 002", TypeAhead.NoCursor);
await WaitForAsync(() => buffer.Pending.Length == 0);
Assert.EndsWith("599 002", Sent, StringComparison.Ordinal);
@@ -111,7 +111,7 @@ public class TypeAheadTests
buffer.Rewrite("CQ TEST", 2);
await WaitForAsync(() => Sent.Length == 2);
buffer.Cursor = TransmitBuffer.NoCursor;
buffer.Cursor = TypeAhead.NoCursor;
await WaitForAsync(() => Sent == "CQ TEST");
Assert.Equal("CQ TEST", Sent);
@@ -212,21 +212,53 @@ public class TypeAheadTests
Assert.Equal(2, Sent.Length);
}
/// The estimate of what the engine still holds is only an estimate. An
/// engine that says it has stopped transmitting has an empty buffer, and
/// the next character goes to it at once rather than a character time
/// later.
/// An engine that says how much it holds is asked rather than timed, so
/// nothing depends on the baud rate being right.
[Fact]
public async Task AnIdleEngineIsFedWithoutWaiting()
public async Task ACountingEngineIsAskedHowMuchItHolds()
{
using TypeAhead buffer = Buffer(baud: Slow);
FakeEngine engine = new();
using TypeAhead buffer = new(engine, baud: Slow);
buffer.Append("CQ TEST");
await WaitForAsync(() => Sent.Length >= 2);
buffer.EngineIdle();
await WaitForAsync(() => engine.Waiting.Length >= 2);
await Task.Delay(30);
Assert.Equal("CQ", engine.Waiting);
await WaitForAsync(() => Sent.Length >= 4);
Assert.Equal("CQ T", Sent);
engine.Transmit(2);
await WaitForAsync(() => engine.Transmitted.Length + engine.Waiting.Length >= 4);
Assert.Equal("CQ T", engine.Transmitted + engine.Waiting);
}
/// MMTTY set to Word out holds a word until the space after it, so the
/// count stops going down. Waiting for it to move would leave the message
/// sitting there, so the pump feeds on.
[Fact]
public async Task AnEngineHoldingWhatItHasIsFedAnyway()
{
FakeEngine engine = new();
using TypeAhead buffer = new(engine, baud: Slow);
buffer.Append("CQ TEST");
await WaitForAsync(() => engine.Waiting == "CQ TEST");
Assert.Equal("CQ TEST", engine.Waiting);
}
/// 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]
@@ -236,4 +268,70 @@ public class TypeAheadTests
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<int>? 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);
}
}
}
}