using System.Net; using System.Net.Http.Json; using MeetingAssistant.Recording; using MeetingAssistant.Speakers; using MeetingAssistant.Transcription; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; namespace MeetingAssistant.Tests; public sealed class AsrDiagnosticEndpointTests { [Fact] public async Task EndpointTranscribesLocalWavThroughConfiguredProvider() { await using var factory = CreateFactory(); using var client = factory.CreateClient(); var wavPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav"); using var response = await client.PostAsJsonAsync("/asr/transcribe-file", new { path = wavPath }); var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(wavPath, body?.Path); var segment = Assert.Single(body?.Segments ?? []); Assert.Equal("Unknown", segment.Speaker); Assert.StartsWith("endpoint-bytes:", segment.Text, StringComparison.Ordinal); } [Fact] public async Task EndpointReportsProviderFailuresAsBadGateway() { await using var factory = CreateFactory(); using var client = factory.CreateClient(); var wavPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav"); using var response = await client.PostAsJsonAsync("/asr/transcribe-file", new { path = wavPath }); Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode); } [Fact] public async Task EndpointRunsFinalDiarizationForLocalWav() { SpeechRecognitionPipelineOptions? capturedOptions = null; await using var factory = CreateFactory( (audioPath, liveSegments, options, cancellationToken) => { capturedOptions = options; return Task.FromResult>( liveSegments .Select(segment => segment with { Speaker = "Speaker 0", Text = $"diarized:{segment.Text}" }) .ToList()); }); using var client = factory.CreateClient(); var wavPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav"); using var response = await client.PostAsJsonAsync("/asr/diarize-file", new { path = wavPath, numSpeakers = 5 }); var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(5, capturedOptions?.NumSpeakers); Assert.Equal(wavPath, body?.Path); var segment = Assert.Single(body?.Segments ?? []); Assert.Equal("Speaker 0", segment.Speaker); Assert.StartsWith("diarized:endpoint-bytes:", segment.Text, StringComparison.Ordinal); } [Fact] public async Task SpeakerIdentityMergeDiagnosticEndpointUsesRecentDaysParameter() { var mergeService = new CapturingSpeakerIdentityMergeService(); await using var factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, configuration) => { configuration.AddInMemoryCollection(new Dictionary { ["MeetingAssistant:FunAsr:Backend:Enabled"] = "false" }); }); builder.ConfigureTestServices(services => { services.RemoveAll(); services.AddSingleton(mergeService); }); }); using var client = factory.CreateClient(); using var response = await client.PostAsJsonAsync( "/diagnostics/speaker-identities/merge", new { recentDays = 7 }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(TimeSpan.FromDays(7), mergeService.RecentIdentityAge); } private static WebApplicationFactory CreateFactory() where TProvider : class, IStreamingTranscriptionProvider { return CreateFactory((_, _, _, _) => Task.FromResult>([])); } private static WebApplicationFactory CreateFactory( Func, SpeechRecognitionPipelineOptions, CancellationToken, Task>> finalize) where TProvider : class, IStreamingTranscriptionProvider { return new WebApplicationFactory() .WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, configuration) => { configuration.AddInMemoryCollection(new Dictionary { ["MeetingAssistant:FunAsr:Backend:Enabled"] = "false" }); }); builder.ConfigureTestServices(services => { services.RemoveAll(); services.AddSingleton( new TestSpeechRecognitionPipelineFactory(finalize)); }); }); } private sealed class EndpointFakeTranscriptionProvider : IStreamingTranscriptionProvider { public async IAsyncEnumerable TranscribeAsync( IAsyncEnumerable audio, SpeechRecognitionPipelineOptions options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { var bytes = 0; await foreach (var chunk in audio.WithCancellation(cancellationToken)) { bytes += chunk.Pcm.Length; } yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", $"endpoint-bytes:{bytes}"); } } private sealed class FailingTranscriptionProvider : IStreamingTranscriptionProvider { public async IAsyncEnumerable TranscribeAsync( IAsyncEnumerable audio, SpeechRecognitionPipelineOptions options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await Task.Yield(); throw new InvalidOperationException("backend unavailable"); #pragma warning disable CS0162 yield break; #pragma warning restore CS0162 } } private sealed class TestSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory where TProvider : class, IStreamingTranscriptionProvider { private readonly Func, SpeechRecognitionPipelineOptions, CancellationToken, Task>> finalize; public TestSpeechRecognitionPipelineFactory( Func, SpeechRecognitionPipelineOptions, CancellationToken, Task>> finalize) { this.finalize = finalize; } public ISpeechRecognitionPipeline Create() { return new TestSpeechRecognitionPipeline( Activator.CreateInstance(), finalize); } } private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline { private readonly Func, SpeechRecognitionPipelineOptions, CancellationToken, Task>> finalize; public TestSpeechRecognitionPipeline( IStreamingTranscriptionProvider provider, Func, SpeechRecognitionPipelineOptions, CancellationToken, Task>> finalize) : base(provider) { this.finalize = finalize; } protected override Task> BuildFinishedTranscriptAsync( string audioPath, IReadOnlyList liveSegments, SpeechRecognitionPipelineOptions options, CancellationToken cancellationToken) { return finalize(audioPath, liveSegments, options, cancellationToken); } } private sealed record AsrDiagnosticResponse(string Path, IReadOnlyList Segments); private sealed record AsrDiagnosticSegment(string Speaker, string Text); private sealed class CapturingSpeakerIdentityMergeService : ISpeakerIdentityMergeService { public TimeSpan? RecentIdentityAge { get; private set; } public Task MergeRecentIdentitiesAsync( TimeSpan? recentIdentityAge, CancellationToken cancellationToken) { RecentIdentityAge = recentIdentityAge; return Task.FromResult(new SpeakerIdentityMergeResult(0, 0, 0, 0)); } } }