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:
parent
dae8f35db9
commit
9891f2444d
1 changed files with 145 additions and 0 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue