75723acc1b
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.
196 lines
7.6 KiB
C#
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;
|
|
}
|
|
} |