Public Access
Preserve recovery transcripts and add user-approved agent requests
PR and Push Build/Test / build-and-test (push) Successful in 12m0s
PR and Push Build/Test / build-and-test (push) Successful in 12m0s
Append successful offline recovery with a duplicate-warning marker and retain failed replay sources. Offer newly available microphones through confirmation notifications. Add project discovery metadata and blocking association approval, plus nonblocking workflow requests that start a separate settings-agent conversation. Include regression tests, OpenSpec changes, and verified local Release startup.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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<MicrophoneSwitchMonitor>.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<MicrophoneDevice>(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<MicrophoneSwitchMonitor>.Instance, TimeSpan.FromMilliseconds(10),
|
||||
promptLifetime ?? TimeSpan.FromMinutes(1));
|
||||
|
||||
private sealed class MutableDevices : IMicrophoneDeviceProvider
|
||||
{
|
||||
private readonly Channel<bool> polls = Channel.CreateUnbounded<bool>();
|
||||
public volatile MicrophoneDevice[] Available = [];
|
||||
public Action? BeforeRead { get; set; }
|
||||
|
||||
public IReadOnlyList<MicrophoneDevice> 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<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken)
|
||||
{
|
||||
WasShown = true;
|
||||
beforeAcceptance();
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ControlledPrompts : IMicrophoneSwitchPromptService
|
||||
{
|
||||
private readonly Channel<Prompt> requests = Channel.CreateUnbounded<Prompt>();
|
||||
public bool HasMore => requests.Reader.TryPeek(out _);
|
||||
public Task<Prompt> NextAsync() => requests.Reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
public async Task<bool> 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<bool> Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user