namespace Nonemm.Keying.Tests; /// The sound card is driven with ordinary shell commands standing in for /// `aplay` and `arecord`, so what the program does with a command — the file /// it fills in, the exit code, the interrupt — is what is asserted. public class CommandSoundCardTests : IDisposable { private readonly string folder = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); public CommandSoundCardTests() => Directory.CreateDirectory(folder); public void Dispose() { Directory.Delete(folder, recursive: true); GC.SuppressFinalize(this); } private string In(string name) => Path.Combine(folder, name); /// Double quotes hold a word together, so a command whose argument has /// spaces in it arrives as one argument. [Fact] public async Task AQuotedWordHoldsTogether() { string played = In("played"); File.WriteAllText(In("Cq.wav"), "hello"); CommandSoundCard card = new($$"""sh -c "cp $0 {{played}}" {file}""", ""); await card.PlayAsync(In("Cq.wav")); Assert.Equal("hello", File.ReadAllText(played)); } [Fact] public async Task PlayingRunsTheCommandOnTheFile() { string played = In("played"); File.WriteAllText(In("Cq.wav"), "hello"); CommandSoundCard card = new($"cp {{file}} {played}", ""); await card.PlayAsync(In("Cq.wav")); Assert.Equal("hello", File.ReadAllText(played)); } [Fact] public async Task PlayingAFileThatIsNotThereSaysWhichFile() { CommandSoundCard card = new("cp {file} /dev/null", ""); FileNotFoundException missing = await Assert.ThrowsAsync( () => card.PlayAsync(In("Cq.wav"))); Assert.Contains("Cq.wav", missing.Message); } [Fact] public async Task APlayerThatIsNotInstalledSaysSo() { File.WriteAllText(In("Cq.wav"), ""); CommandSoundCard card = new("nonemm-no-such-player {file}", ""); InvalidOperationException failed = await Assert.ThrowsAsync( () => card.PlayAsync(In("Cq.wav"))); Assert.Contains("nonemm-no-such-player", failed.Message); } [Fact] public async Task APlayerThatExitsWithAnErrorIsNotPassedOver() { File.WriteAllText(In("Cq.wav"), ""); CommandSoundCard card = new("""sh -c "echo broken pipe >&2; exit 3" """, ""); InvalidOperationException failed = await Assert.ThrowsAsync( () => card.PlayAsync(In("Cq.wav"))); Assert.Contains("code 3", failed.Message); Assert.Contains("broken pipe", failed.Message); } [Fact] public async Task RecordingWritesTheFile() { CommandSoundCard card = new("", """sh -c "echo recorded > $0" {file}"""); await card.RecordAsync(In("Cq.wav")); Assert.Equal("recorded", File.ReadAllText(In("Cq.wav")).Trim()); } /// Stopping a recorder is what the operator does at the end of every /// recording, so what it was writing is the recording rather than a /// failure. [Fact] public async Task ARecorderThatIsStoppedKeepsWhatItWrote() { CommandSoundCard card = new("", """sh -c "echo recorded > $0; sleep 30" {file}"""); using CancellationTokenSource stopping = new(); Task recording = card.RecordAsync(In("Cq.wav"), stopping.Token); while (!File.Exists(In("Cq.wav") + ".recording")) { await Task.Delay(20); } await stopping.CancelAsync(); await recording.WaitAsync(TimeSpan.FromSeconds(10)); Assert.Equal("recorded", File.ReadAllText(In("Cq.wav")).Trim()); } /// The old recording is worth more than a failed one: a station that /// cannot record today still has the voice it recorded last year. [Fact] public async Task ARecorderThatFailsLeavesTheOldRecordingAlone() { File.WriteAllText(In("Cq.wav"), "the old one"); CommandSoundCard card = new("", "nonemm-no-such-recorder {file}"); await Assert.ThrowsAsync(() => card.RecordAsync(In("Cq.wav"))); Assert.Equal("the old one", File.ReadAllText(In("Cq.wav"))); Assert.False(File.Exists(In("Cq.wav") + ".recording")); } [Fact] public async Task AStationWithNoRecorderIsToldRatherThanLeftWaiting() { CommandSoundCard card = new("aplay {file}", ""); Assert.False(card.CanRecord); await Assert.ThrowsAsync(() => card.RecordAsync(In("Cq.wav"))); } }