using System.Threading.Channels; using MeetingAssistant.Recording; using Microsoft.Extensions.Logging.Abstractions; namespace MeetingAssistant.Tests; public sealed class MicrophoneSwitchMonitorTests { [Fact] public async Task CancellationDuringConfirmationDeviceLookupCannotSwitchMicrophone() { using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var devices = new MutableDevices(); var prompts = new ImmediatelyAcceptedPrompt(() => devices.BeforeRead = lifetime.Cancel); var monitor = new MicrophoneSwitchMonitor(devices, prompts, NullLogger.Instance, TimeSpan.FromMilliseconds(10), TimeSpan.FromMinutes(1)); var switched = false; var monitoring = monitor.RunAsync((_, _) => switched = true, lifetime.Token); devices.Available = [new("usb", "USB microphone")]; await monitoring.WaitAsync(TimeSpan.FromSeconds(2)); Assert.True(prompts.WasShown); Assert.False(switched); } [Fact] public async Task OffersOnlyNewDevicesOnceAndSwitchesOnlyAfterAcceptance() { var oldDevice = new MicrophoneDevice("old", "Built-in microphone"); var newDevice = new MicrophoneDevice("new", "USB headset"); var devices = new MutableDevices { Available = [oldDevice] }; var prompts = new ControlledPrompts(); var monitor = CreateMonitor(devices, prompts); var switched = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var monitoring = monitor.RunAsync((device, _) => switched.TrySetResult(device), lifetime.Token); try { devices.Available = [oldDevice, newDevice]; var prompt = await prompts.NextAsync(); Assert.Equal(newDevice, prompt.Device); Assert.False(switched.Task.IsCompleted); await devices.WaitForPollsAsync(); Assert.False(prompts.HasMore); prompt.Response.SetResult(true); Assert.Equal(newDevice, await switched.Task.WaitAsync(TimeSpan.FromSeconds(2))); await devices.WaitForPollsAsync(); Assert.False(prompts.HasMore); } finally { await lifetime.CancelAsync(); await monitoring; } } [Theory] [InlineData("declined")] [InlineData("removed")] [InlineData("expired")] [InlineData("ended")] public async Task InvalidOrDeclinedPromptsCannotChangeSelection(string outcome) { var device = new MicrophoneDevice("usb", "USB microphone"); var devices = new MutableDevices(); var prompts = new ControlledPrompts(); var monitor = CreateMonitor(devices, prompts, outcome == "expired" ? TimeSpan.FromMilliseconds(30) : null); var switched = false; using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var monitoring = monitor.RunAsync((_, _) => switched = true, lifetime.Token); try { devices.Available = [device]; var prompt = await prompts.NextAsync(); if (outcome == "declined") { prompt.Response.SetResult(false); await devices.WaitForPollsAsync(); } else { if (outcome == "removed") devices.Available = []; if (outcome == "ended") await lifetime.CancelAsync(); var canceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var registration = prompt.Cancellation.Register(() => canceled.TrySetResult()); await canceled.Task.WaitAsync(TimeSpan.FromSeconds(2)); prompt.Response.TrySetResult(true); } await lifetime.CancelAsync(); await monitoring; Assert.False(switched); } finally { await lifetime.CancelAsync(); await monitoring; } } private static MicrophoneSwitchMonitor CreateMonitor(MutableDevices devices, ControlledPrompts prompts, TimeSpan? promptLifetime = null) => new(devices, prompts, NullLogger.Instance, TimeSpan.FromMilliseconds(10), promptLifetime ?? TimeSpan.FromMinutes(1)); private sealed class MutableDevices : IMicrophoneDeviceProvider { private readonly Channel polls = Channel.CreateUnbounded(); public volatile MicrophoneDevice[] Available = []; public Action? BeforeRead { get; set; } public IReadOnlyList GetAvailableMicrophones() { BeforeRead?.Invoke(); polls.Writer.TryWrite(true); return Available; } public MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options) => new(Available, Available.FirstOrDefault()); public async Task WaitForPollsAsync() { while (polls.Reader.TryRead(out _)) { } for (var i = 0; i < 3; i++) await polls.Reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); } } private sealed class ImmediatelyAcceptedPrompt(Action beforeAcceptance) : IMicrophoneSwitchPromptService { public bool WasShown { get; private set; } public Task ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken) { WasShown = true; beforeAcceptance(); return Task.FromResult(true); } } private sealed class ControlledPrompts : IMicrophoneSwitchPromptService { private readonly Channel requests = Channel.CreateUnbounded(); public bool HasMore => requests.Reader.TryPeek(out _); public Task NextAsync() => requests.Reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); public async Task ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken) { var prompt = new Prompt(microphone, cancellationToken); requests.Writer.TryWrite(prompt); return await prompt.Response.Task.WaitAsync(cancellationToken); } } private sealed record Prompt(MicrophoneDevice Device, CancellationToken Cancellation) { public TaskCompletionSource Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); } }