using Xunit; using ytLive.Models; using ytLive.Services; using ytLive.Services.Compositor; using ytLive.Services.Encoder; namespace ytLive.Tests; /// /// The live frame producer (TASK 4 ship step 5): composites the active scene at /// the tier's FPS and paces frames into the encoder. The integration test drives /// the full lifecycle against fakes — real SceneCompositor + real FramePump, fake /// IFfmpegEncoder — proving the composite frame actually reaches the encoder and /// that stop tears the pump down cleanly. The units pin the failure edges: the /// no-URL skip (the TASK 5 seam), re-entrancy, and encoder death. /// public class FramePumpTests { private sealed class FakeEncoder : IFfmpegEncoder { public readonly List Frames = new(); public int StartCount; public int StopCount; public bool Disposed; public EncoderOptions? LastOptions; public Exception? StartError; public TaskCompletionSource FrameArrived = new(TaskCreationOptions.RunContinuationsAsynchronously); public event EventHandler? HealthUpdated; public event EventHandler? ProcessFailed; public Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default) { StartCount++; LastOptions = options; if (StartError != null) throw StartError; return Task.CompletedTask; } public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default) { Frames.Add(frame); FrameArrived.TrySetResult(); return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken = default) { StopCount++; return Task.CompletedTask; } public void Dispose() => Disposed = true; public void RaiseProcessFailed(string message) => ProcessFailed?.Invoke(this, message); public void RaiseHealth(StreamHealth health) => HealthUpdated?.Invoke(this, health); } private static Scene BackdropScene() { var scene = new Scene { Name = "Live" }; scene.Elements.Add(new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" }); return scene; } private static FramePump NewPump(FakeEncoder encoder, Func? options = null, Func? scene = null, Func? resolve = null, List? log = null, Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null) { return new FramePump( sceneProvider: scene ?? (() => BackdropScene()), frameResolver: resolve ?? (_ => null), compositorOptions: () => new CompositorOptions { SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 64, SourceRectHeight = 48, OutputWidth = 64, OutputHeight = 48, }, encoderOptions: options ?? (() => new EncoderOptions { RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc", Width = 64, Height = 48, Fps = 60, }), encoderFactory: () => encoder, log: log != null ? m => log.Add(m) : null, pacingDelay: async (_, _) => await Task.Yield(), // deterministic: no real waits socialBar: socialBar); } private static void AssertColor(VideoFrame frame, int x, int y, byte r, byte g, byte b) { var i = (y * frame.Width + x) * 4; var br = frame.BgraPixels[i + 2]; var bg = frame.BgraPixels[i + 1]; var bb = frame.BgraPixels[i]; Assert.True( Math.Abs(br - r) <= 2 && Math.Abs(bg - g) <= 2 && Math.Abs(bb - b) <= 2, $"pixel ({x},{y}): expected rgb({r},{g},{b}), got rgb({br},{bg},{bb})"); } [Fact] public async Task Start_CompositesScene_FeedsEncoder_StopsCleanly() { var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0); var encoder = new FakeEncoder(); using var pump = NewPump(encoder, resolve: e => e is Source { IsBackdrop: true } ? red : null); await pump.StartAsync(); Assert.True(pump.IsRunning); Assert.Equal(1, encoder.StartCount); await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.NotEmpty(encoder.Frames); var frame = encoder.Frames[0]; Assert.Equal(64, frame.Width); Assert.Equal(48, frame.Height); AssertColor(frame, 0, 0, 255, 0, 0); // the backdrop really was composited in await pump.StopAsync(); Assert.False(pump.IsRunning); Assert.Equal(1, encoder.StopCount); Assert.True(encoder.Disposed); } [Fact] public async Task Start_WithoutRtmpUrl_SkipsEncoder() { var encoder = new FakeEncoder(); var log = new List(); using var pump = NewPump(encoder, options: () => null, log: log); await pump.StartAsync(); Assert.False(pump.IsRunning); Assert.Equal(0, encoder.StartCount); Assert.Contains(log, m => m.Contains("RTMP")); await pump.StopAsync(); // no-op after a skipped start Assert.Equal(0, encoder.StopCount); } [Fact] public async Task Start_WhileRunning_IsNoop() { var encoder = new FakeEncoder(); using var pump = NewPump(encoder); await pump.StartAsync(); await pump.StartAsync(); Assert.Equal(1, encoder.StartCount); } [Fact] public async Task Stop_WithoutStart_IsNoop() { var encoder = new FakeEncoder(); using var pump = NewPump(encoder); await pump.StopAsync(); Assert.Equal(0, encoder.StopCount); Assert.False(pump.IsRunning); } [Fact] public async Task Start_WithSocialBarSeam_PlacesBarAtTopThenBottomEdge() { var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0); var bar = new VideoFrame(64, 8, new byte[64 * 8 * 4]); Array.Fill(bar.BgraPixels, (byte)255); // opaque white strip var encoder = new FakeEncoder(); var position = SocialBarPosition.Top; using var pump = NewPump(encoder, resolve: e => e is Source { IsBackdrop: true } ? red : null, socialBar: () => (bar, position)); await pump.StartAsync(); await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.NotEmpty(encoder.Frames); AssertColor(encoder.Frames[0], 0, 0, 255, 255, 255); // bar at the top edge // Flip to Bottom: the pump re-reads the seam each frame, so a later frame // lands the bar at the bottom edge (SourceRectHeight - bar height) and the // top corner clears back to backdrop. position = SocialBarPosition.Bottom; VideoFrame? flipped = null; var deadline = DateTime.UtcNow.AddSeconds(5); while (DateTime.UtcNow < deadline && flipped == null) { for (var i = 1; i < encoder.Frames.Count; i++) { var f = encoder.Frames[i]; if (f.BgraPixels[2] == 255 && f.BgraPixels[1] == 0 && f.BgraPixels[0] == 0) { flipped = f; break; } } if (flipped == null) await Task.Delay(10); } Assert.NotNull(flipped); AssertColor(flipped!, 0, 47, 255, 255, 255); // bar sits on the bottom edge await pump.StopAsync(); } [Fact] public async Task Start_EncoderThrows_RaisesFailed_AndDisposes() { var encoder = new FakeEncoder { StartError = new InvalidOperationException("access denied") }; var failed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var pump = NewPump(encoder); pump.Failed += (_, m) => failed.TrySetResult(m); await pump.StartAsync(); Assert.False(pump.IsRunning); var message = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Contains("access denied", message); Assert.True(encoder.Disposed); } [Fact] public async Task ProcessDeath_StopsPump_AndRaisesFailed() { var encoder = new FakeEncoder(); var failed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var pump = NewPump(encoder); pump.Failed += (_, m) => failed.TrySetResult(m); await pump.StartAsync(); encoder.RaiseProcessFailed("FFmpeg exited with code 1"); var message = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Contains("1", message); // The pump self-stops (fire-and-forget); poll the definitive teardown // marker — the encoder's disposal — rather than the IsRunning flag, which // StopAsync clears before the loop has fully drained. var deadline = DateTime.UtcNow.AddSeconds(5); while (!encoder.Disposed && DateTime.UtcNow < deadline) await Task.Delay(10); Assert.True(encoder.Disposed); Assert.False(pump.IsRunning); } [Fact] public async Task HealthUpdated_ForwardsEncoderHealth() { var encoder = new FakeEncoder(); var health = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var pump = NewPump(encoder); pump.HealthUpdated += (_, h) => health.TrySetResult(h); await pump.StartAsync(); encoder.RaiseHealth(new StreamHealth { Status = StreamStatus.Streaming, FPS = 59.9 }); var h = await health.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(StreamStatus.Streaming, h.Status); Assert.Equal(59.9, h.FPS, 1); } }