Ctrl+B calls CQ on one radio, and when that message has gone out, moves to the other and calls there. The keyboard, the entry window and the SO2R box follow each turn. N1MM calls this dueling CQs and puts it on the same key. The turn is taken when the keyer says the message has ended, not when a timer guesses it has. So MessageSender grew a Finished event and a ReportsCompletion flag, and both keyers fill them in: cwdaemon answers the <ESC>h reply request that now goes out in front of every message, and a WinKeyer clears the busy bit in the status bytes it sends of its own accord. The status-byte reading is in WinkeyerStatus, away from the serial port, because that is the half that can be tested without a keyer on the desk. A keyer that reports nothing refuses to start alternating CQ rather than keying the second radio over the first. AlternatingCq itself takes the keyer, a callback that calls CQ on a radio, the gap and a wait function, so the alternation is tested without sleeping. The gap is in Config ▸ Keyer and messages and will not go below 100 ms, which is N1MM's floor too: an SO2R box works relays. docs/keying.md writes down why cwdaemon does the timing and we do not. N1MM keys DTR itself with a coarse sleep, a busy-wait and a margin that grows every time the sleep overshoots, and it raises the thread to TIME_CRITICAL for the length of the message. The busy-wait ports to Linux; the priority does not, without CAP_SYS_NICE, and a garbage collection mid-element is audible. A direct serial keyer stays a reasonable third option, to be taken knowingly. Running it against a fake daemon that takes 1.5 seconds to play a message: six CQs went out back to back and the keyboard moved between the two entry windows each time. Escape stopped it, let the message in flight finish, and started nothing further. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
137 lines
3.7 KiB
C#
137 lines
3.7 KiB
C#
using Nonemm.Keying;
|
|
|
|
namespace Nonemm.Session.Tests;
|
|
|
|
public class AlternatingCqTests
|
|
{
|
|
/// A keyer that sends nothing and says it has finished when the test says
|
|
/// so, so the alternation is what is under test rather than a timer.
|
|
private sealed class FakeKeyer : MessageSender
|
|
{
|
|
public bool IsReady => true;
|
|
|
|
public bool ReportsCompletion { get; init; } = true;
|
|
|
|
public event EventHandler? Finished;
|
|
|
|
public void FinishMessage() => Finished?.Invoke(this, EventArgs.Empty);
|
|
|
|
public Task SendAsync(string text, CancellationToken cancellation = default) =>
|
|
Task.CompletedTask;
|
|
|
|
public Task AbortAsync(CancellationToken cancellation = default) => Task.CompletedTask;
|
|
|
|
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) =>
|
|
Task.CompletedTask;
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
private static readonly Func<TimeSpan, CancellationToken, Task> NoWait =
|
|
(_, _) => Task.CompletedTask;
|
|
|
|
private static AlternatingCq Driving(FakeKeyer keyer, List<int> called) =>
|
|
new(
|
|
keyer,
|
|
radio =>
|
|
{
|
|
called.Add(radio);
|
|
return Task.CompletedTask;
|
|
},
|
|
TimeSpan.Zero,
|
|
NoWait);
|
|
|
|
[Fact]
|
|
public void StartingCallsOnTheRadioItWasGiven()
|
|
{
|
|
FakeKeyer keyer = new();
|
|
List<int> called = [];
|
|
using AlternatingCq cq = Driving(keyer, called);
|
|
|
|
cq.Start(2);
|
|
|
|
Assert.Equal([2], called);
|
|
Assert.True(cq.IsRunning);
|
|
Assert.Equal(2, cq.RadioNumber);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheOtherRadioCallsOnceTheMessageHasGoneOut()
|
|
{
|
|
FakeKeyer keyer = new();
|
|
List<int> called = [];
|
|
using AlternatingCq cq = Driving(keyer, called);
|
|
|
|
cq.Start(1);
|
|
keyer.FinishMessage();
|
|
keyer.FinishMessage();
|
|
keyer.FinishMessage();
|
|
|
|
Assert.Equal([1, 2, 1, 2], called);
|
|
}
|
|
|
|
[Fact]
|
|
public void StoppingLeavesTheKeyerAlone()
|
|
{
|
|
FakeKeyer keyer = new();
|
|
List<int> called = [];
|
|
using AlternatingCq cq = Driving(keyer, called);
|
|
|
|
cq.Start(1);
|
|
cq.Stop();
|
|
keyer.FinishMessage();
|
|
|
|
Assert.Equal([1], called);
|
|
Assert.False(cq.IsRunning);
|
|
}
|
|
|
|
/// The message that was already going out when the operator stopped still
|
|
/// reports itself finished, and that must not start the other radio.
|
|
[Fact]
|
|
public void AMessageFinishingAfterAStopStartsNothing()
|
|
{
|
|
FakeKeyer keyer = new();
|
|
List<int> called = [];
|
|
using AlternatingCq cq = Driving(keyer, called);
|
|
|
|
cq.Start(1);
|
|
keyer.FinishMessage();
|
|
cq.Stop();
|
|
keyer.FinishMessage();
|
|
|
|
Assert.Equal([1, 2], called);
|
|
}
|
|
|
|
[Fact]
|
|
public void AKeyerThatCannotReportCompletionCannotDriveIt()
|
|
{
|
|
FakeKeyer keyer = new() { ReportsCompletion = false };
|
|
List<int> called = [];
|
|
using AlternatingCq cq = Driving(keyer, called);
|
|
|
|
Assert.False(cq.IsPossible);
|
|
Assert.Throws<InvalidOperationException>(() => cq.Start(1));
|
|
Assert.Empty(called);
|
|
}
|
|
|
|
[Fact]
|
|
public void AKeyerThatHasGoneAwayStopsTheWholeThing()
|
|
{
|
|
FakeKeyer keyer = new();
|
|
List<string> reasons = [];
|
|
using AlternatingCq cq = new(
|
|
keyer,
|
|
_ => throw new InvalidOperationException("could not reach cwdaemon at 127.0.0.1:6789"),
|
|
TimeSpan.Zero,
|
|
NoWait);
|
|
cq.Stopped += (_, reason) => reasons.Add(reason);
|
|
|
|
cq.Start(1);
|
|
|
|
Assert.False(cq.IsRunning);
|
|
Assert.Equal(["could not reach cwdaemon at 127.0.0.1:6789"], reasons);
|
|
}
|
|
}
|