feat(webcam): resource lifecycle startup slice — poll-on-start, single-cam lock, persistent Layers alert
The creator couldn't add a webcam to Live (grayed app-wide) and nothing in the app explained why. Ground truth from the live DB: one Webcam identity AND one WebcamSceneConfig in the Chat scene — so the gray was the single-identity rule working, but the reason was unobservable. This slice makes the webcam a resource the app validates and locks, mirroring how OBS reserves its devices. At startup we enumerate the OS once (ValidateWebcamResourceStartupAsync, fired after LoadLayout, stored as WebcamStartupValidationTask for tests to await): - 0 webcams -> app runs on, layer inactive, no alarm - exactly 1 -> attempt CameraManager.AcquireAsync as an app-wide lock; on failure show a persistent red alert at the bottom of the Layers panel (WebcamLockAlert + Retry) that re-polls every 5s and clears itself the moment the camera locks, or on any first real frame - >=2 -> deliberately no auto-lock; camera selection belongs to the App Settings dialog (gear) — next slice CameraManager.IsRunning(deviceId) tells the pass a session already exists (started OR still starting) so loaded identity configs count as the lock and the pass never double-acquires. Test seams mirror LayoutPathOverride: CameraEnumeratorOverride / CameraFrameSourceFactoryOverride so the startup probe never touches real hardware under test. Good Dog test: WebcamStartupResourceTests x3 — single-cam locked + app runs on, zero-cams no-alarm/no-lock, lock-fails -> red alert -> Retry -> clears. Full suite 304/304, build 0 warnings, scope-check passed. Docs in-commit: TASKS Open items + ai.md Webcam section + HANDOFF rewrite. Also corrects the record: 'NVIDIA Broadcast opens the webcam exclusively' was a suspect-list claim (CameraConflictProbe reads process names only, no handles) — not restated as fact.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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);
|
||||
});
|
||||
|
||||
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;
|
||||
public event Action<string>? SourceFailed;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user