using System.Diagnostics; using System.IO; using Xunit; using ytLive.Services; using ytLive.Services.Encoder; namespace ytLive.Tests; /// /// TASK 21: the media decoder path. is pure /// (synthetic bytes, no ffmpeg); the one integration test drives the real /// source loop against a fake decoder process. /// public class MediaVideoSourceTests { // ─── Pure reader (unit) ─── [Fact] public void Reader_EmitsOneFramePerFrameBytes() { var w = 2; var h = 2; var reader = new RawVideoFrameReader(w, h); var frameBytes = new byte[w * h * 4]; var frames = reader.Feed(frameBytes); Assert.Single(frames); Assert.Equal(w, frames[0].Width); Assert.Equal(h, frames[0].Height); } [Fact] public void Reader_SplitsMultipleFramesFromOneFeed() { var reader = new RawVideoFrameReader(1, 1); // 4 bytes each var frames = reader.Feed(new byte[12]); // 3 frames Assert.Equal(3, frames.Count); } [Fact] public void Reader_KeepsPartialBytesAcrossFeeds() { var reader = new RawVideoFrameReader(1, 1); Assert.Empty(reader.Feed(new byte[2])); // half a frame Assert.Empty(reader.Feed(new byte[1])); // 3/4 var frames = reader.Feed(new byte[1]); // completes the frame Assert.Single(frames); Assert.Empty(reader.Feed(new byte[0])); // no leftover } // ─── The one integration test: source loop against a fake decode process ─── [Fact] public async Task MediaVideoSource_EmitsFramesInOrderFromFakeDecoder() { const int w = 2, h = 1; // frame bytes = 8 var frameA = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }; var frameB = new byte[] { 9, 10, 11, 12, 13, 14, 15, 16 }; var payload = new byte[frameA.Length + frameB.Length]; Buffer.BlockCopy(frameA, 0, payload, 0, frameA.Length); Buffer.BlockCopy(frameB, 0, payload, frameA.Length, frameB.Length); using var source = new MediaVideoSource( "clip.mp4", w, h, new FakeLocator(), () => new FakeDecodeProcess(payload)); var seen = new List(); var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); source.FrameAvailable += f => seen.Add(((byte[])f.BgraPixels.Clone())); source.Completed += () => completed.TrySetResult(true); await source.StartAsync(); await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(2, seen.Count); Assert.Equal(frameA, seen[0]); Assert.Equal(frameB, seen[1]); } private sealed class FakeDecodeProcess : IDecodeProcess { private readonly MemoryStream _stream; public FakeDecodeProcess(byte[] bytes) => _stream = new MemoryStream(bytes); public void Start(ProcessStartInfo startInfo) { } public Stream StandardOutput => _stream; public bool HasExited => _stream.Position >= _stream.Length; public int ExitCode => 0; public void Kill() { } public Task WaitForExitAsync(CancellationToken ct = default) => Task.CompletedTask; public void Dispose() => _stream.Dispose(); } [Fact] public async Task MediaVideoSource_PacesFramesByProbedFps() { const int w = 2, h = 1; // frame bytes = 8 var payload = new byte[16]; // two frames var delays = new List(); using var source = new MediaVideoSource( "clip.mp4", w, h, new FakeLocator(), () => new FakeDecodeProcess(payload), frameRateProbe: new FakeFrameRateProbe(1000.0), delay: (_d, _) => { delays.Add(_d); return Task.CompletedTask; }); var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); source.Completed += () => completed.TrySetResult(true); await source.StartAsync(); await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); // One pacing delay per emitted frame, each = 1/1000s. Assert.Equal(2, delays.Count); foreach (var d in delays) Assert.True(Math.Abs((d - TimeSpan.FromMilliseconds(1)).TotalMilliseconds) < 0.001, $"expected ~1ms, got {d.TotalMilliseconds}ms"); } [Fact] public async Task MediaVideoSource_LoopsUntilLoopDisabled() { const int w = 1, h = 1; // frame bytes = 4 var payload = new byte[4]; var passCount = 0; var frames = 0; var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var source = new MediaVideoSource( "clip.mp4", w, h, new FakeLocator(), () => { passCount++; return new FakeDecodeProcess(payload); }); source.Looping = true; source.Completed += () => completed.TrySetResult(true); source.FrameAvailable += _ => { frames++; if (frames >= 3) source.Looping = false; }; await source.StartAsync(); await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); await source.StopAsync(); // The single frame re-emits across passes (restart) and Completed only // fires once Looping is cleared. Assert.True(frames >= 3, $"expected the single frame to re-emit across passes, got {frames}"); Assert.True(passCount >= 3, "Looping should have restarted the decode process"); } private sealed class FakeFrameRateProbe : IFrameRateProbe { private readonly double? _fps; public FakeFrameRateProbe(double? fps) => _fps = fps; public Task ProbeAsync(string path, CancellationToken ct = default) => Task.FromResult(_fps); } private sealed class FakeLocator : IFfmpegLocator { public Task LocateAsync(CancellationToken ct = default) => Task.FromResult("ffmpeg.exe"); } }