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