Hold a digital message back so it can still be changed

A digital engine takes a whole message and transmits it at 45.45 baud, which
takes several seconds. Once it has the message nothing can be changed, so an
operator who sees the wrong call going out has to stop the transmission and
start again.

TypeAhead keeps the message instead and feeds the engine one character at a
time. What has not gone out yet can be rewritten, added to or deleted. Cursor
is how much of it may go: the pump stops there and the engine idles on the air,
which is the diddle a RTTY engine sends between characters anyway.

The pump counts character times off the clock at the baud rate rather than
asking the engine what it has left, so it runs a little behind: a gap between
two characters is idle on the air and costs nothing, while feeding faster than
the engine transmits would put text out of reach again.

Every digital message goes through it — the function keys, ESM, the QTC window
and the digital window's own buttons — because they all send through
DigitalEngineSender. Finished is now raised when the buffer runs dry, which is
when the message has really gone, so what stands after {END} runs then and {RX}
drops the transmitter at the right moment. The transmitter dropping only counts
as the end when nothing is left to send: an engine that keys itself off what it
is given drops between two characters as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-09-01 05:25:36 +00:00
parent 0e2aee545e
commit 65f1051bc1
3 changed files with 492 additions and 9 deletions

View File

@@ -0,0 +1,188 @@
using System.Text;
using Nonemm.Digital;
namespace Nonemm.Digital.Tests;
/// The text waiting to go out, fed to the engine a character 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;
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<bool> ready)
{
DateTime giveUp = DateTime.UtcNow + Patience;
while (!ready() && DateTime.UtcNow < giveUp)
{
await Task.Delay(2);
}
}
[Fact]
public async Task AMessageGoesOutOneCharacterAtATime()
{
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);
}
[Fact]
public void ACharacterTakesAsLongAsTheBaudRateSays()
{
using TypeAhead buffer = Buffer(baud: TypeAhead.DefaultBaud);
Assert.Equal(165, buffer.CharacterTime.TotalMilliseconds, 0.5);
}
}