using System.Reactive.Linq; using Microsoft.Extensions.Logging.Abstractions; using TeamsISO.Engine.Controller; using TeamsISO.Engine.Domain; using TeamsISO.Engine.Interop; using TeamsISO.Engine.Persistence; using TeamsISO.Engine.Pipeline; using TeamsISO.Engine.Tests.Fakes; namespace TeamsISO.Engine.Tests.Controller; public class IsoControllerTests : IDisposable { private readonly string _dir; private readonly ConfigStore _store; private readonly FakeNdiInterop _interop; private readonly NdiRuntimeProbe _probeMatch; private readonly NdiRuntimeProbe _probeMismatch; private readonly List _factoryCalls = new(); private readonly Dictionary _pipelineBlockers = new(); public IsoControllerTests() { _dir = Path.Combine(Path.GetTempPath(), $"teamsiso-controller-{Guid.NewGuid():N}"); Directory.CreateDirectory(_dir); _store = new ConfigStore(Path.Combine(_dir, "config.json"), NullLogger.Instance); _interop = new FakeNdiInterop { RuntimeVersion = "6.0.0" }; _probeMatch = new NdiRuntimeProbe(_interop, expectedVersion: "6.0.0"); _probeMismatch = new NdiRuntimeProbe(_interop, expectedVersion: "9.9.9"); } public void Dispose() => Directory.Delete(_dir, recursive: true); private IsoPipeline TestPipelineFactory(IsoPipelineConfig config) { _factoryCalls.Add(config); var blocker = new TaskCompletionSource(); _pipelineBlockers[config.ParticipantId] = blocker; var backoff = new ExponentialBackoff(maxAttempts: 3, initial: TimeSpan.FromMilliseconds(1), cap: TimeSpan.FromMilliseconds(5)); return new IsoPipeline( config.ParticipantId, ct => blocker.Task.WaitAsync(ct), backoff, (_, _) => Task.CompletedTask, NullLoggerFactory.Instance); } private IsoController NewController(NdiRuntimeProbe? probe = null) => new( _interop, TestPipelineFactory, _store, probe ?? _probeMatch, NullLoggerFactory.Instance, renameWindow: TimeSpan.FromSeconds(5), discoveryInterval: TimeSpan.FromMilliseconds(20)); [Fact] public async Task DiscoveredParticipant_AppearsInParticipantsObservable() { await using var controller = NewController(); var seenLists = new List>(); using var sub = controller.Participants.Subscribe(p => seenLists.Add(p)); await controller.StartAsync(CancellationToken.None); _interop.Sources.Add("PC1 (Teams - Jane)"); // Wait for discovery to pick it up var deadline = DateTime.UtcNow.AddSeconds(2); while (seenLists.LastOrDefault()?.Any() != true && DateTime.UtcNow < deadline) await Task.Delay(20); seenLists.Last().Should().HaveCount(1); seenLists.Last()[0].DisplayName.Should().Be("Jane"); } [Fact] public async Task EnableIsoAsync_CreatesAndStartsPipeline() { await using var controller = NewController(); await controller.StartAsync(CancellationToken.None); _interop.Sources.Add("PC1 (Teams - Jane)"); var pid = await WaitForFirstParticipantAsync(controller); await controller.EnableIsoAsync(pid, customName: "TEAMSISO_JANE", CancellationToken.None); _factoryCalls.Should().HaveCount(1); _factoryCalls[0].OutputName.Should().Be("TEAMSISO_JANE"); _factoryCalls[0].SourceName.Should().Be("PC1 (Teams - Jane)"); } [Fact] public async Task DisableIsoAsync_StopsPipeline_AndRemovesIt() { await using var controller = NewController(); await controller.StartAsync(CancellationToken.None); _interop.Sources.Add("PC1 (Teams - Jane)"); var pid = await WaitForFirstParticipantAsync(controller); await controller.EnableIsoAsync(pid, customName: null, CancellationToken.None); // Unblock the pipeline so StopAsync can complete _pipelineBlockers[pid].SetResult(); await controller.DisableIsoAsync(pid, CancellationToken.None); // Re-enable should now create a new pipeline await controller.EnableIsoAsync(pid, customName: null, CancellationToken.None); _factoryCalls.Should().HaveCount(2); } [Fact] public async Task SetGlobalSettingsAsync_PersistsToConfigStore() { await using var controller = NewController(); var newSettings = new FrameProcessingSettings( TargetFramerate.Fps59_94, TargetResolution.R1080p, AspectMode.Pillarbox, AudioMode.Auto); await controller.SetGlobalSettingsAsync(newSettings, CancellationToken.None); controller.GlobalSettings.Should().Be(newSettings); _store.Load().Global.Should().Be(newSettings); } [Fact] public async Task Constructor_PassesDiscoveryGroupsToFinder() { // Pre-populate the config store with a discovery-groups setting; the controller // reads it on construction and feeds it into the finder. _store.Save(new EngineConfig( FrameProcessingSettings.Default, Array.Empty(), new NdiGroupSettings(DiscoveryGroups: "teamsiso-input", OutputGroups: null))); await using var controller = NewController(); _interop.LastFinderGroups.Should().Be("teamsiso-input"); controller.GroupSettings.DiscoveryGroups.Should().Be("teamsiso-input"); } [Fact] public async Task EnableIsoAsync_PassesOutputGroupsToPipelineConfig() { _store.Save(new EngineConfig( FrameProcessingSettings.Default, Array.Empty(), new NdiGroupSettings(DiscoveryGroups: null, OutputGroups: "Public,producers"))); await using var controller = NewController(); await controller.StartAsync(CancellationToken.None); _interop.Sources.Add("PC1 (Teams - Jane)"); var pid = await WaitForFirstParticipantAsync(controller); await controller.EnableIsoAsync(pid, customName: null, CancellationToken.None); _factoryCalls.Should().HaveCount(1); _factoryCalls[0].OutputGroups.Should().Be("Public,producers"); } [Fact] public async Task SetGroupSettingsAsync_PersistsToConfigStore() { await using var controller = NewController(); var newGroups = new NdiGroupSettings(DiscoveryGroups: "teamsiso-input", OutputGroups: "Public"); await controller.SetGroupSettingsAsync(newGroups, CancellationToken.None); controller.GroupSettings.Should().Be(newGroups); _store.Load().GroupsOrDefault.Should().Be(newGroups); } [Fact] public async Task StartAsync_RuntimeProbeMismatch_RaisesAlert() { await using var controller = NewController(probe: _probeMismatch); var alerts = new List(); using var sub = controller.Alerts.Subscribe(alerts.Add); await controller.StartAsync(CancellationToken.None); alerts.Should().Contain(a => a is EngineAlert.NdiRuntimeMismatch); } private static async Task WaitForFirstParticipantAsync(IsoController controller) { var tcs = new TaskCompletionSource(); using var sub = controller.Participants .Where(p => p.Count > 0) .Subscribe(p => tcs.TrySetResult(p[0].Id)); return await tcs.Task.WaitAsync(TimeSpan.FromSeconds(3)); } }