The second probe run read TxBufLen as 0 one millisecond and again fifty milliseconds after twenty-one characters had been pushed, while those characters were already going out: the first of them was decoded back off the air 420 ms later. The number is right when it is a second old and wrong when it is fresh, so it runs behind the engine. A pump that fed on it would have handed over the whole message in half a second and put all of it beyond reach, which is worse than the clock it replaced. So the clock is the pace again, and the count is a check on it: the engine is never given more than Lead + Slack characters however fast the clock says to feed, and a message ends when the count and the clock estimate both say it has. Characters fed since the last answer are added to it, so a stale answer cannot be spent twice. The engine is set to Character out: ABCD with no space after it went out at once. Word out would have held it, so the holding case stays, but it now needs the count to be neither going down nor being added to, since an engine kept exactly at the cap has a count that does not move either. The probe writes down every reading for two seconds after a push and does not believe an empty one in that window, which is what the run needed to measure the lag and did not do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
328 lines
9.4 KiB
C#
328 lines
9.4 KiB
C#
using System.Text;
|
|
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.
|
|
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;
|
|
|
|
/// 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;
|
|
|
|
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 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(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);
|
|
}
|
|
|
|
/// 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.
|
|
[Fact]
|
|
public async Task TheEngineIsKeptOneCharacterAhead()
|
|
{
|
|
using TypeAhead buffer = Buffer(baud: Slow);
|
|
|
|
buffer.Append("CQ TEST");
|
|
|
|
await WaitForAsync(() => Sent.Length >= 2);
|
|
Assert.Equal("CQ", Sent);
|
|
}
|
|
|
|
/// Only the lead goes out ahead. The rest waits, which is what leaves it
|
|
/// where the operator can still change it.
|
|
[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()
|
|
{
|
|
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);
|
|
|
|
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");
|
|
}
|
|
|
|
/// 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]
|
|
public void ACharacterTakesAsLongAsTheBaudRateSays()
|
|
{
|
|
using TypeAhead buffer = Buffer(baud: TypeAhead.DefaultBaud);
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|