267 lines
9.7 KiB
C#
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);
|
|
}
|
|
}
|