test(ndi): end-to-end pipeline round-trip with framerate normalization

Synthesizes a 640x360 cyan BGRA source named like a Teams participant, runs the production IsoPipeline against it (NdiReceiver -> FrameProcessor -> ManagedNearestNeighborFrameScaler -> NdiSender, all backed by NdiInteropPInvoke), connects a receiver to the resulting TEAMSISO_* output, and asserts that captured frames come back at the configured 1920x1080 target. Closes the loop on the receive/scale/emit chain that was previously only unit-tested in isolation against fakes.

If this test ever goes red we have a regression in something that actually matters: the runtime resolver, the parser-free pipeline construction path, the frame channel back-pressure, the scaler's pillarbox math, the sender clock, or the receiver-from-loopback path. Pinned at requires=ndi so default CI skips it; runs locally in ~780ms.
This commit is contained in:
Zac Gaetano 2026-05-08 00:39:23 -04:00
parent dae8f35db9
commit 9891f2444d

View file

@ -0,0 +1,145 @@
using System.Runtime.Versioning;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using TeamsISO.Engine.Domain;
using TeamsISO.Engine.NdiInterop;
using TeamsISO.Engine.Pipeline;
namespace TeamsISO.Engine.IntegrationTests;
/// <summary>
/// End-to-end pipeline test: synthesize a fake Teams source, run an
/// <see cref="IsoPipeline"/> against it, and assert the resulting normalized
/// output stream yields frames at the configured 1080p target size. This is
/// the closest unit-test analog to "did the engine actually do its job?"
/// — exercises NdiReceiver, FrameProcessor, ManagedNearestNeighborFrameScaler,
/// and NdiSender as a single chain through the production P/Invoke shim.
///
/// Marked [Trait("requires","ndi")] so default CI skips it; run locally with
/// dotnet test --filter requires=ndi
/// </summary>
[SupportedOSPlatform("windows")]
public class PipelineFrameRoundTripTests
{
[Fact]
[Trait("requires", "ndi")]
public async Task Pipeline_ReceivesFromTeamsLikeSource_AndEmitsAt1080pTarget()
{
// ─── 1. Source pump ─────────────────────────────────────────────────
// Spin up an NDI sender broadcasting an unambiguous, unique source
// name. We use the literal "Teams - <token>" form so this works even
// if a parallel test happens to be running at the same time.
var token = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
var sourceShortName = $"Teams - FrameRT_{token}";
var outputName = $"TEAMSISO_RT_{token}";
using var interop = new NdiInteropPInvoke(NullLogger<NdiInteropPInvoke>.Instance);
using var fakeSender = interop.CreateSender(sourceShortName);
// Synthesize 640x360 BGRA frames in cyan so the receive side has
// something obviously-not-zero to pick up. Pump at ~30 fps; the engine
// is configured for 59.94 fps target so it'll either re-emit our
// frames (slate threshold not yet reached) or interpolate via the
// last-frame re-emit path — either is fine for the dimensions check.
const int srcW = 640, srcH = 360;
var pixelBuffer = new byte[srcW * srcH * 4];
for (var i = 0; i < pixelBuffer.Length; i += 4)
{
pixelBuffer[i + 0] = 0xF0; // B
pixelBuffer[i + 1] = 0xED; // G
pixelBuffer[i + 2] = 0x97; // R → ~Wild Dragon cyan #97EDF0 in BGRA
pixelBuffer[i + 3] = 0xFF; // A
}
using var pumpCts = new CancellationTokenSource();
var pumpTask = Task.Run(() =>
{
var pixelMemory = new ReadOnlyMemory<byte>(pixelBuffer);
while (!pumpCts.IsCancellationRequested)
{
var frame = new ProcessedFrame(srcW, srcH, DateTime.UtcNow.Ticks, pixelMemory, PixelFormat.Bgra);
try { interop.SendFrame(fakeSender, frame); }
catch { /* sender may be torn down during cancellation */ }
Thread.Sleep(33); // ~30 fps source
}
}, pumpCts.Token);
try
{
// ─── 2. Wait for our fake source to be discoverable ─────────────
using var finder = interop.CreateFinder();
var fullSourceName = await WaitForSourceAsync(interop, finder, sourceShortName, TimeSpan.FromSeconds(5));
fullSourceName.Should().NotBeNullOrEmpty(
because: "fake Teams sender should be visible to a same-process finder within 5s");
// ─── 3. Build the production IsoPipeline against it ────────────
var settings = FrameProcessingSettings.Default; // 1080p, 59.94 fps, Pillarbox
var clock = new PeriodicTimerFrameClock(settings.FramerateHz);
var scaler = new ManagedNearestNeighborFrameScaler();
var config = new IsoPipelineConfig(Guid.NewGuid(), fullSourceName!, outputName, settings);
await using var pipeline = new IsoPipeline(
config, interop, scaler, clock,
ExponentialBackoff.Default,
(delay, ct) => Task.Delay(delay, ct),
NullLoggerFactory.Instance);
await pipeline.StartAsync();
// ─── 4. Wait for the pipeline's output sender to appear ────────
var outputFullName = await WaitForSourceAsync(interop, finder, outputName, TimeSpan.FromSeconds(5));
outputFullName.Should().NotBeNullOrEmpty(
because: "IsoPipeline must broadcast a TEAMSISO_* sender within 5s of StartAsync");
// ─── 5. Receive a frame from the normalized output ─────────────
using var receiver = interop.CreateReceiver(outputFullName!);
RawFrame? captured = null;
var captureDeadline = DateTime.UtcNow.AddSeconds(8);
while (DateTime.UtcNow < captureDeadline)
{
captured = interop.CaptureFrame(receiver, 1000);
if (captured is not null) break;
await Task.Delay(50);
}
captured.Should().NotBeNull(
because: "the pipeline output must yield at least one normalized frame within 8s");
// ─── 6. Assert the normalized dimensions match the target ──────
var (expectedW, expectedH) = settings.ResolutionSize;
captured!.Width.Should().Be(expectedW,
because: $"the pipeline normalizes to {expectedW}x{expectedH} per FrameProcessingSettings.Default");
captured.Height.Should().Be(expectedH);
// ─── 7. Stop pipeline cleanly ──────────────────────────────────
await pipeline.StopAsync();
}
finally
{
pumpCts.Cancel();
try { await pumpTask; }
catch (OperationCanceledException) { /* expected */ }
}
}
/// <summary>
/// Polls the finder until a source whose name contains <paramref name="needle"/>
/// appears, or the timeout elapses. Returns the full NDI name (machine + paren
/// short-name) on success, or null on timeout.
/// </summary>
private static async Task<string?> WaitForSourceAsync(
NdiInteropPInvoke interop,
TeamsISO.Engine.Interop.NdiFindHandle finder,
string needle,
TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
var sources = interop.GetCurrentSources(finder);
var match = sources.FirstOrDefault(s => s.Contains(needle, StringComparison.Ordinal));
if (match is not null) return match;
await Task.Delay(150);
}
return null;
}
}