using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using Xunit; using ytLive.Services; using ytLive.ViewModels; namespace ytLive.Tests; /// /// Webcam resource lifecycle, startup slice: the app polls the OS for webcams /// exactly once at startup and tri-states on the count — none → app runs on, /// layer inactive, no alarm; exactly one → attempt to allocate and lock it, and a /// failed lock surfaces a persistent red alert (bottom of the Layers panel) that /// re-polls and clears itself the moment the camera can be locked again. /// The multi-camera path (App Settings selector) is a later slice. /// Real-App + temp-DB + camera-seam pattern (see WebcamMenuGateTests; the camera /// seams are new — the startup probe must never touch real hardware under test). /// [Collection("RealApp")] public sealed class WebcamStartupResourceTests { [Fact] public void SingleCamera_IsLockedAtStartup_And_AppRunsOn() { _app.Run(RunSingleLock); } [Fact] public void NoCameras_AppRunsOn_NoLock_NoAlert() { _app.Run(RunZero); } [Fact] public void SingleCameraLockFails_RedAlertShown_ThenClears_OnceLockSucceeds() { _app.Run(RunFailThenRecover); } private readonly RealAppHost _app; public WebcamStartupResourceTests(RealAppHost app) => _app = app; private void RunSingleLock() => RunWith(sceneBoilerplate: true, cameraCount: 1, failOnStart: false, sentinel: (vm, harness) => { vm.WebcamStartupValidationTask!.GetAwaiter().GetResult(); Assert.Null(vm.WebcamLockAlert); Assert.Single(harness.Started); Assert.Equal("dev1", harness.Started[0]); Assert.NotNull(vm.StagedScene); Assert.True(vm.CanAddWebcam, "the single camera becomes the app default — the Web Cam layer is offered"); }); private void RunZero() => RunWith(sceneBoilerplate: true, cameraCount: 0, failOnStart: false, sentinel: (vm, harness) => { vm.WebcamStartupValidationTask!.GetAwaiter().GetResult(); Assert.Null(vm.WebcamLockAlert); Assert.Empty(harness.Started); Assert.NotNull(vm.StagedScene); }); private void RunFailThenRecover() => RunWith(sceneBoilerplate: true, cameraCount: 1, failOnStart: true, sentinel: (vm, harness) => { vm.WebcamStartupValidationTask!.GetAwaiter().GetResult(); Assert.NotNull(vm.WebcamLockAlert); Assert.Contains("Web Cam unavailable", vm.WebcamLockAlert); harness.FailLock = false; vm.RetryWebcamLockCommand.Execute(null); vm.ValidateWebcamResourceStartupAsync().GetAwaiter().GetResult(); Assert.Null(vm.WebcamLockAlert); Assert.Contains("dev1", harness.Started); }); private void RunWith(bool sceneBoilerplate, int cameraCount, bool failOnStart, Action sentinel) { var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-wcamstart-{Guid.NewGuid():N}.db"); MainViewModel.LayoutPathOverride = tempDb; var harness = new Harness { FailLock = failOnStart }; MainViewModel.CameraEnumeratorOverride = new FakeEnumerator(cameraCount); MainViewModel.CameraFrameSourceFactoryOverride = id => new FakeWebcamSource(id, harness); var window = default(MainWindow); try { using (var schema = new ytLive.Services.LayoutStore(tempDb)) { } if (sceneBoilerplate) SeedScenes(tempDb); window = new MainWindow(); var vm = (MainViewModel)window.DataContext; sentinel(vm, harness); } finally { window?.Close(); MainViewModel.LayoutPathOverride = null; MainViewModel.CameraEnumeratorOverride = null; MainViewModel.CameraFrameSourceFactoryOverride = null; try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } } private static void SeedScenes(string path) { using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={path}"); connection.Open(); var scenes = new[] { "Starting", "Live", "BRB", "Chat", "Ending" }; for (var i = 0; i < scenes.Length; i++) { using var cmd = connection.CreateCommand(); cmd.CommandText = "INSERT INTO Scene (Id, Name, HasBackground, SortOrder) VALUES ($id, $name, 1, $order);"; cmd.Parameters.AddWithValue("$id", $"scene-{scenes[i].ToLowerInvariant()}"); cmd.Parameters.AddWithValue("$name", scenes[i]); cmd.Parameters.AddWithValue("$order", i); cmd.ExecuteNonQuery(); } } private sealed class Harness { public bool FailLock; public readonly List Started = new(); public readonly List Stopped = new(); } private sealed class FakeEnumerator : ICameraEnumerator { private readonly int _count; public FakeEnumerator(int count) => _count = count; public Task> GetCamerasAsync() { var list = new List(); for (var i = 1; i <= _count; i++) list.Add(new CameraDeviceInfo($"dev{i}", $"Test Cam {i}")); return Task.FromResult>(list); } } private sealed class FakeWebcamSource : ICameraFrameSource { public string DeviceId { get; } private readonly Harness _harness; public event Action? FrameAvailable; #pragma warning disable CS0067 public event Action? SourceFailed; #pragma warning restore CS0067 public FakeWebcamSource(string deviceId, Harness harness) { DeviceId = deviceId; _harness = harness; } public Task StartAsync() { if (_harness.FailLock) throw new InvalidOperationException("Camera is in use by another app."); _harness.Started.Add(DeviceId); FrameAvailable?.Invoke(new VideoFrame(2, 2, new byte[16])); return Task.CompletedTask; } public Task StopAsync() { _harness.Stopped.Add(DeviceId); return Task.CompletedTask; } } }