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; /// /// End-to-end pipeline test: synthesize a fake Teams source, run an /// 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 /// [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 - " 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.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(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 */ } } } /// /// Polls the finder until a source whose name contains /// appears, or the timeout elapses. Returns the full NDI name (machine + paren /// short-name) on success, or null on timeout. /// private static async Task 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; } }