Files
LlamaCasty/ytLive.Tests/WebcamMenuGateTests.cs
gramps 75723acc1b 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.
2026-09-17 09:16:56 -07:00

196 lines
7.6 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// 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 WebcamIsAppDefault_AddPlacesSameCamera_AnyScene_IdentitySurvivesRemoval()
{
_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()
=> RunWith(failLock: false, sentinel: (vm, starting, live) =>
{
vm.StagedScene = live;
Assert.Null(live.WebcamConfig);
Assert.True(vm.CanAddWebcam,
"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={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
{
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 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}");
connection.Open();
var scenes = new (string Id, string Name)[]
{
("scene-starting", "Starting"),
("scene-live", "Live"),
("scene-brb", "BRB"),
("scene-chat", "Chat"),
("scene-ending", "Ending"),
};
foreach (var (id, name) in scenes)
{
using var cmd = connection.CreateCommand();
cmd.CommandText =
"INSERT INTO Scene (Id, Name, HasBackground, SortOrder) VALUES ($id, $name, 1, $order);";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$name", name);
cmd.Parameters.AddWithValue("$order", Array.IndexOf(scenes, (id, name)));
cmd.ExecuteNonQuery();
}
using (var webcam = connection.CreateCommand())
{
webcam.CommandText =
"INSERT INTO Webcam (Id, DeviceId, Name) VALUES ('wc-1', 'test-camera', 'Test Cam');";
webcam.ExecuteNonQuery();
}
using (var config = connection.CreateCommand())
{
config.CommandText =
@"INSERT INTO WebcamSceneConfig
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity, SortOrder)
VALUES ('scene-starting', 'wc-1', 1, 100, 100, 480, 270, 1, 0);";
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; }
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, 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;
}
public Task StopAsync() => Task.CompletedTask;
}
}