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
This commit is contained in:
2026-09-03 14:40:30 +00:00
parent 7ae60be4a0
commit f49a8c10fd
20 changed files with 2604 additions and 390 deletions

View File

@@ -3,20 +3,20 @@ 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.
/// 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 pump partway through.
private const double Fast = 7500;
/// test can still catch the feeder partway through.
private const double Fast = 1500;
/// 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;
/// 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();
@@ -31,6 +31,7 @@ public class TypeAheadTests
}
}
/// An engine with no count of its own, so the clock alone paces it.
private TypeAhead Buffer(double baud = Fast) =>
new(
(character, _) =>
@@ -68,7 +69,7 @@ public class TypeAheadTests
[Fact]
public async Task WhatHasGoneOutIsNotPendingAnyMore()
{
using TypeAhead buffer = Buffer(baud: 40);
using TypeAhead buffer = Buffer();
buffer.Append("CQ TEST DE OM5M");
@@ -77,52 +78,72 @@ public class TypeAheadTests
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(baud: 60);
using TypeAhead buffer = Buffer();
buffer.Append("OM5X 599 001");
await WaitForAsync(() => buffer.Sent.Length >= 5);
buffer.Rewrite("599 002", TypeAhead.NoCursor);
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 NothingGoesOutFromBehindTheCursor()
public async Task NothingTypedGoesOutBeforeTheTransmitterIsKeyed()
{
using TypeAhead buffer = Buffer();
buffer.Rewrite("CQ TEST", 2);
buffer.Edit("CQ TEST");
await WaitForAsync(() => Sent.Length == 2);
await Task.Delay(50);
Assert.Equal("CQ", Sent);
Assert.Equal(" TEST", buffer.Pending);
Assert.Equal("", Sent);
Assert.Equal("CQ TEST", buffer.Pending);
}
[Fact]
public async Task TheRestGoesOutWhenTheCursorMovesOn()
public async Task TheWholePaneGoesOutOnTransmit()
{
using TypeAhead buffer = Buffer();
buffer.Rewrite("CQ TEST", 2);
await WaitForAsync(() => Sent.Length == 2);
buffer.Edit("CQ TEST");
await Task.Delay(20);
buffer.Cursor = TypeAhead.NoCursor;
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 TextAddedBehindTheCursorStillGoesOut()
public async Task TextTypedWhileTransmittingGoesOutBehindIt()
{
using TypeAhead buffer = Buffer();
buffer.Rewrite("CQ", 2);
await WaitForAsync(() => Sent == "CQ");
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");
@@ -130,12 +151,200 @@ public class TypeAheadTests
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.Drained += (_, _) => Interlocked.Increment(ref drained);
buffer.Aired += (_, _) => Interlocked.Increment(ref drained);
buffer.Append("TU");
@@ -146,7 +355,7 @@ public class TypeAheadTests
[Fact]
public async Task AMessageAddedWhileOneIsGoingOutFollowsIt()
{
using TypeAhead buffer = Buffer(baud: 400);
using TypeAhead buffer = Buffer();
buffer.Append("CQ ");
buffer.Append("DE OM5M");
@@ -158,7 +367,7 @@ public class TypeAheadTests
[Fact]
public async Task DroppingLeavesWhatHasAlreadyGone()
{
using TypeAhead buffer = Buffer(baud: 60);
using TypeAhead buffer = Buffer();
buffer.Append("CQ TEST DE OM5M");
await WaitForAsync(() => buffer.Sent.Length >= 3);
@@ -174,7 +383,7 @@ public class TypeAheadTests
[Fact]
public void ClearingEmptiesBothHalves()
{
using TypeAhead buffer = Buffer(baud: 1);
using TypeAhead buffer = Buffer();
buffer.Append("CQ TEST");
buffer.Clear();
@@ -183,63 +392,71 @@ public class TypeAheadTests
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.
/// 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 TheEngineIsKeptOneCharacterAhead()
public async Task AnEngineAnsweringZeroDoesNotPullThePumpForward()
{
using TypeAhead buffer = Buffer(baud: Slow);
FakeEngine engine = new() { Answer = 0 };
using TypeAhead buffer = new(engine, baud: TypeAhead.DefaultBaud);
buffer.Append("CQ TEST");
buffer.Append("CQ TEST DE OM5M OM5M K");
await WaitForAsync(() => Sent.Length >= 2);
Assert.Equal("CQ", Sent);
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);
}
/// Only the lead goes out ahead. The rest waits, which is what leaves it
/// where the operator can still change it.
/// 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 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()
public async Task TheEngineIsNeverLeftWithNothingInHand()
{
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);
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");
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");
await WaitForAsync(() => engine.Transmitted == "CQ TEST DE OM5M");
Assert.Equal("CQ TEST DE OM5M", engine.Transmitted);
}
/// The clock is the fallback for an engine that will not answer.
/// 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 AnEngineThatWillNotCountIsPacedByTheClock()
public async Task AnEngineThatWillNotCountIsFedAnyway()
{
FakeEngine engine = new() { Counts = false };
using TypeAhead buffer = new(engine, baud: Fast);
@@ -252,24 +469,49 @@ public class TypeAheadTests
}
[Fact]
public void ACharacterTakesAsLongAsTheBaudRateSays()
public async Task WhatTheEngineIsStillHoldingHasNotGoneOutYet()
{
using TypeAhead buffer = Buffer(baud: TypeAhead.DefaultBaud);
using TypeAhead buffer = Buffer(Slow);
Assert.Equal(165, buffer.CharacterTime.TotalMilliseconds, 0.5);
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.
/// 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
@@ -308,7 +550,7 @@ public class TypeAheadTests
int left;
lock (gate)
{
left = Counts ? waiting.Length : -1;
left = Counts ? (Answer >= 0 ? Answer : waiting.Length) : -1;
}
Buffered?.Invoke(this, left);
return Task.CompletedTask;