using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using Microsoft.Data.Sqlite; using Xunit; using ytLive.Services; using ytLive.ViewModels; namespace ytLive.Tests; /// /// The webcam is an app-level resource with ONE selection (the app default): /// placing it in one scene must NOT grey it in the others (per-scene max, not /// app-wide), "Add Webcam" places the existing default directly (no picker — /// the picker only runs for the initial selection), and removing the last /// placement keeps the identity so the layer stays offered. /// Real-App + temp-DB + camera-seam pattern (camera seams are required: the /// startup resource pass and Add both acquire, and must never touch real /// hardware under test). /// [Collection("RealApp")] public sealed class WebcamMenuGateTests { [Fact] public void WebcamIsAppDefault_AddPlacesSameCamera_AnyScene_IdentitySurvivesRemoval() { _app.Run(Run); } private readonly RealAppHost _app; public WebcamMenuGateTests(RealAppHost app) => _app = app; private void Run() { var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-wcgate-{Guid.NewGuid():N}.db"); MainViewModel.LayoutPathOverride = tempDb; MainViewModel.CameraEnumeratorOverride = new FakeEnumerator(); MainViewModel.CameraFrameSourceFactoryOverride = id => new FakeFrameSource(id); var window = default(MainWindow); try { using (var schema = new ytLive.Services.LayoutStore(tempDb)) { } SqliteConnection.ClearAllPools(); SeedDatabaseWithWebcam(tempDb); window = new MainWindow(); var vm = (MainViewModel)window.DataContext; var starting = vm.Scenes.Single(s => s.Name == "Starting"); var live = vm.Scenes.Single(s => s.Name == "Live"); vm.StagedScene = live; Assert.Null(live.WebcamConfig); Assert.True(vm.CanAddWebcam, "webcam exists in Starting but that is NOT a block — the app default stays offered in Live"); // Add Webcam must place the existing app default without opening a picker. vm.AddWebcamCommand.Execute(null); Assert.NotNull(live.WebcamConfig); Assert.Equal("wc-1", live.WebcamConfig!.WebcamId); // Per-scene max: the scene that already hosts the webcam stays greyed. vm.StagedScene = starting; Assert.False(vm.CanAddWebcam, "a scene already hosting the webcam must not offer it again"); // Removing placements keeps the app-level identity alive. vm.RemoveSourceCommand.Execute(starting.WebcamConfig!); Assert.True(vm.CanAddWebcam, "removing a placement re-enables Add for the same app default"); vm.StagedScene = live; vm.RemoveSourceCommand.Execute(live.WebcamConfig!); Assert.True(vm.CanAddWebcam, "the app default is still selected — Add stays offered"); vm.SaveLayoutNow(); using (var connection = new SqliteConnection($"Data Source={tempDb}")) { connection.Open(); using var webcams = connection.CreateCommand(); webcams.CommandText = "SELECT COUNT(*) FROM Webcam;"; Assert.Equal(1L, (long)webcams.ExecuteScalar()!); } } finally { window?.Close(); MainViewModel.LayoutPathOverride = null; MainViewModel.CameraEnumeratorOverride = null; MainViewModel.CameraFrameSourceFactoryOverride = null; SqliteConnection.ClearAllPools(); try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } } /// Five canonical scenes + a webcam identity used by exactly one /// non-staged-by-default scene (Starting), so staging Live reproduces the /// old app-wide gate's false "unavailable". private static void SeedDatabaseWithWebcam(string path) { using var connection = new SqliteConnection($"Data Source={path}"); connection.Open(); var scenes = new (string Id, string Name)[] { ("scene-starting", "Starting"), ("scene-live", "Live"), ("scene-brb", "BRB"), ("scene-chat", "Chat"), ("scene-ending", "Ending"), }; foreach (var (id, name) in scenes) { using var cmd = connection.CreateCommand(); cmd.CommandText = "INSERT INTO Scene (Id, Name, HasBackground, SortOrder) VALUES ($id, $name, 1, $order);"; cmd.Parameters.AddWithValue("$id", id); cmd.Parameters.AddWithValue("$name", name); cmd.Parameters.AddWithValue("$order", Array.IndexOf(scenes, (id, name))); cmd.ExecuteNonQuery(); } using (var webcam = connection.CreateCommand()) { webcam.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ('wc-1', 'test-camera', 'Test Cam');"; webcam.ExecuteNonQuery(); } using (var config = connection.CreateCommand()) { config.CommandText = @"INSERT INTO WebcamSceneConfig (SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity, SortOrder) VALUES ('scene-starting', 'wc-1', 1, 100, 100, 480, 270, 1, 0);"; config.ExecuteNonQuery(); } } private sealed class FakeEnumerator : ICameraEnumerator { public Task> GetCamerasAsync() => Task.FromResult>(new[] { new CameraDeviceInfo("test-camera", "Test Cam"), }); } private sealed class FakeFrameSource : ICameraFrameSource { public string DeviceId { get; } public event Action? FrameAvailable; #pragma warning disable CS0067 public event Action? SourceFailed; #pragma warning restore CS0067 public FakeFrameSource(string deviceId) => DeviceId = deviceId; public Task StartAsync() { FrameAvailable?.Invoke(new VideoFrame(2, 2, new byte[16])); return Task.CompletedTask; } public Task StopAsync() => Task.CompletedTask; } }