fix(webcam): offer the Web Cam row only when a camera is attainable (live lock), not merely selected

Creator refinement: 'offered iff there's not one already configured & attainable'.
CanAddWebcam now requires IsWebcamAttainable = identity present AND a RUNNING
session (CameraManager.IsRunning) — an identity whose camera was unplugged or
whose lock keeps failing leaves the row greyed with reason 'No webcam is
currently available…', and it un-greys the moment a session is live. Gate
re-raised at every attainability flip: staging, removal, startup lock success,
first frame, camera failure, identity swap.

Root cause the old test surfaced: the startup pass skipped acquiring when the
loaded identity's configs already held the session, so there was no independent
app base ref — removing the last placement dropped RefCount to 0 and killed the
session. The single-camera branch now ALWAYS acquires (a running session just
bumps), laying the app-wide base hold so the default outlives the scenes.

Good Dog: WebcamMenuGateTests second fact — identity loaded, session can't start
→ row NOT offered + 'No webcam is currently available…' tooltip. Positive fact
waits for WebcamStartupValidationTask to make the IsRunning read deterministic.
Docs same commit (ai.md gate + base-lock, TASKS.md, HANDOFF.md incl. proven
pre-existing audio flake). 305 tests (304 pass + known flake), 0 warnings.
This commit is contained in:
2026-09-17 09:16:56 -07:00
parent 9d00955004
commit 75723acc1b
5 changed files with 135 additions and 75 deletions
+48 -19
View File
@@ -4,6 +4,7 @@ using System.IO;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
@@ -28,29 +29,19 @@ public sealed class WebcamMenuGateTests
_app.Run(Run);
}
[Fact]
public void WebcamNotOffered_WhenIdentityExists_ButNoCameraIsAttainable()
{
_app.Run(RunNotAttainable);
}
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
=> RunWith(failLock: false, sentinel: (vm, starting, live) =>
{
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,
@@ -74,13 +65,45 @@ public sealed class WebcamMenuGateTests
Assert.True(vm.CanAddWebcam, "the app default is still selected — Add stays offered");
vm.SaveLayoutNow();
using (var connection = new SqliteConnection($"Data Source={tempDb}"))
using (var connection = new SqliteConnection($"Data Source={MainViewModel.LayoutPathOverride}"))
{
connection.Open();
using var webcams = connection.CreateCommand();
webcams.CommandText = "SELECT COUNT(*) FROM Webcam;";
Assert.Equal(1L, (long)webcams.ExecuteScalar()!);
}
});
private void RunNotAttainable()
=> RunWith(failLock: true, sentinel: (vm, starting, live) =>
{
vm.StagedScene = live;
Assert.Null(live.WebcamConfig);
Assert.False(vm.CanAddWebcam,
"identity is selected but its session can't start — the row is NOT offered (not attainable)");
Assert.Equal("No webcam is currently available — plug one in, or allow camera access in Windows.",
vm.WebcamAddToolTip);
});
private void RunWith(bool failLock, Action<MainViewModel, Scene, Scene> sentinel)
{
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, failLock);
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;
// Deterministic: the offer reads live session state, so wait for the
// startup lock before asserting anything camera-flavoured.
vm.WebcamStartupValidationTask!.GetAwaiter().GetResult();
sentinel(vm, vm.Scenes.Single(s => s.Name == "Starting"), vm.Scenes.Single(s => s.Name == "Live"));
}
finally
{
@@ -149,15 +172,21 @@ public sealed class WebcamMenuGateTests
private sealed class FakeFrameSource : ICameraFrameSource
{
public string DeviceId { get; }
private readonly bool _failLock;
public event Action<VideoFrame>? FrameAvailable;
#pragma warning disable CS0067
public event Action<string>? SourceFailed;
#pragma warning restore CS0067
public FakeFrameSource(string deviceId) => DeviceId = deviceId;
public FakeFrameSource(string deviceId, bool failLock = false)
{
DeviceId = deviceId;
_failLock = failLock;
}
public Task StartAsync()
{
if (_failLock) throw new InvalidOperationException("Camera is in use by another app.");
FrameAvailable?.Invoke(new VideoFrame(2, 2, new byte[16]));
return Task.CompletedTask;
}