A phone function key names a wav file instead of words, which is what N1MM's
own SSB file holds: `F1 CQ,{WAVDIR}\Cq.wav`. `{WAVDIR}` is the folder named
after whoever is at the microphone, a name is taken relative to `Wav`, and
commas play several files in a row. An SSB `.mc` file written for N1MM plays
here.
Window ▸ Voice Keyer lists every recording the messages name, says which of
them are there for the operator picked at the top, plays one, and records over
one. The recording is written beside the wanted file and moved over it at the
end, so a recorder that will not start takes nothing away.
`CommandSoundCard` runs `aplay` and `arecord`, and both commands are settings:
the audio path belongs to whatever the station runs, and a library would pick
one of them for the operator. A recorder is stopped with an interrupt so it
closes its wav file, and killed only if it ignores that. PTT is cwdaemon's
`<ESC>a` for as long as the recording plays, off for a station on VOX.
Two things follow. The QTC window sends on SSB: N1MM's four recordings on the
Ready, Agn, Cfm and Close buttons, with the lines still read out by the
operator. And alternating CQ duels on phone, because the voice keyer says when
a recording has finished — which it had to, since the CQ message is picked by
the radio's mode and would otherwise have been keyed as Morse.
Nothing here has been played on a radio: this machine has no sound card, so
what is tested is the program being run, the file filled in, the exit code and
the interrupt, with shell commands standing in for the two.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
199 lines
5.4 KiB
C#
199 lines
5.4 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);
|
|
}
|
|
|
|
/// A message that is not a CQ leaves the CQ where it is, and the next CQ
|
|
/// carries the alternation on. This is what happens while a contact is
|
|
/// worked, and it is N1MM's rule.
|
|
[Fact]
|
|
public void WorkingAStationHoldsTheCqOnOneRadio()
|
|
{
|
|
FakeKeyer keyer = new();
|
|
List<int> called = [];
|
|
using AlternatingCq cq = Driving(keyer, called);
|
|
|
|
cq.Start(1);
|
|
cq.Hold();
|
|
keyer.FinishMessage();
|
|
keyer.FinishMessage();
|
|
|
|
Assert.Equal([1], called);
|
|
Assert.True(cq.IsRunning);
|
|
Assert.False(cq.CarriesOn);
|
|
}
|
|
|
|
/// The CQ that follows a logged contact starts the alternation again,
|
|
/// without the operator switching it back on.
|
|
[Fact]
|
|
public void TheNextCqCarriesTheAlternationOnAgain()
|
|
{
|
|
FakeKeyer keyer = new();
|
|
List<int> called = [];
|
|
using AlternatingCq cq = Driving(keyer, called);
|
|
|
|
cq.Start(1);
|
|
cq.Hold();
|
|
keyer.FinishMessage();
|
|
cq.CarryOn();
|
|
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);
|
|
}
|
|
|
|
/// There is a keyer per mode, and a CQ that goes out through the voice
|
|
/// keyer has to carry the alternation on the same way a CW one does.
|
|
[Fact]
|
|
public void AMessageFinishingAtAnyOfTheKeyersMovesTheCq()
|
|
{
|
|
FakeKeyer cw = new();
|
|
FakeKeyer voice = new();
|
|
List<int> called = [];
|
|
using AlternatingCq cq = new(
|
|
[cw, voice],
|
|
radio =>
|
|
{
|
|
called.Add(radio);
|
|
return Task.CompletedTask;
|
|
},
|
|
TimeSpan.Zero,
|
|
NoWait);
|
|
|
|
cq.Start(1);
|
|
voice.FinishMessage();
|
|
|
|
Assert.Equal([1, 2], 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);
|
|
}
|
|
}
|