teamsiso/src/tests/TeamsISO.Engine.Tests/Controller/IsoControllerTests.cs
Zac Gaetano 909237f454 feat(ndi): plumb NDI groups (discovery + output) through the engine
Adds an NdiGroupSettings record carrying optional comma-separated NDI group lists for the finder and the senders. Extends INdiInterop.CreateFinder / CreateSender with optional groups arguments and populates NDIlib_find_create_t.p_groups and NDIlib_send_create_t.p_groups via P/Invoke. IsoController reads the settings on construction, threads DiscoveryGroups into NdiDiscoveryService and OutputGroups into IsoPipelineConfig, and exposes SetGroupSettingsAsync for runtime updates (group changes apply on next process restart so live pipelines aren't orphaned).

This unblocks the 'transcoder' topology where Teams broadcasts NDI on a private group (e.g. teamsiso-input) and TeamsISO re-emits clean normalized streams on Public — keeping raw, wrong-framerate Teams sources off the production network.

EngineConfig schema is JSON-back-compat: existing config.json files (no NdiGroups field) deserialize with NdiGroups=null and load as NdiGroupSettings.Default. UI surface for these settings comes in a follow-up.

Tests: 72/72 passing (was 69) — added IsoController coverage that group settings are read from ConfigStore on startup, passed to the finder, threaded into per-pipeline config, and round-trip through SetGroupSettingsAsync/Save/Load.
2026-05-07 23:48:49 -04:00

193 lines
7.4 KiB
C#

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<IsoPipelineConfig> _factoryCalls = new();
private readonly Dictionary<Guid, TaskCompletionSource> _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<ConfigStore>.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<IReadOnlyList<Participant>>();
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<IsoAssignment>(),
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<IsoAssignment>(),
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<EngineAlert>();
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<Guid> WaitForFirstParticipantAsync(IsoController controller)
{
var tcs = new TaskCompletionSource<Guid>();
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));
}
}