From 0b24fbb529fa7755c9ac701c9bd0d1ad4ccec94b Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Fri, 8 May 2026 00:11:01 -0400 Subject: [PATCH] test(ndi): seed requires=ndi integration tests against real NDI runtime Replaces the previously-skipped placeholder with 8 integration tests that exercise the production P/Invoke shim against the installed NDI 6 runtime: runtime version probe + prefix assertion (catches future SDK rebrandings), finder lifecycle on default + custom groups (incl. whitespace tolerance + multi-group), sender lifecycle on default + custom groups, and a loopback-discovery test that creates a uniquely-named sender and asserts a same-process finder sees it within 5 s. All marked [Trait('requires', 'ndi')] so the existing CI filter (Category!=ndi&requires!=ndi) excludes them. Run locally with: dotnet test --filter requires=ndi. Today: 8/8 pass against NDI 6.2 on Windows 11. --- .../IntegrationTestsScaffold.cs | 121 +++++++++++++++++- 1 file changed, 117 insertions(+), 4 deletions(-) diff --git a/src/tests/TeamsISO.Engine.IntegrationTests/IntegrationTestsScaffold.cs b/src/tests/TeamsISO.Engine.IntegrationTests/IntegrationTestsScaffold.cs index 6d33330..05ebdf6 100644 --- a/src/tests/TeamsISO.Engine.IntegrationTests/IntegrationTestsScaffold.cs +++ b/src/tests/TeamsISO.Engine.IntegrationTests/IntegrationTestsScaffold.cs @@ -1,11 +1,124 @@ +using System.Runtime.Versioning; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using TeamsISO.Engine.Interop; +using TeamsISO.Engine.NdiInterop; + namespace TeamsISO.Engine.IntegrationTests; -public class IntegrationTestsScaffold +/// +/// Integration tests that talk to a real NDI runtime — gated behind +/// requires=ndi so the default CI run skips them. Run locally with: +/// dotnet test --filter requires=ndi +/// +/// Pre-conditions: +/// - Windows host +/// - NDI 6 Runtime installed (NDI_RUNTIME_DIR_V6 set) +/// - Network discovery permitted on the loopback / local subnet +/// +[SupportedOSPlatform("windows")] +public class NdiInteropIntegrationTests { - [Fact(Skip = "Phase A: integration tests require NDI runtime — added in Phase B.")] + private static NdiInteropPInvoke NewInterop() => + new(NullLogger.Instance); + + [Fact] [Trait("requires", "ndi")] - public void ScaffoldFactSkipsCleanly() + public void NdiRuntime_LoadsAndReportsVersion() { - Assert.True(true); + using var interop = NewInterop(); + + var version = interop.GetRuntimeVersion(); + + version.Should().NotBeNullOrEmpty(); + version.Should().StartWith(NdiVersion.ExpectedRuntimeVersionPrefix, + because: "the engine probe asserts this prefix; if it ever drifts CI must catch it"); + } + + [Fact] + [Trait("requires", "ndi")] + public void Finder_DefaultGroups_CreatesAndDisposesCleanly() + { + using var interop = NewInterop(); + + using (var finder = interop.CreateFinder()) + { + finder.Should().NotBeNull(because: "default-group finder must construct successfully"); + // Snapshot any visible sources — exercises the path; we don't assert on count + // because the test environment's NDI sources are unknowable. + _ = interop.GetCurrentSources(finder); + } + } + + [Theory] + [Trait("requires", "ndi")] + [InlineData("teamsiso-test-input")] + [InlineData("teamsiso-test-input,production")] + [InlineData(" teamsiso-test-input ")] + public void Finder_CustomGroups_DoesNotThrow(string groups) + { + using var interop = NewInterop(); + + var act = () => + { + using var finder = interop.CreateFinder(groups); + _ = interop.GetCurrentSources(finder); + }; + + act.Should().NotThrow(because: $"groups='{groups}' must round-trip into NDIlib_find_create_v2 cleanly"); + } + + [Fact] + [Trait("requires", "ndi")] + public void Sender_DefaultGroups_CreatesAndDisposesCleanly() + { + using var interop = NewInterop(); + + using (var sender = interop.CreateSender("TEAMSISO_TEST_DEFAULT")) + { + sender.Should().NotBeNull(); + } + } + + [Fact] + [Trait("requires", "ndi")] + public void Sender_CustomGroups_CreatesAndDisposesCleanly() + { + using var interop = NewInterop(); + + using (var sender = interop.CreateSender("TEAMSISO_TEST_GROUPED", "teamsiso-test-output")) + { + sender.Should().NotBeNull(); + } + } + + [Fact] + [Trait("requires", "ndi")] + public async Task LoopbackDiscovery_FindsOurOwnSenderWithinFiveSeconds() + { + // End-to-end check: a sender we create on the local machine must be visible + // to a finder running in the same process, within a reasonable window. Catches + // network-layer regressions (firewall, mDNS, multicast disable). + using var interop = NewInterop(); + + var uniqueName = $"TEAMSISO_LOOP_{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}"; + using var sender = interop.CreateSender(uniqueName); + using var finder = interop.CreateFinder(); + + var deadline = DateTime.UtcNow.AddSeconds(5); + bool found = false; + while (DateTime.UtcNow < deadline) + { + var sources = interop.GetCurrentSources(finder); + if (sources.Any(s => s.Contains(uniqueName, StringComparison.Ordinal))) + { + found = true; + break; + } + await Task.Delay(250); + } + + found.Should().BeTrue( + because: $"local sender '{uniqueName}' must be discovered by a same-process finder within 5s"); } }