Files

267 lines
9.7 KiB
C#

using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// 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.
/// </summary>
public class FramePumpTests
{
private sealed class FakeEncoder : IFfmpegEncoder
{
public readonly List<VideoFrame> 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<StreamHealth>? HealthUpdated;
public event EventHandler<string>? 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<EncoderOptions?>? options = null,
Func<Scene?>? scene = null, Func<SceneElement, VideoFrame?>? resolve = null,
List<string>? 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<string>();
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<string>(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<string>(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<StreamHealth>(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);
}
}