using System; using System.Collections.Generic; using System.Threading.Tasks; using Xunit; using ytLive.Services; 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); } }