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

@@ -0,0 +1,404 @@
using System.Text;
using Nonemm.Digital;
namespace Nonemm.Digital.Tests;
/// The message sender over a digital engine. The engine here reports what it
/// was given and when it was told to stop; the pace is a baud rate no radio
/// uses so the tests do not wait for RTTY.
public class DigitalEngineSenderTests
{
/// Long enough for the stop, which waits for the engine to empty and then
/// one character time on top of it.
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(15);
private const double Fast = 1500;
/// A quarter of a second per character, so a test can catch the engine
/// still holding what it was handed.
private const double Slow = 30;
[Fact]
public async Task TheEngineDroppingTheTransmitterOnItsOwnDoesNotEndTheMessage()
{
FakeEngine engine = new();
using DigitalEngineSender sender = new(engine, baud: Fast);
await sender.SendAsync("CQ TEST DE OM5M");
await WaitForAsync(() => engine.Sent.Length >= 2);
engine.Drop();
await WaitForAsync(() => engine.Sent == "CQ TEST DE OM5M");
Assert.Equal("CQ TEST DE OM5M", engine.Sent);
}
[Fact]
public async Task TheDropAfterReturnToReceiveEndsTheMessage()
{
FakeEngine engine = new();
using DigitalEngineSender sender = new(engine, baud: Fast);
bool finished = false;
sender.Finished += (_, _) => finished = true;
await sender.SendAsync("CQ TEST");
sender.ReturnToReceiveWhenSent();
await WaitForAsync(() => engine.Stopped);
engine.Drop();
await WaitForAsync(() => finished && sender.Buffer.Sent.Length == 0);
Assert.True(finished);
Assert.Equal("", sender.Buffer.Sent);
Assert.Equal("CQ TEST", engine.Sent);
}
[Fact]
public async Task TheEngineDroppingInTheMiddleOfAMessagePutsTheTransmitterBackUp()
{
FakeEngine engine = new();
using DigitalEngineSender sender = new(engine, baud: Fast);
sender.Transmit();
await sender.SendAsync("CQ TEST DE OM5M");
await WaitForAsync(() => engine.Sent.Length >= 2);
int keyed = engine.Keyed;
engine.Drop();
await WaitForAsync(() => engine.Keyed > keyed);
Assert.Equal(keyed + 1, engine.Keyed);
}
/// MMTTY's stop does nothing when its buffer is empty already, so the
/// stop has to reach the engine while it still holds the last characters.
[Fact]
public async Task ReturnToReceiveReachesTheEngineWhileItStillHoldsTheLastCharacters()
{
FakeEngine engine = new();
using DigitalEngineSender sender = new(engine, baud: Slow);
sender.Transmit();
await sender.SendAsync("AB");
sender.ReturnToReceiveWhenSent();
await WaitForAsync(() => engine.Stopped);
Assert.True(engine.Stopped);
Assert.True(sender.Buffer.OnAir < 2, "the engine had already transmitted everything");
}
/// MMTTY's stop leaves the transmitter up, so `{RX}` puts the key down
/// itself once the engine has transmitted what it holds.
[Fact]
public async Task ReturnToReceivePutsTheKeyDownAfterTheEngineHasEmptied()
{
FakeEngine engine = new();
using DigitalEngineSender sender = new(engine, baud: Slow);
bool finished = false;
sender.Finished += (_, _) => finished = true;
sender.Transmit();
await sender.SendAsync("AB");
sender.ReturnToReceiveWhenSent();
await WaitForAsync(() => engine.Released);
Assert.True(engine.Released);
Assert.Equal("AB", engine.Sent);
Assert.False(engine.Aborted, "the message was cut off instead of being let finish");
await WaitForAsync(() => finished);
Assert.True(finished);
}
/// N1MM's ending: what is left of the message goes to the engine in one
/// piece and the engine is asked to stop with its buffer full, because
/// `SetMmttyPTT(1)` does nothing at an engine that has been fed one
/// character at a time and is therefore nearly empty.
[Fact]
public async Task ReturnToReceiveHandsTheRestOfTheMessageOverInOnePiece()
{
FakeEngine engine = new();
using DigitalEngineSender sender = new(engine, baud: Slow);
sender.Transmit();
await sender.SendAsync("CQ TEST DE OM5M");
sender.ReturnToReceiveWhenSent();
await WaitForAsync(() => engine.Stopped);
Assert.Equal("CQ TEST DE OM5M", engine.Sent);
Assert.Equal("", sender.Buffer.Pending);
Assert.Contains(engine.Pushes, push => push.Length > 1);
}
/// The stop reaches the engine while the message is still in it, which at
/// 30 baud is most of the four seconds the message takes.
[Fact]
public async Task TheStopReachesTheEngineWhileItStillHoldsTheMessage()
{
FakeEngine engine = new();
using DigitalEngineSender sender = new(engine, baud: Slow);
sender.Transmit();
await sender.SendAsync("CQ TEST DE OM5M");
sender.ReturnToReceiveWhenSent();
await WaitForAsync(() => engine.Stopped);
Assert.True(sender.Buffer.OnAir < "CQ TEST DE OM5M".Length,
"the engine had transmitted the whole message before it was told to stop");
}
/// A message takes longer to transmit than the engine is given to make
/// progress, and the two are not the same thing. Measured as one, the key
/// went down partway through a CQ.
[Fact]
public async Task TheKeyWaitsForTheWholeMessageAndNotForTheStopPatience()
{
const string message = "CQ CQ DE OM5M OM5M K";
FakeEngine engine = new();
using DigitalEngineSender sender = new(engine, baud: Slow);
sender.Transmit();
await sender.SendAsync(message);
DateTime from = DateTime.UtcNow;
sender.ReturnToReceiveWhenSent();
await WaitForAsync(() => engine.Released);
TimeSpan waited = DateTime.UtcNow - from;
Assert.Equal(message, engine.Sent);
Assert.True(
waited > DigitalEngineSender.StopPatience,
$"the key went down after {waited.TotalMilliseconds:0} ms, before the message was out");
}
/// Transmitting again while the last message is still ending. The ending
/// takes as long as the engine takes to transmit what it holds, and the
/// operator can key inside that time; the old ending must not put the key
/// down in the middle of the new message.
[Fact]
public async Task TransmittingAgainAbandonsTheEndingOfTheMessageBeforeIt()
{
FakeEngine engine = new();
using DigitalEngineSender sender = new(engine, baud: Slow);
sender.Transmit();
await sender.SendAsync("CQ");
sender.ReturnToReceiveWhenSent();
await WaitForAsync(() => engine.Stopped);
sender.Transmit();
await sender.SendAsync("TU");
await WaitForAsync(() => engine.Sent == "CQTU");
Assert.Equal("CQTU", engine.Sent);
Assert.False(engine.Released, "the old ending put the key down during the new message");
Assert.True(engine.IsTransmitting);
}
/// The same through a message alone, with no `{TX}` in front of it: text is
/// the operator asking for the transmitter.
[Fact]
public async Task AMessageDuringAnEndingKeysAgainAndGoesOut()
{
FakeEngine engine = new();
using DigitalEngineSender sender = new(engine, baud: Slow);
sender.Transmit();
await sender.SendAsync("CQ");
sender.ReturnToReceiveWhenSent();
await WaitForAsync(() => engine.Stopped);
int keyed = engine.Keyed;
await sender.SendAsync("TU");
await WaitForAsync(() => engine.Sent == "CQTU");
Assert.Equal("CQTU", engine.Sent);
Assert.True(engine.Keyed > keyed, "the transmitter was not keyed for the new message");
Assert.False(engine.Released);
}
/// The engine dropping ends the message it was ending, and nothing else.
[Fact]
public async Task TheKeyStaysDownAfterAMessageHasEnded()
{
FakeEngine engine = new();
using DigitalEngineSender sender = new(engine, baud: Fast);
sender.Transmit();
await sender.SendAsync("CQ TEST");
sender.ReturnToReceiveWhenSent();
await WaitForAsync(() => engine.Stopped);
engine.Drop();
await WaitForAsync(() => !sender.Buffer.IsTransmitting);
int keyed = engine.Keyed;
await Task.Delay(500);
Assert.Equal(keyed, engine.Keyed);
Assert.False(engine.IsTransmitting);
}
/// A macro pressed just as the last one finishes. The stop for that message
/// is inside the engine, waiting for its buffer to empty, and feeding an
/// engine with that standing left MMTTY keyed and transmitting nothing. The
/// stop is cleared before the new message is fed.
[Fact]
public async Task AMessageStartedWhileTheLastOneIsEndingClearsTheStopFirst()
{
FakeEngine engine = new();
using DigitalEngineSender sender = new(engine, baud: Fast);
sender.Transmit();
await sender.SendAsync("CQ");
sender.ReturnToReceiveWhenSent();
await WaitForAsync(() => engine.Stopped);
await sender.SendAsync("TU");
await WaitForAsync(() => engine.Sent == "CQTU");
Assert.Equal("CQTU", engine.Sent);
Assert.True(engine.Aborted, "the engine was fed with its stop still pending");
Assert.True(engine.IsTransmitting);
}
/// Two macros pressed one after the other keep the transmitter up, so the
/// engine never reports the drop that ends a message. The pane still starts
/// again on each of them. Text that has gone to the engine cannot be
/// edited, and keeping the whole run in the pane left none of it editable.
[Fact]
public async Task AMessageStartedAfterTheLastOneLeavesOnlyItsOwnTextInThePane()
{
FakeEngine engine = new();
using DigitalEngineSender sender = new(engine, baud: Fast);
sender.Transmit();
await sender.SendAsync("CQ");
sender.ReturnToReceiveWhenSent();
await WaitForAsync(() => engine.Stopped);
await sender.SendAsync("TU");
await WaitForAsync(() => engine.Sent == "CQTU");
Assert.Equal("TU", sender.Buffer.Sent + sender.Buffer.Pending);
}
private static async Task WaitForAsync(Func<bool> ready)
{
DateTime giveUp = DateTime.UtcNow + Patience;
while (!ready() && DateTime.UtcNow < giveUp)
{
await Task.Delay(2);
}
}
/// An engine with no buffer of its own: it takes what it is given, says
/// when it was told to stop, and drops the transmitter when the test says
/// so.
private sealed class FakeEngine : DigitalEngine
{
private readonly StringBuilder sent = new();
private readonly List<string> pushes = [];
private readonly Lock gate = new();
public bool IsConnected => true;
public bool IsTransmitting { get; private set; } = true;
/// True once `{RX}` told the engine to stop.
public bool Stopped { get; private set; }
/// True once the engine was stopped the hard way.
public bool Aborted { get; private set; }
/// True once the key was put down, which is what ends a message on
/// MMTTY.
public bool Released { get; private set; }
/// How many times the transmitter has been keyed.
public int Keyed { get; private set; }
public string Sent
{
get
{
lock (gate)
{
return sent.ToString();
}
}
}
/// Every call, so a test can tell a message pushed in one piece from
/// the same text fed one character at a time.
public IReadOnlyList<string> Pushes
{
get
{
lock (gate)
{
return [.. pushes];
}
}
}
// nothing here decodes or disconnects, so these two are declared to
// satisfy the interface and never raised
public event EventHandler<string>? Received
{
add { }
remove { }
}
public event EventHandler<bool>? TransmitChanged;
public event EventHandler<bool>? ConnectionChanged
{
add { }
remove { }
}
/// The engine reporting the transmitter down.
public void Drop()
{
IsTransmitting = false;
TransmitChanged?.Invoke(this, false);
}
public Task StartAsync(CancellationToken cancellation = default) => Task.CompletedTask;
public Task KeyAsync(CancellationToken cancellation = default)
{
Keyed++;
Stopped = false;
IsTransmitting = true;
return Task.CompletedTask;
}
public Task SendAsync(string text, CancellationToken cancellation = default)
{
lock (gate)
{
sent.Append(text);
pushes.Add(text);
}
return Task.CompletedTask;
}
public Task AbortAsync(CancellationToken cancellation = default)
{
Aborted = true;
return Task.CompletedTask;
}
public Task ReturnToReceiveAsync(CancellationToken cancellation = default)
{
Stopped = true;
return Task.CompletedTask;
}
/// MMTTY drops the transmitter as soon as the key goes down, and this
/// does the same.
public Task ReleaseKeyAsync(CancellationToken cancellation = default)
{
Released = true;
Drop();
return Task.CompletedTask;
}
public void Dispose()
{
}
}
}