namespace Nonemm.Keying.Tests; /// The keyer is driven with a sound card that records what it was asked to /// play instead of playing it, so what goes to the card and what the /// transmitter does are both asserted. public class VoiceKeyerTests : IDisposable { private readonly string folder = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); private readonly FakeSoundCard card = new(); private readonly List keying = []; public VoiceKeyerTests() { Directory.CreateDirectory(folder); Record("Cq.wav"); Record("Exchange.wav"); } public void Dispose() { Directory.Delete(folder, recursive: true); GC.SuppressFinalize(this); } private void Record(string name) => File.WriteAllText(Path.Combine(folder, name), ""); private VoiceKeyer Keyer() => new(card, new VoiceFiles(folder), (on, _) => { lock (keying) { keying.Add(on); } return Task.CompletedTask; }); private static async Task Finished(VoiceKeyer keyer, Func send) { TaskCompletionSource done = new(); keyer.Finished += (_, _) => done.TrySetResult(); await send(); await done.Task.WaitAsync(TimeSpan.FromSeconds(5)); } [Fact] public async Task TheFilesAMessageNamesArePlayedInOrder() { using VoiceKeyer keyer = Keyer(); await Finished(keyer, () => keyer.SendAsync("Cq.wav,Exchange.wav")); Assert.Equal( [Path.Combine(folder, "Cq.wav"), Path.Combine(folder, "Exchange.wav")], card.Played); } /// The recording goes out of the sound card, so the transmitter has to be /// keyed for as long as it plays and dropped at the end. [Fact] public async Task TheTransmitterIsKeyedForTheMessageAndDroppedAfterIt() { using VoiceKeyer keyer = Keyer(); await Finished(keyer, () => keyer.SendAsync("Cq.wav")); Assert.Equal([true, false], keying); } [Fact] public async Task AMessageThatNamesARecordingThatIsNotThereIsRefused() { using VoiceKeyer keyer = Keyer(); InvalidOperationException refused = await Assert.ThrowsAsync( () => keyer.SendAsync("Cq.wav,Thanks.wav")); Assert.Contains("Thanks.wav", refused.Message); Assert.Empty(card.Played); } /// Nothing is keyed for a message that could not be played: a transmitter /// held up over a missing file is a stuck carrier. [Fact] public async Task ARefusedMessageDoesNotKeyTheTransmitter() { using VoiceKeyer keyer = Keyer(); await Assert.ThrowsAsync(() => keyer.SendAsync("Thanks.wav")); Assert.Empty(keying); } [Fact] public async Task TextWithNoRecordingInItIsRefused() { using VoiceKeyer keyer = Keyer(); InvalidOperationException refused = await Assert.ThrowsAsync( () => keyer.SendAsync("CQ CONTEST OM5M")); Assert.Contains("names no .wav", refused.Message); } /// Escape mid-message: what is playing stops, and the transmitter comes /// down with it. [Fact] public async Task AbortStopsTheRecordingAndDropsTheTransmitter() { card.HoldUntilCancelled = true; using VoiceKeyer keyer = Keyer(); await Finished(keyer, async () => { await keyer.SendAsync("Cq.wav,Exchange.wav"); await card.Playing.Task.WaitAsync(TimeSpan.FromSeconds(5)); await keyer.AbortAsync(); }); Assert.Single(card.Played); Assert.Equal([true, false], keying); } /// A card that will not play says so while the message is going out, by /// which time the caller has been answered and there is nothing left to /// throw at. [Fact] public async Task APlayerThatFailsIsReported() { card.Failure = new InvalidOperationException("aplay is not installed"); using VoiceKeyer keyer = Keyer(); List reported = []; keyer.Reported += (_, what) => reported.Add(what); await Finished(keyer, () => keyer.SendAsync("Cq.wav")); Assert.Equal(["aplay is not installed"], reported); Assert.Equal([true, false], keying); } private sealed class FakeSoundCard : SoundCard { public List Played { get; } = []; public TaskCompletionSource Playing { get; } = new(); public bool HoldUntilCancelled { get; set; } public Exception? Failure { get; set; } public bool CanRecord => true; public async Task PlayAsync(string file, CancellationToken cancellation = default) { Played.Add(file); Playing.TrySetResult(); if (Failure is not null) { throw Failure; } if (HoldUntilCancelled) { await Task.Delay(Timeout.Infinite, cancellation); } } public Task RecordAsync(string file, CancellationToken cancellation = default) => throw new NotSupportedException(); } }