Files
Nonemm/tests/Nonemm.Digital.Tests/TypeAheadTests.cs
ericek111 f49a8c10fd Start the transmit pane again on every new message
The macro buttons went wrong at the end of a transmission, both faults in
DigitalEngineSender:

- The engine reports the transmitter drop on its own thread.
  WhenTransmitChanged took the state lock, decided the message had ended,
  released the lock, and only then called Buffer.Ended(), which clears
  what has gone to the engine. A macro pressed on the last character got
  through StartAsync in that gap and had already flushed its own text into
  Sent, so Ended() wiped the new text off the pane while the engine
  transmitted it. Ended() is now called inside the same lock.

- The pane only started again when the engine reported a drop. Two macros
  in a row keep the transmitter up, so that report never came and Sent
  grew with every press. Everything in Sent is locked, because it is in
  the engine and cannot be taken back, so the whole pane became
  read-only. TypeAhead.Started() drops the last message's sent text and
  keeps what was typed ahead, and StartAsync calls it whenever it keys a
  new transmission.

The rest of this commit is the digital transmit work these fixes sit on:
the pane as one coloured box, the sender's three keying states, the
type-ahead feeder paced by the clock with the engine's count as a brake,
{RX} flushing what is left in one piece, and the entry window's function
keys reading the digital macros on a digital mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
2026-09-03 14:40:30 +00:00

570 lines
18 KiB
C#

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