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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user