using MeetingAssistant.Transcription; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace MeetingAssistant.Tests; public sealed class FunAsrBackendLifecycleTests { [Fact] public async Task LifecycleInstallsStartsAndStopsManagedDockerContainer() { var runner = new CapturingCommandRunner(); var readinessProbe = new CapturingReadinessProbe(); var modelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var lifecycle = new FunAsrDockerBackendLifecycle( runner, readinessProbe, Options.Create(new MeetingAssistantOptions { FunAsr = new FunAsrOptions { Endpoint = "ws://127.0.0.1:10095", Backend = new FunAsrBackendOptions { Enabled = true, Image = "funasr:test", ContainerName = "meeting-assistant-funasr-test", ModelsFolder = modelsFolder, StartupTimeout = TimeSpan.Zero } } }), NullLogger.Instance); await lifecycle.EnsureStartedAsync(CancellationToken.None); await lifecycle.EnsureStartedAsync(CancellationToken.None); await lifecycle.DisposeAsync(); Assert.Equal(new Uri("ws://127.0.0.1:10095"), readinessProbe.Endpoint); Assert.Equal(TimeSpan.Zero, readinessProbe.Timeout); Assert.Collection( runner.Commands, command => { Assert.Equal("docker", command.FileName); Assert.Equal(["pull", "funasr:test"], command.Arguments); }, command => { Assert.Equal(["rm", "-f", "meeting-assistant-funasr-test"], command.Arguments); }, command => { Assert.Equal("docker", command.FileName); Assert.Contains("run", command.Arguments); Assert.Contains("-d", command.Arguments); Assert.Contains("--privileged=true", command.Arguments); Assert.Contains("--name", command.Arguments); Assert.Contains("meeting-assistant-funasr-test", command.Arguments); Assert.Contains("-p", command.Arguments); Assert.Contains("10095:10095", command.Arguments); Assert.Contains($"{modelsFolder}:/workspace/models", command.Arguments); Assert.Contains("funasr:test", command.Arguments); }, command => { Assert.Equal(["stop", "meeting-assistant-funasr-test"], command.Arguments); }); Assert.True(File.Exists(Path.Combine(modelsFolder, "hotwords.txt"))); } [Fact] public async Task DisabledLifecycleDoesNotRunDockerCommands() { var runner = new CapturingCommandRunner(); var lifecycle = new FunAsrDockerBackendLifecycle( runner, new CapturingReadinessProbe(), Options.Create(new MeetingAssistantOptions { FunAsr = new FunAsrOptions { Backend = new FunAsrBackendOptions { Enabled = false } } }), NullLogger.Instance); await lifecycle.EnsureStartedAsync(CancellationToken.None); await lifecycle.DisposeAsync(); Assert.Empty(runner.Commands); } [Fact] public async Task LifecycleFailsWhenDockerCommandTimesOut() { var runner = new HangingCommandRunner(); var lifecycle = new FunAsrDockerBackendLifecycle( runner, new CapturingReadinessProbe(), Options.Create(new MeetingAssistantOptions { FunAsr = new FunAsrOptions { Backend = new FunAsrBackendOptions { Enabled = true, CommandTimeout = TimeSpan.FromMilliseconds(1) } } }), NullLogger.Instance); var exception = await Assert.ThrowsAsync( () => lifecycle.EnsureStartedAsync(CancellationToken.None)); Assert.Contains("timed out", exception.Message); } [Fact] public async Task LifecycleWaitsForWebSocketReadinessBeforeMarkingStarted() { var runner = new CapturingCommandRunner(); var readinessProbe = new CapturingReadinessProbe(); var lifecycle = new FunAsrDockerBackendLifecycle( runner, readinessProbe, Options.Create(new MeetingAssistantOptions { FunAsr = new FunAsrOptions { Endpoint = "ws://127.0.0.1:10095", Backend = new FunAsrBackendOptions { Enabled = true, Image = "funasr:test", ContainerName = "meeting-assistant-funasr-test", ModelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")), StartupTimeout = TimeSpan.FromMinutes(3) } } }), NullLogger.Instance); await lifecycle.EnsureStartedAsync(CancellationToken.None); Assert.Equal(1, readinessProbe.CallCount); Assert.Equal(new Uri("ws://127.0.0.1:10095"), readinessProbe.Endpoint); Assert.Equal(TimeSpan.FromMinutes(3), readinessProbe.Timeout); Assert.Equal(3, runner.Commands.Count); await lifecycle.DisposeAsync(); } private sealed class CapturingCommandRunner : ICommandRunner { public List Commands { get; } = []; public Task RunAsync( string fileName, IReadOnlyList arguments, CancellationToken cancellationToken, IReadOnlyDictionary? environment = null) { Commands.Add(new CapturedCommand(fileName, arguments.ToArray())); return Task.FromResult(new CommandResult(0, "", "")); } } private sealed record CapturedCommand(string FileName, IReadOnlyList Arguments); private sealed class HangingCommandRunner : ICommandRunner { public async Task RunAsync( string fileName, IReadOnlyList arguments, CancellationToken cancellationToken, IReadOnlyDictionary? environment = null) { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return new CommandResult(0, "", ""); } } private sealed class CapturingReadinessProbe : IFunAsrBackendReadinessProbe { public int CallCount { get; private set; } public Uri? Endpoint { get; private set; } public TimeSpan Timeout { get; private set; } public Task WaitUntilReadyAsync( Uri endpoint, TimeSpan timeout, CancellationToken cancellationToken) { CallCount++; Endpoint = endpoint; Timeout = timeout; return Task.CompletedTask; } } }