diff --git a/src/tests/TeamsISO.Engine.IntegrationTests/PipelineFrameRoundTripTests.cs b/src/tests/TeamsISO.Engine.IntegrationTests/PipelineFrameRoundTripTests.cs
new file mode 100644
index 0000000..0f8c84e
--- /dev/null
+++ b/src/tests/TeamsISO.Engine.IntegrationTests/PipelineFrameRoundTripTests.cs
@@ -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;
+
+///
+/// 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;
+ }
+}