using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using System.Windows.Media.Imaging; using System.Windows.Threading; using Xunit; using ytLive.Models; using ytLive.Services; using ytLive.Services.BackgroundEffects; namespace ytLive.Tests; public class CameraManagerTests { private sealed class FakeFrameSource : ICameraFrameSource { private readonly List _started; private readonly List _stopped; private readonly VideoFrame? _pumpOnStart; private readonly string? _failOnStart; public string DeviceId { get; } public event Action? FrameAvailable; public event Action? SourceFailed; public FakeFrameSource(string deviceId, List started, List stopped, VideoFrame? pumpOnStart = null, string? failOnStart = null) { DeviceId = deviceId; _started = started; _stopped = stopped; _pumpOnStart = pumpOnStart; _failOnStart = failOnStart; } public Task StartAsync() { if (_failOnStart != null) throw new InvalidOperationException(_failOnStart); _started.Add(DeviceId); if (_pumpOnStart != null) FrameAvailable?.Invoke(_pumpOnStart); return Task.CompletedTask; } public Task StopAsync() { _stopped.Add(DeviceId); return Task.CompletedTask; } public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame); public void Fail(string message) => SourceFailed?.Invoke(message); } // The refcount/coalescing tests model a camera whose frame pump is driven by // the test after acquire, so they skip the first-frame proof (TimeSpan.Zero); // the proof itself is exercised by the dedicated tests below. private static CameraManager CreateManager(List started, List stopped) => new(new FakeEnumerator(), id => new FakeFrameSource(id, started, stopped), null, TimeSpan.Zero); private sealed class FakeEnumerator : ICameraEnumerator { public Task> GetCamerasAsync() => Task.FromResult>(new[] { new CameraDeviceInfo("dev1", "Logitech C920"), }); } [Fact] public async Task Refcount_TwoAcquires_OneCaptureUntilLastRelease() { var started = new List(); var stopped = new List(); var manager = CreateManager(started, stopped); Assert.True(await manager.AcquireAsync("dev1")); Assert.True(await manager.AcquireAsync("dev1")); Assert.Single(started); await manager.ReleaseAsync("dev1"); Assert.Empty(stopped); await manager.ReleaseAsync("dev1"); Assert.Single(stopped); Assert.Null(manager.GetLatestFrame("dev1")); } [Fact] public async Task FramePump_UpdatesLatestFrame() { var started = new List(); var stopped = new List(); FakeFrameSource? captured = null; var manager = new CameraManager( new FakeEnumerator(), id => captured = new FakeFrameSource(id, started, stopped), null, TimeSpan.Zero); await manager.AcquireAsync("dev1"); var frame = new VideoFrame(2, 2, new byte[16]); captured!.Pump(frame); Assert.Same(frame, manager.GetLatestFrame("dev1")); } [Fact] public async Task Acquire_FailingCamera_ReturnsFalseAndStops() { var manager = new CameraManager( new FakeEnumerator(), id => new FakeFrameSource(id, new List(), new List(), failOnStart: "camera in use"), null, TimeSpan.Zero); string? failedDevice = null; manager.CameraFailed += (device, _) => failedDevice = device; Assert.False(await manager.AcquireAsync("dev1")); Assert.Equal("dev1", failedDevice); } [Fact] public async Task Acquire_EmptyDeviceId_ReturnsFalse() { var manager = CreateManager(new List(), new List()); Assert.False(await manager.AcquireAsync(" ")); } // ─── First-frame proof (the integration test for this change) ─── [Fact] public async Task Acquire_SilentCamera_NoFirstFrame_FailsAndSurfacesCameraFailed() { var stopped = new List(); var manager = new CameraManager( new FakeEnumerator(), id => new FakeFrameSource(id, new List(), stopped), null, TimeSpan.FromMilliseconds(150)); string? failedDevice = null; string? failedMessage = null; manager.CameraFailed += (device, message) => { failedDevice = device; failedMessage = message; }; // The reader "starts" fine but never delivers a frame — the exact // silent-empty-box scenario. Must fail, be reported, and be rolled back. Assert.False(await manager.AcquireAsync("dev1")); Assert.Equal("dev1", failedDevice); Assert.Contains("no frames", failedMessage); Assert.Null(manager.GetLatestFrame("dev1")); Assert.Contains("dev1", stopped); } [Fact] public async Task Acquire_FirstFrameProvesAlive_ReturnsTrueWithoutWaitingTimeout() { var manager = new CameraManager( new FakeEnumerator(), id => new FakeFrameSource(id, new List(), new List(), pumpOnStart: new VideoFrame(2, 2, new byte[16])), null, TimeSpan.FromSeconds(10)); var started = await manager.AcquireAsync("dev1"); Assert.True(started); Assert.NotNull(manager.GetLatestFrame("dev1")); } [Fact] public async Task Acquire_SourceFailureAfterStart_SurfacesCameraFailedAndRollsBack() { var stopped = new List(); FakeFrameSource? source = null; var manager = new CameraManager( new FakeEnumerator(), id => source = new FakeFrameSource(id, new List(), stopped, pumpOnStart: new VideoFrame(2, 2, new byte[16])), null, TimeSpan.Zero); string? failedMessage = null; manager.CameraFailed += (_, message) => failedMessage = message; Assert.True(await manager.AcquireAsync("dev1")); source!.Fail("capture failed (0x8007001F): device not available"); Assert.Contains("device not available", failedMessage); Assert.Null(manager.GetLatestFrame("dev1")); Assert.Contains("dev1", stopped); } private sealed class HalfMaskSegmenter : IPersonSegmenter { public int Calls; public SegmentationMask? Segment(VideoFrame frame) { Interlocked.Increment(ref Calls); return new SegmentationMask(2, 1, new[] { 1f, 0f }); // left person, right background } } /// Returns a FRESH mask per call — like the real segmenter, whose contract test /// pins the raw per-frame output: {person, person, bg, bg} then {person, 0.2, bg, bg}. private sealed class SteppingMaskSegmenter : IPersonSegmenter { public int Calls; public SegmentationMask? Segment(VideoFrame _) { var n = Interlocked.Increment(ref Calls); var v = n == 1 ? new[] { 1f, 1f, 0f, 0f } : new[] { 1f, 0.2f, 0f, 0f }; return new SegmentationMask(4, 1, v); } } /// TASK 51 slice 4 integration test — the one processing point: with a Remove /// variant requested, a raw camera frame reaches BOTH consumers processed — the /// compositor's latest frame for that variant and the variant's preview bitmap on the UI /// dispatcher — while the raw path keeps the untouched frame. [Fact] public async Task Requested_Remove_Variant_Reaches_Compositor_And_Preview_Raw_Stays_Raw() { Dispatcher? ui = null; using var ready = new ManualResetEventSlim(); var uiThread = new Thread(() => { ui = Dispatcher.CurrentDispatcher; ready.Set(); Dispatcher.Run(); }); uiThread.SetApartmentState(ApartmentState.STA); uiThread.IsBackground = true; uiThread.Start(); ready.Wait(); try { FakeFrameSource? source = null; var segmenter = new HalfMaskSegmenter(); var manager = new CameraManager( new FakeEnumerator(), id => source = new FakeFrameSource(id, new List(), new List()), ui, TimeSpan.Zero, new BackgroundEffectProcessor(segmenter)); var key = WebcamEffectKey.For(BackgroundEffect.Remove, 0.5); manager.SetRequestedEffects("dev1", new[] { key, WebcamEffectKey.None }); var previewArrived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); manager.EffectPreviewBitmapChanged += (device, k, bitmap) => { if (device == "dev1" && k == key) previewArrived.TrySetResult(bitmap); }; Assert.True(await manager.AcquireAsync("dev1")); const int w = 16, h = 4; var pixels = new byte[w * h * 4]; for (var i = 0; i < pixels.Length; i += 4) { pixels[i] = 50; pixels[i + 1] = 60; pixels[i + 2] = 70; pixels[i + 3] = 255; } var raw = new VideoFrame(w, h, pixels) { IsOpaque = true }; source!.Pump(raw); var bitmap = await previewArrived.Task.WaitAsync(TimeSpan.FromSeconds(10)); // Raw consumer: the very same frame, untouched. Assert.Same(raw, manager.GetLatestFrame("dev1")); Assert.Same(raw, manager.GetLatestFrame("dev1", WebcamEffectKey.None)); // Compositor consumer: the processed variant. var processed = manager.GetLatestFrame("dev1", key); Assert.NotNull(processed); Assert.False(processed!.IsOpaque); Assert.Equal(255, processed.BgraPixels[(1 * w + 1) * 4 + 3]); // person side Assert.Equal(0, processed.BgraPixels[(1 * w + (w - 2)) * 4 + 3]); // background side // Preview consumer: the variant's bitmap carries the same cut-out. var previewPixels = new byte[w * h * 4]; var previewWidth = await ui!.InvokeAsync(() => { bitmap.CopyPixels(previewPixels, w * 4, 0); return bitmap.PixelWidth; }); Assert.Equal(w, previewWidth); Assert.Equal(255, previewPixels[(1 * w + 1) * 4 + 3]); Assert.Equal(0, previewPixels[(1 * w + (w - 2)) * 4 + 3]); Assert.Same(bitmap, manager.GetPreviewBitmap("dev1", key)); Assert.True(segmenter.Calls >= 1); // Dropping the request stops the variant. manager.SetRequestedEffects("dev1", new[] { WebcamEffectKey.None }); Assert.Null(manager.GetLatestFrame("dev1", key)); } finally { ui?.InvokeShutdown(); } } /// TASK 51 jitter fix integration test — the one processing point: two consecutive /// frames whose raw masks disagree at an edge. The first frame passes through untouched; the /// second's blend with history (0.75/0.25) keeps a partial alpha where the raw mask would /// have snapped to 0 — the per-frame judder the smoother damps. [Fact] public async Task Temporal_Smoothing_Damps_Segmentation_Jitter_Across_Frames() { FakeFrameSource? source = null; var segmenter = new SteppingMaskSegmenter(); var manager = new CameraManager( new FakeEnumerator(), id => source = new FakeFrameSource(id, new List(), new List()), null, TimeSpan.Zero, new BackgroundEffectProcessor(segmenter)); var key = WebcamEffectKey.For(BackgroundEffect.Remove, 0.5); manager.SetRequestedEffects("dev1", new[] { key }); Assert.True(await manager.AcquireAsync("dev1")); const int w = 16, h = 4; var pixels = new byte[w * h * 4]; for (var i = 0; i < pixels.Length; i += 4) { pixels[i] = 50; pixels[i + 1] = 60; pixels[i + 2] = 70; pixels[i + 3] = 255; } var frame = new VideoFrame(w, h, pixels) { IsOpaque = true }; var edge = (0 * w + 6) * 4 + 3; // row 0, x 6: the contested mask boundary source!.Pump(frame); var first = await PollLatestFrameAsync(manager, key, distinctFrom: null); Assert.NotNull(first); Assert.Equal(255, first!.BgraPixels[edge]); // maiden frame: raw mask, untouched // A NEW instance — the worker's latest-frame-drop guard treats a re-queued // identical reference as "no new frame", which is the real camera behavior. source.Pump(new VideoFrame(w, h, pixels) { IsOpaque = true }); var second = await PollLatestFrameAsync(manager, key, distinctFrom: first); Assert.NotNull(second); Assert.Equal(2, segmenter.Calls); Assert.False(second!.IsOpaque); Assert.Equal(58, second.BgraPixels[edge]); Assert.Equal(255, second.BgraPixels[(0 * w + 1) * 4 + 3]); // person core stays opaque Assert.Equal(0, second.BgraPixels[(0 * w + 14) * 4 + 3]); // background stays cut // The raw second mask alone would have cut the same pixel to 0 — exactly the flutter // the smoother removes. var rawSecond = BackgroundEffectProcessor.Render(frame, new SegmentationMask(4, 1, new[] { 1f, 0.2f, 0f, 0f }), BackgroundEffect.Remove, 0.5); Assert.Equal(0, rawSecond.BgraPixels[edge]); } private static async Task PollLatestFrameAsync(CameraManager manager, WebcamEffectKey key, VideoFrame? distinctFrom) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); while (DateTime.UtcNow < deadline) { var frame = manager.GetLatestFrame("dev1", key); if (frame != null && !ReferenceEquals(frame, distinctFrom)) return frame; await Task.Delay(10); } return null; } }