using MeetingAssistant; using MeetingAssistant.LaunchProfiles; using MeetingAssistant.Transcription; using Microsoft.Extensions.Logging.Abstractions; namespace MeetingAssistant.Tests; public sealed class SpeechRecognitionPipelineHostedServiceTests { [Fact] public async Task HostedServiceInitializesAndWarmsConfiguredPipelineWithoutBlockingStartup() { var pipeline = new CapturingSpeechRecognitionPipeline { BlockReadinessUntilCancelled = true }; var service = new SpeechRecognitionPipelineHostedService( new CapturingSpeechRecognitionPipelineFactory(pipeline), NullLogger.Instance); var startTask = service.StartAsync(CancellationToken.None); await startTask.WaitAsync(TimeSpan.FromSeconds(1)); await pipeline.WaitForInitializeAsync(); await pipeline.WaitForReadinessAsync(); await service.StopAsync(CancellationToken.None); Assert.Equal(1, pipeline.InitializeCount); Assert.Equal(1, pipeline.ReadinessCount); Assert.True(pipeline.ReadinessCancellationWasObserved); Assert.True(pipeline.Disposed); } [Fact] public async Task HostedServiceWarmsConfiguredLaunchProfilePipelinesWithoutBlockingStartup() { var defaultPipeline = new CapturingSpeechRecognitionPipeline(); var funAsrPipeline = new CapturingSpeechRecognitionPipeline { BlockReadinessUntilCancelled = true }; var englishPipeline = new CapturingSpeechRecognitionPipeline(); var service = new SpeechRecognitionPipelineHostedService( new CapturingSpeechRecognitionPipelineFactory(new Dictionary { [ConfigurationLaunchProfileOptionsProvider.DefaultProfileName] = defaultPipeline, ["funasr"] = funAsrPipeline, ["english"] = englishPipeline }), new FakeLaunchProfileOptionsProvider( [ new LaunchProfile( ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, new MeetingAssistantOptions { Recording = { TranscriptionProvider = "azure-speech" } }), new LaunchProfile( "funasr", new MeetingAssistantOptions { Recording = { TranscriptionProvider = "funasr" } }), new LaunchProfile( "english", new MeetingAssistantOptions { Recording = { TranscriptionProvider = "azure-speech" } }) ]), NullLogger.Instance); var startTask = service.StartAsync(CancellationToken.None); await startTask.WaitAsync(TimeSpan.FromSeconds(1)); await defaultPipeline.WaitForInitializeAsync(); await defaultPipeline.WaitForReadinessAsync(); await funAsrPipeline.WaitForInitializeAsync(); await funAsrPipeline.WaitForReadinessAsync(); await service.StopAsync(CancellationToken.None); Assert.Equal(1, defaultPipeline.InitializeCount); Assert.Equal(1, funAsrPipeline.InitializeCount); Assert.True(defaultPipeline.Disposed); Assert.True(funAsrPipeline.Disposed); Assert.True(funAsrPipeline.ReadinessCancellationWasObserved); Assert.Equal(0, englishPipeline.InitializeCount); Assert.False(englishPipeline.Disposed); } private sealed class CapturingSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory { private readonly IReadOnlyDictionary pipelines; public CapturingSpeechRecognitionPipelineFactory(ISpeechRecognitionPipeline pipeline) : this(new Dictionary { [ConfigurationLaunchProfileOptionsProvider.DefaultProfileName] = pipeline }) { } public CapturingSpeechRecognitionPipelineFactory( IReadOnlyDictionary pipelines) { this.pipelines = pipelines; } public ISpeechRecognitionPipeline Create() { return Create(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName); } public ISpeechRecognitionPipeline Create(string? launchProfileName) { var profileName = string.IsNullOrWhiteSpace(launchProfileName) ? ConfigurationLaunchProfileOptionsProvider.DefaultProfileName : launchProfileName; return pipelines[profileName]; } } private sealed class FakeLaunchProfileOptionsProvider : ILaunchProfileOptionsProvider { private readonly IReadOnlyList profiles; public FakeLaunchProfileOptionsProvider(IReadOnlyList profiles) { this.profiles = profiles; } public LaunchProfile GetRequiredProfile(string? name) { var profileName = string.IsNullOrWhiteSpace(name) ? ConfigurationLaunchProfileOptionsProvider.DefaultProfileName : name; return profiles.Single(profile => profile.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase)); } public IReadOnlyList GetProfiles() { return profiles; } public IReadOnlyList GetHotkeys() { return []; } } private sealed class CapturingSpeechRecognitionPipeline : ISpeechRecognitionPipeline { private readonly TaskCompletionSource initialized = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource readinessChecked = new(TaskCreationOptions.RunContinuationsAsynchronously); public int InitializeCount { get; private set; } public int ReadinessCount { get; private set; } public bool BlockReadinessUntilCancelled { get; init; } public bool ReadinessCancellationWasObserved { get; private set; } public bool Disposed { get; private set; } public Task WaitForInitializeAsync() { return initialized.Task.WaitAsync(TimeSpan.FromSeconds(5)); } public Task WaitForReadinessAsync() { return readinessChecked.Task.WaitAsync(TimeSpan.FromSeconds(5)); } public Task InitializeAsync(CancellationToken cancellationToken) { return InitializeAsync(SpeechRecognitionPipelineOptions.Default, cancellationToken); } public Task InitializeAsync( SpeechRecognitionPipelineOptions options, CancellationToken cancellationToken) { InitializeCount++; initialized.TrySetResult(); return Task.CompletedTask; } public async Task WaitUntilReadyAsync(CancellationToken cancellationToken) { ReadinessCount++; readinessChecked.TrySetResult(); if (!BlockReadinessUntilCancelled) { return; } try { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); } catch (OperationCanceledException) { ReadinessCancellationWasObserved = true; throw; } } public ValueTask WriteAsync(MeetingAssistant.Recording.AudioChunk chunk, CancellationToken cancellationToken) { return ValueTask.CompletedTask; } public Task CompleteAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } public async IAsyncEnumerable ReadLiveTranscriptAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask; yield break; } public Task> ReadFinishedTranscriptAsync( string audioPath, SpeechRecognitionPipelineOptions options, CancellationToken cancellationToken) { return Task.FromResult>([]); } public ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; } } }