Files
LlamaCasty/ytLive.Tests/WebcamStartupResourceTests.cs
gramps 9d00955004 feat(webcam): app-default gate slice — per-scene offer, Add places default directly, identity survives removal
Chat's camera no longer greys Web Cam in Live (per-scene max, not app-wide);
TASK 26 superseded by creator directive (app-level resource model). Add Webcam
now places the existing app default without the picker; the picker runs only
for the initial selection. Removing the last placement keeps the identity
(_webcam never nulled) so Add stays offered. Startup pass adopts a solo camera
as the app default, so a clean layout offers the layer in Live/Chat at once.
Dynamic WebcamAddToolTip names the why (incl. the 'graduated to OBS' line).

Good Dog: WebcamMenuGateTests rewritten (real app + temp DB + camera seams) —
identity in Chat does not gray Live; Add in Live places same wc-1 no picker;
scene-with-placement stays gray; identity survives both removals (DB row 1).
WebcamStartupResourceTests single-lock fact asserts CanAddWebcam after adoption.
Docs same commit (ai.md supersession, TASKS.md, HANDOFF.md). 304/304, 0 warnings.
2026-09-17 08:56:04 -07:00

177 lines
6.4 KiB
C#

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;
/// <summary>
/// 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).
/// </summary>
[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<MainViewModel, Harness> 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<string> Started = new();
public readonly List<string> Stopped = new();
}
private sealed class FakeEnumerator : ICameraEnumerator
{
private readonly int _count;
public FakeEnumerator(int count) => _count = count;
public Task<IReadOnlyList<CameraDeviceInfo>> GetCamerasAsync()
{
var list = new List<CameraDeviceInfo>();
for (var i = 1; i <= _count; i++)
list.Add(new CameraDeviceInfo($"dev{i}", $"Test Cam {i}"));
return Task.FromResult<IReadOnlyList<CameraDeviceInfo>>(list);
}
}
private sealed class FakeWebcamSource : ICameraFrameSource
{
public string DeviceId { get; }
private readonly Harness _harness;
public event Action<VideoFrame>? FrameAvailable;
#pragma warning disable CS0067
public event Action<string>? 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;
}
}
}