Files
meeting-assistant/MeetingAssistant.Tests/AsrDiagnosticEndpointTests.cs
2026-06-03 07:17:00 +02:00

271 lines
12 KiB
C#

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<EndpointFakeTranscriptionProvider>();
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<AsrDiagnosticResponse>();
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 NamedProfileEndpointPassesProfileToConfiguredProvider()
{
var pipelineFactory = new CapturingProfileSpeechRecognitionPipelineFactory();
await using var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+L",
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US"
});
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<ISpeechRecognitionPipelineFactory>();
services.AddSingleton<ISpeechRecognitionPipelineFactory>(pipelineFactory);
});
});
using var client = factory.CreateClient();
var wavPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav");
using var response = await client.PostAsJsonAsync("/profiles/english/asr/transcribe-file", new { path = wavPath });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("english", pipelineFactory.LastProfileName);
}
[Fact]
public async Task EndpointReportsProviderFailuresAsBadGateway()
{
await using var factory = CreateFactory<FailingTranscriptionProvider>();
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<EndpointFakeTranscriptionProvider>(
(audioPath, liveSegments, options, cancellationToken) =>
{
capturedOptions = options;
return Task.FromResult<IReadOnlyList<TranscriptionSegment>>(
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<AsrDiagnosticResponse>();
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<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false"
});
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<ISpeakerIdentityMergeService>();
services.AddSingleton<ISpeakerIdentityMergeService>(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<Program> CreateFactory<TProvider>()
where TProvider : class, IStreamingTranscriptionProvider
{
return CreateFactory<TProvider>((_, _, _, _) => Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]));
}
private static WebApplicationFactory<Program> CreateFactory<TProvider>(
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize)
where TProvider : class, IStreamingTranscriptionProvider
{
return new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false"
});
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<ISpeechRecognitionPipelineFactory>();
services.AddSingleton<ISpeechRecognitionPipelineFactory>(
new TestSpeechRecognitionPipelineFactory<TProvider>(finalize));
});
});
}
private sealed class EndpointFakeTranscriptionProvider : IStreamingTranscriptionProvider
{
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> 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<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> 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<TProvider> : ISpeechRecognitionPipelineFactory
where TProvider : class, IStreamingTranscriptionProvider
{
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
public TestSpeechRecognitionPipelineFactory(
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize)
{
this.finalize = finalize;
}
public ISpeechRecognitionPipeline Create()
{
return new TestSpeechRecognitionPipeline(
Activator.CreateInstance<TProvider>(),
finalize);
}
}
private sealed class CapturingProfileSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
{
public string? LastProfileName { get; private set; }
public ISpeechRecognitionPipeline Create()
{
return Create(null);
}
public ISpeechRecognitionPipeline Create(string? launchProfileName)
{
LastProfileName = launchProfileName;
return new TestSpeechRecognitionPipeline(
new EndpointFakeTranscriptionProvider(),
(_, liveSegments, _, _) => Task.FromResult(liveSegments));
}
}
private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
{
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
public TestSpeechRecognitionPipeline(
IStreamingTranscriptionProvider provider,
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize)
: base(provider)
{
this.finalize = finalize;
}
protected override Task<IReadOnlyList<TranscriptionSegment>> BuildFinishedTranscriptAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> liveSegments,
SpeechRecognitionPipelineOptions options,
CancellationToken cancellationToken)
{
return finalize(audioPath, liveSegments, options, cancellationToken);
}
}
private sealed record AsrDiagnosticResponse(string Path, IReadOnlyList<AsrDiagnosticSegment> Segments);
private sealed record AsrDiagnosticSegment(string Speaker, string Text);
private sealed class CapturingSpeakerIdentityMergeService : ISpeakerIdentityMergeService
{
public TimeSpan? RecentIdentityAge { get; private set; }
public Task<SpeakerIdentityMergeResult> MergeRecentIdentitiesAsync(
TimeSpan? recentIdentityAge,
CancellationToken cancellationToken)
{
RecentIdentityAge = recentIdentityAge;
return Task.FromResult(new SpeakerIdentityMergeResult(0, 0, 0, 0));
}
}
}