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.
This commit is contained in:
2026-09-17 08:56:04 -07:00
parent 1e4017df03
commit 9d00955004
9 changed files with 210 additions and 114 deletions
+69 -25
View File
@@ -1,25 +1,29 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// TASK 26 integration: the (+) menu's Webcam row gates on the app-wide webcam
/// identity — one camera per app, so the row must stay disabled while ANY scene
/// shows the webcam (not just the staged one), and re-enable only after the
/// last config anywhere is removed (which also drops the identity).
/// Real-App + temp-DB pattern (see BackgroundHealIntegrationTests).
/// 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).
/// </summary>
[Collection("RealApp")]
public sealed class WebcamMenuGateTests
{
[Fact]
public void CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity()
public void WebcamIsAppDefault_AddPlacesSameCamera_AnyScene_IdentitySurvivesRemoval()
{
_app.Run(Run);
}
@@ -32,6 +36,8 @@ public sealed class WebcamMenuGateTests
{
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
{
@@ -42,22 +48,30 @@ public sealed class WebcamMenuGateTests
window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
var starting = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Starting));
var live = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
var starting = vm.Scenes.Single(s => s.Name == "Starting");
var live = vm.Scenes.Single(s => s.Name == "Live");
vm.StagedScene = live;
Assert.True(live.WebcamConfig == null, "Live must not carry the webcam config");
Assert.False(vm.CanAddWebcam,
"webcam exists in another scene — Add Webcam must be greyed out app-wide");
vm.StagedScene = starting;
Assert.False(vm.CanAddWebcam, "staged scene itself has the webcam");
var config = starting.WebcamConfig!;
vm.RemoveSourceCommand.Execute(config);
Assert.Null(live.WebcamConfig);
Assert.True(vm.CanAddWebcam,
"last webcam config anywhere was removed — identity cleared, picker may run again");
"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}"))
@@ -65,21 +79,23 @@ public sealed class WebcamMenuGateTests
connection.Open();
using var webcams = connection.CreateCommand();
webcams.CommandText = "SELECT COUNT(*) FROM Webcam;";
Assert.Equal(0L, (long)webcams.ExecuteScalar()!);
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 */ }
}
}
/// <summary>Five canonical scenes + a webcam identity used by exactly one
/// non-staged-by-default scene (Starting), so staging any other screen
/// reproduces the old per-scene gate's false "available".</summary>
/// non-staged-by-default scene (Starting), so staging Live reproduces the
/// old app-wide gate's false "unavailable".</summary>
private static void SeedDatabaseWithWebcam(string path)
{
using var connection = new SqliteConnection($"Data Source={path}");
@@ -120,4 +136,32 @@ public sealed class WebcamMenuGateTests
config.ExecuteNonQuery();
}
}
}
private sealed class FakeEnumerator : ICameraEnumerator
{
public Task<IReadOnlyList<CameraDeviceInfo>> GetCamerasAsync()
=> Task.FromResult<IReadOnlyList<CameraDeviceInfo>>(new[]
{
new CameraDeviceInfo("test-camera", "Test Cam"),
});
}
private sealed class FakeFrameSource : ICameraFrameSource
{
public string DeviceId { get; }
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 Task StartAsync()
{
FrameAvailable?.Invoke(new VideoFrame(2, 2, new byte[16]));
return Task.CompletedTask;
}
public Task StopAsync() => Task.CompletedTask;
}
}