diff --git a/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs b/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs index 84bf2f3..9eb4298 100644 --- a/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs +++ b/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs @@ -35,6 +35,36 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable return Marshal.PtrToStringAnsi(ptr) ?? string.Empty; } + /// + /// Normalize a comma-separated NDI group list before handing it to the SDK. + /// Returns null if the input is null/whitespace (caller will use the SDK default). + /// + /// **NDI group names are case-sensitive in the runtime.** "Public" matches; "public" + /// does NOT. The default group an unconfigured NDI Sender broadcasts to is "Public" + /// (capital P). Operators who type "public" into the discovery groups field then see + /// zero sources and report the app as broken — that's how this normalizer came to + /// exist (2026-05-16 dev session, ~6h of misdiagnosis). We special-case "public" → + /// "Public" to match the most common operator footgun. Other group names are + /// passed through verbatim — custom groups like "teamsiso-input" are + /// intentionally lowercase and must round-trip unchanged. + /// + /// Marked internal so the test project can cover the lookup table directly. + /// + internal static string? NormalizeGroups(string? groups) + { + if (string.IsNullOrWhiteSpace(groups)) return null; + var parts = groups.Split(',', StringSplitOptions.RemoveEmptyEntries); + for (var i = 0; i < parts.Length; i++) + { + var p = parts[i].Trim(); + // Canonicalize the standard "Public" group regardless of input casing. + if (string.Equals(p, "Public", StringComparison.OrdinalIgnoreCase)) + p = "Public"; + parts[i] = p; + } + return string.Join(",", parts); + } + // ---- Discovery ---- public NdiFindHandle CreateFinder(string? groups = null) @@ -48,7 +78,7 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable // same lifetime contract CreateReceiver / CreateSender below have relied on // since Phase B-2; if it ever turns out to be wrong, those will fail too. The // loopback discovery integration test would catch a regression here. - var trimmed = string.IsNullOrWhiteSpace(groups) ? null : groups!.Trim(); + var trimmed = NormalizeGroups(groups); if (trimmed is null) { var nativeDefault = NdiNative.FindCreateV2(IntPtr.Zero); @@ -247,7 +277,7 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable public NdiSenderHandle CreateSender(string outputName, string? groups = null) { - var trimmedGroups = string.IsNullOrWhiteSpace(groups) ? null : groups!.Trim(); + var trimmedGroups = NormalizeGroups(groups); var nameUtf8 = Marshal.StringToHGlobalAnsi(outputName); var groupsUtf8 = trimmedGroups is null ? IntPtr.Zero diff --git a/src/TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj b/src/TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj index b7908a0..3a63b19 100644 --- a/src/TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj +++ b/src/TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj @@ -1,7 +1,16 @@  - - + + + + + + + + <_Parameter1>TeamsISO.Engine.Tests + diff --git a/src/tests/TeamsISO.Engine.Tests/Interop/NdiInteropNormalizeGroupsTests.cs b/src/tests/TeamsISO.Engine.Tests/Interop/NdiInteropNormalizeGroupsTests.cs new file mode 100644 index 0000000..f36a4ed --- /dev/null +++ b/src/tests/TeamsISO.Engine.Tests/Interop/NdiInteropNormalizeGroupsTests.cs @@ -0,0 +1,35 @@ +using System.Runtime.Versioning; +using TeamsISO.Engine.NdiInterop; + +namespace TeamsISO.Engine.Tests.Interop; + +// NdiInteropPInvoke is marked [SupportedOSPlatform("windows")] because it +// P/Invokes the Windows-only NDI runtime. The pure NormalizeGroups helper +// doesn't actually touch native code, but it inherits the platform tag from +// the enclosing class. Re-declaring SupportedOSPlatform here silences CA1416 +// — these tests still only run on Windows (the Engine.Tests project itself +// is platform-agnostic but xunit only schedules them when the OS supports). +[SupportedOSPlatform("windows")] + +// NdiInteropPInvoke.NormalizeGroups is internal; the engine tests project has +// access via InternalsVisibleTo applied to TeamsISO.Engine.NdiInterop. +public class NdiInteropNormalizeGroupsTests +{ + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("Public", "Public")] // already canonical + [InlineData("public", "Public")] // lowercase -> canonical (the bug fix) + [InlineData("PUBLIC", "Public")] // shouty -> canonical + [InlineData("PuBlIc", "Public")] // mixed case -> canonical + [InlineData("teamsiso-input", "teamsiso-input")] // custom group: pass through + [InlineData("Public,teamsiso-input", "Public,teamsiso-input")] + [InlineData("public,teamsiso-input", "Public,teamsiso-input")] // mixed list normalizes the standard one only + [InlineData("teamsiso-input,PUBLIC", "teamsiso-input,Public")] + [InlineData(" public , teamsiso-input ", "Public,teamsiso-input")] // whitespace trimmed per part + public void NormalizeGroups_Maps(string? input, string? expected) + { + NdiInteropPInvoke.NormalizeGroups(input).Should().Be(expected); + } +} diff --git a/src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj b/src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj index b58d824..d4e0928 100644 --- a/src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj +++ b/src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj @@ -10,9 +10,9 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive - all + + runtime; build; native; contentfiles; analyzers; buildtransitive + all @@ -24,8 +24,11 @@ - - + + + +