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
+1 -1
View File
@@ -30,7 +30,7 @@
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Webcam" Command="{Binding AddWebcamCommand}"
IsEnabled="{Binding CanAddWebcam}"
ToolTip="One webcam at a time — it's already in your stream"/>
ToolTip="{Binding WebcamAddToolTip}"/>
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
+56 -39
View File
@@ -1,11 +1,12 @@
# HANDOFF — 2026-09-17 (webcam resource lifecycle, startup slice; committed locally — no push)
# HANDOFF — 2026-09-17 (webcam app-default gate slice; committed locally — no push)
## Branch / Commit State
`main` HEAD = **`94a934f` webcam reader-ladder fix** (local). This work unit sits un-pushed on top
as a NEW commit (same bundle of web/A/V work — stays commit-local until greenlight). Below: slice-18
C4 cache (`64a5a6d`), slice-17, slice-16, slice-15, `c01206f`, `b22d08e` (signed audio-sync, pushed).
No push after this commit either — still awaiting the device take + user greenlight.
`main` HEAD = **`1e4017d` webcam startup resource lifecycle** (local). The gate slice lands
on top as a NEW commit (stays commit-local until greenlight). Below: `94a934f` (reader-ladder),
`64a5a6d` (slice-18 C4 cache), slice-17, slice-16, slice-15, `c01206f`, `b22d08e` (signed
audio-sync, pushed). No push after this commit either — still awaiting the device take + user
greenlight.
## ⚠️ Branding (2026-09-14, creator-corrected): product = **llamacasty**, internals = ytLive
@@ -13,36 +14,48 @@ Product is **llamacasty**; repo path, csproj `AssemblyName`/`RootNamespace`, DB/
(`%APPDATA%\ytLlive\...`), most code names are legacy **ytLive/ytLlive**. User-facing text:
"llamacasty" / UI labels use the catalog names ("Web Cam", "YouTube Chat", "Countdown Timer"…).
## ✅ Committed locally — webcam resource lifecycle (startup slice)
## ✅ Committed — webcam app-default gate slice (creator-visible goal: Web Cam offered when switching to Live)
**Incident review (the reason this exists):** the creator couldn't add a webcam to the Live scene
(WebCam row grayed app-wide). Ground truth from `%APPDATA%\ytLlive\ytLlive.db` (NOT `layout.db`,
which is a 0-byte legacy file): one `Webcam` identity (C920 `\?\USB#VID_046D&PID_082D…GLOBAL`) AND
one `WebcamSceneConfig` — in the **Chat** scene. Under the single-identity rule that means Live's
"+" menu is gray *by design*, but the app couldn't *say why*. Combined with the earlier crash/failure
chain, the fix is a proper resource lifecycle:
**Background:** the DB truth (`%APPDATA%\ytLlive\ytLlive.db`; NOT `layout.db`, a 0-byte legacy
file) put the single `WebcamSceneConfig` in **Chat**, so the old TASK 26 app-wide rule grayed
Live's "+" → Web Cam even though the camera was available and in use. The creator ruled (rigorous
resource model): **the webcam is an app-level resource — ONE app-wide default selection, usable in
every scene that can host it; it is NOT blocked by other scenes' placements.** Scope of this slice:
the availability layer. TASK 26's app-wide gate is **superseded** by creator directive.
- **Startup poll + tri-state** (`MainViewModel.ValidateWebcamResourceStartupAsync`, fired
fire-and-forget right after `LoadLayout`): 0 webcams → app runs on, layer inactive, no alarm;
**1 → attempt `CameraManager.AcquireAsync` as an app-wide LOCK**; **≥2 → no auto-lock** — selection
belongs to the App Settings dialog (gear) — that's the next slice.
- **Persistent red alert** (`WebcamLockAlert`, bottom of the Layers panel, + Retry button): shown when
the one detected camera can't be locked; **re-polls every 5 s** (`_webcamLockPollTimer`) and clears
the moment a lock succeeds, or on any first real frame (`OnCameraPreviewBitmapChanged`).
- **`CameraManager.IsRunning(deviceId)`** — new accessor (session exists, started OR starting) so the
pass never double-acquires a lock the loaded identity's configs already hold; a rolled-back (failed)
session leaves the dictionary, so the pass retries cleanly.
- **Test seams mirroring `LayoutPathOverride`:** `CameraEnumeratorOverride` /
`CameraFrameSourceFactoryOverride` — the startup probe must NEVER touch real hardware under test
(the reason the 09:15 tests never asserted camera state). **Good Dog test**
`WebcamStartupResourceTests` x3: single-cam locked + app runs on; zero-cams no-alarm no-lock;
lock-fails → red alert → Retry → clears. **304/304 green, build 0 warnings.**
- **Attribution correction committed to `ai.md`:** "NVIDIA Broadcast opens the webcam
exclusively" is a *suspect-list* claim — `CameraConflictProbe` reads running process NAMES only, no
device handles; the contention symptoms fit shared-mode/bandwidth just as well. Do not restate it as
fact (creator called this out).
- **Per-scene gate:** `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && _webcam != null`.
A scene already hosting the webcam stays gray (one webcam per stream); another scene holding it
does NOT gray the row elsewhere.
- **Add places the default directly:** when `_webcam != null`, "Add Webcam" creates the placement
with NO picker; the Windows picker runs only for the initial selection (`_webcam == null`;
choosing there swaps app-wide via the existing `SwapWebcamIdentityAsync`).
- **Identity survives last-placement removal:** `RemoveElement` no longer nulls `_webcam` (that was
the TASK 26 rule). `LayoutStore` writes the singleton whenever it's non-null, so it persists.
- **Startup adopts a solo camera as the app default** (in `ValidateWebcamResourceStartupAsync`,
after the successful single-cam lock): a clean/empty layout now offers the Web Cam layer in
Live/Chat immediately; `CameraManager.IsRunning` skip means a loaded identity already holding a
session is never overwritten.
- **Dynamic why-gray tooltip:** `WebcamAddToolTip` (raised alongside the gates) — "Already in this
scene — one webcam per stream. A second camera means you've graduated to OBS." / "No webcam
detected…" / "Adds the app default webcam to this scene."
- **Good Dog integration test** `WebcamMenuGateTests` rewritten for the new model (real app + temp
DB + camera seams): Chat's identity does NOT gray Live; Add in Live places the same `wc-1`
without a picker; scene-with-placement stays gray; identity survives both removals (DB row count
keeps 1). `WebcamStartupResourceTests` test 1 strengthened: after startup the single camera is
adopted → `CanAddWebcam` true. **304/304 green, build 0 warnings.**
**Task docs:** `TASKS.md` Open items + `ai.md` Webcam section updated to match.
**Task docs:** `TASKS.md` Open items + `ai.md` Webcam section updated (supersession recorded).
## ✅ Earlier committed — webcam resource lifecycle (startup slice, `1e4017d`)
Startup poll + tri-state (`ValidateWebcamResourceStartupAsync` after `LoadLayout`): 0 → run on,
no alarm; **1 → `AcquireAsync` as app-wide lock**; **≥2 → no auto-lock** (App Settings selector
next). Lock failure → **persistent red alert** (`WebcamLockAlert` in the Layers panel + Retry),
re-polls every 5 s (`_webcamLockPollTimer`), clears on lock success or any first real frame.
`CameraManager.IsRunning(deviceId)` = session exists (started OR starting). Camera test seams
`CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` (mirror `LayoutPathOverride`).
Attribution correction committed: "NVIDIA Broadcast opens the webcam exclusively" is a *suspect-list*
claim — `CameraConflictProbe` reads process names only, no device handles; do not restate as fact.
## ⚠️ Open items
@@ -51,18 +64,20 @@ chain, the fix is a proper resource lifecycle:
reader+capture while the frame thread sits in `TryAcquireLatestFrame`/`Marshal.Copy` (outside the
frame's try/catch). No repro, no integration test, native race not catchable — deferred per
spin-guard. Record a second occurrence's pattern.
- **Multi-camera slice (user-queued):** when ≥2 webcams exist, add the web-cam selection text +
- **Multi-camera selector slice (user-queued):** when ≥2 webcams exist, add web-cam selection text +
control to the **App Settings** dialog (gear icon, `OverlayHost` — left-click opens settings,
right-click the context menu). Selecting + successfully allocating/locking activates the WebCam
layer. Currently ≥2 cameras → nothing shows in settings.
layer. **Currently ≥2 cameras → nothing shows in settings.** Also: the "Change Webcam" picker
should carry the OBS one-liner (one webcam per stream — a second means OBS).
- **Static (+) catalog slice (user-queued):** all catalog rows always listed (Background, "Web Cam",
"Countdown Timer", "Web Resource", "YouTube Chat", "YouTube Event"…), gray only for (1) system-level
unavailability (persistent alert), (2) max already allocated, (3) not available for the current
scene; tooltips must name the reason (e.g. "already in Chat"). Static labels only, no new code-behind.
scene; tooltips must name the reason. Static labels only, no new code-behind.
- **Device re-verify (one take session):** 1. slice-18 cache verdicts (NR/WH hits, stalls, band
~60); 2. webcam: fresh launch with the browser holding the camera must show the webcam or a NAMED
chip (now also the persistent red alert, and the alert must clear after the browser closes + retry);
3. verify the Chat scene still owns the webcam (so Live's + is intentionally gray with a reason).
3. verify Live now OFFERS the Web Cam layer even while Chat holds the camera (the exact creator
goal: "webcam offered in the layer stack when I switch over to the Live view").
- **No push yet** — after the take verdict, re-measure the clap offset (`/tmp/opencode/avsync.py`),
then decide push with the user.
@@ -103,6 +118,8 @@ chain, the fix is a proper resource lifecycle:
## Next step
The multi-camera App Settings selector slice (+ static catalog rows after), then the creator's take
on this build. The alert is the new expected behavior to eyeball on the take: browser holds the
camera → fresh launch → red alert in Layers (unless Chat's config grabbed it) that clears after a
Retry once the browser closes.
on this build. The webcam gate slice answers the creator's "go" ask — switching to the Live view
must now offer Web Cam in the layer stack (Chat holding the camera no longer blocks it). The red
Layers alert remains the new expected behavior to eyeball on the take: browser holds the camera →
fresh launch → alert (unless Chat's config grabbed it) that clears after a Retry once the browser
closes.
+7 -4
View File
@@ -65,10 +65,13 @@
- **TASK 14** — Branch 2 shipped; branch 3+ in progress
- **TASK 21** — UI picker slice remaining (Windows-only verification pending)
- **TASK 32–36** — all queued future work (2026-09-01)
- **Webcam resource lifecycle (2026-09-17)** — startup slice shipped: OS poll at start, single-camera
auto-lock, persistent red alert in the Layers panel on lock failure (re-polls until resolved). Next
slices (user-queued): multi-camera webcam selector in the App Settings dialog (gear), static (+)
catalog rows (Background, YouTubeEvent, quoted labels) with reason-greying.
- **Webcam resource lifecycle (2026-09-17)** — startup slice shipped (OS poll at start, single-camera
auto-lock, persistent red alert in the Layers panel on lock failure, re-polls until resolved); gate
slice shipped same day — webcam = app-level default, one per stream per scene, Add places the
default directly, identity survives last-placement removal, startup adopts a solo camera as default.
TASK 26's app-wide gate superseded by creator directive. Next slices (user-queued): multi-camera
webcam selector in the App Settings dialog (gear), static (+) catalog rows (Background,
YouTubeEvent, quoted labels) with reason-greying.
---
+1
View File
@@ -49,6 +49,7 @@ public partial class MainViewModel
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanAddYouTubeChat));
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
OnPropertyChanged(nameof(WebcamAddToolTip));
OnPropertyChanged(nameof(IsCountdownAvailable));
UpdateActiveBackground();
UpdateBackgroundImage();
+5 -7
View File
@@ -217,9 +217,9 @@ public partial class MainViewModel
// Removes an element from the active scene. For a webcam config this drops the
// scene's usage and releases one camera reference (CameraManager stops the
// session when the last using scene lets go). When the last config anywhere is
// removed, the webcam identity is cleared too — re-adding opens the picker
// again instead of silently resurrecting the old camera.
// session when the last using scene lets go). The webcam identity survives —
// it's the app-level default, so removing a scene's placement re-enables Add
// for the same camera instead of dropping it.
private void RemoveElement(SceneElement? element)
{
var scene = StagedScene;
@@ -235,13 +235,11 @@ public partial class MainViewModel
_webView2Manager?.Unregister(webSource.Id);
_sceneGraph.RemoveElement(scene, element);
if (element is WebcamSceneConfig && _webcam != null
&& !Scenes.Any(s => s.Elements.OfType<WebcamSceneConfig>().Any()))
if (element is WebcamSceneConfig)
{
_webcam = null;
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
OnPropertyChanged(nameof(WebcamAddToolTip));
}
OnPropertyChanged(nameof(ShowEmptySceneHint));
+45 -20
View File
@@ -22,9 +22,30 @@ public partial class MainViewModel
private string? _webcamLockAlert;
private DispatcherTimer? _webcamLockPollTimer;
/// <summary>The Add → Webcam menu item: enabled only while no webcam exists anywhere —
/// one camera identity app-wide; re-adding always opens the picker.</summary>
public bool CanAddWebcam => StagedScene != null && _webcam == null;
/// <summary>
/// Add → Webcam menu item: the webcam is an app-level resource (one camera —
/// the app default). The row is offered in any scene that doesn't already
/// host it, wherever a camera is selected; it is NOT blocked by other scenes
/// holding their own placement (per-scene max, not app-wide).
/// </summary>
public bool CanAddWebcam => StagedScene is { WebcamConfig: null } && _webcam != null;
/// <summary>
/// Why the Webcam row is greyed, when it is — never a silent bool. The max-1
/// reason gets the gentle "graduated to OBS" line; no camera at all gets its
/// own wording. Shown as the row's tooltip.
/// </summary>
public string WebcamAddToolTip
{
get
{
if (StagedScene?.WebcamConfig != null)
return "Already in this scene — one webcam per stream. A second camera means you've graduated to OBS.";
if (_webcam == null)
return "No webcam detected — plug one in, or allow camera access in Windows.";
return "Adds the app default webcam to this scene.";
}
}
/// <summary>
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
@@ -229,6 +250,15 @@ public partial class MainViewModel
var started = await _cameraManager.AcquireAsync(device.Id);
if (started)
{
// The single camera becomes the app default so the Web Cam layer is
// offered in the capabled scenes (Live/Chat) even on a clean layout.
if (_webcam == null)
{
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(WebcamAddToolTip));
}
WebcamLockAlert = null;
return;
}
@@ -237,33 +267,28 @@ public partial class MainViewModel
WebcamLockAlert = $"Web Cam unavailable: {reason}";
}
// Adds the webcam to the active scene. The creator ALWAYS picks from the
// cameras Windows has registered — never silently resurrects the previous
// camera (which is what happened after deleting one scene's webcam while
// another scene still used it). Picking a different camera than the current
// app-wide one swaps it everywhere, so the single-identity model stays honest;
// each scene's placement config is independent (webcam.{scene}.config).
// Adds the webcam to the active scene. The webcam is an app-level resource:
// once a camera is the app default, adding the layer just places it — the
// picker runs only for the initial selection. Each scene's placement config
// is independent (webcam.{scene}.config); the camera device is Windows' own
// singleton and stays where we put it.
private async Task AddWebcamToStagedSceneAsync()
{
var scene = StagedScene;
if (scene == null || scene.WebcamConfig != null) return;
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
{
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
var device = dialog.PickedDevice;
if (_webcam == null)
{
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
{
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
var device = dialog.PickedDevice;
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam));
}
else if (_webcam.DeviceId != device.Id)
{
await SwapWebcamIdentityAsync(device);
OnPropertyChanged(nameof(WebcamAddToolTip));
}
var config = new WebcamSceneConfig
+23 -18
View File
@@ -408,21 +408,22 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
- **TFM:** `net8.0-windows10.0.19041.0` (app + tests) pulls the WinRT projection from the SDK reference
packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy
camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working.
- **One webcam, many scenes (schema v3):** a singleton `Webcam` row holds the identity
(`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig` (position/size/clip/mirror/
border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at most once, the webcam
(`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager` refcounts capture
sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps it; the last
release stops + disposes). **"Add Webcam" ALWAYS opens the Windows camera picker** — the creator is
never silently handed the previous camera (which used to happen after deleting one scene's webcam
while another scene still used it; that identity survived, so re-adding bypassed the choice).
Picking a different camera than the current app-wide one swaps it everywhere via
`SwapWebcamIdentityAsync` (the same path "Change Webcam…" uses), keeping the singleton honest;
picking the same one just places the config. The Add Webcam menu item greys out whenever a webcam
exists **anywhere** — one camera identity app-wide (`CanAddWebcam` = `StagedScene != null && _webcam == null`,
TASK 26; the old per-staged-scene gate let a second picker run from a scene that lacked the config).
**Removing the last webcam config anywhere clears the identity** (`_webcam = null`, raising
`CanAddWebcam`/`CanChangeWebcam`), which also drops "Change Webcam…" and re-enables Add.
- **One webcam, many scenes (schema v3) — app-level default (2026-09-17):** a singleton `Webcam`
row holds the identity (`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig`
(position/size/clip/mirror/border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at
most once, the webcam (`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager`
refcounts capture sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps
it; the last release stops + disposes). **The webcam is an app-level resource — ONE selection (the
app default), usable in every scene that can host it.** "Add Webcam" places the existing default
DIRECTLY (no picker); the Windows camera picker runs only for the initial selection
(`_webcam == null`). Choosing a different camera in the picker swaps it app-wide via
`SwapWebcamIdentityAsync` (same path "Change Webcam…" uses). **Per-scene gate, not app-wide**
(TASK 26 superseded by creator directive 2026-09-17): `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && _webcam != null`
— a scene holding its own placement greys the row (one webcam per stream), but ANOTHER scene
holding one does not, so Chat's camera never greys Live; the greyed-row tooltip carries the reason
(`WebcamAddToolTip`, incl. the gentle "a second camera means you've graduated to OBS" line).
Removing a scene's placement **keeps the identity** (it's the app default; `_webcam` is never
nulled by removal) — Add stays offered for the same camera.
- **Startup resource lifecycle (2026-09-17):** the webcam is a resource the app *allocates and
locks*, validated at startup — this answers "why is the WebCam row greyed" from inside the app
instead of a DB spelunk. `MainViewModel.ValidateWebcamResourceStartupAsync` (fired fire-and-forget
@@ -435,10 +436,14 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
A session already running for the device (loaded identity's configs) counts as the lock, so the
pass never double-acquires: `CameraManager.IsRunning(deviceId)` = a session exists (started OR
still starting — a mid-start session is not "locked yet", it's in flight), and a rolled-back
(failed) session leaves the dictionary so the pass can retry. Test seams mirror `LayoutPathOverride`:
(failed) session leaves the dictionary so the pass can retry. After a successful single-cam lock,
the pass **adopts the camera as the app default** if no identity exists yet (fresh layout) — the
Web Cam layer is then offered in Live/Chat immediately (`CanAddWebcam` true); `IsRunning` skip
means a loaded identity whose session is already up never overwrites it. Test seams mirror
`LayoutPathOverride`:
`CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` let `WebcamStartupResourceTests`
drive the probe without real hardware (0-cam no-alarm, 1-cam locked, lock-fail → alert → clears on
retry). **Attribution correction:** the "NVIDIA Broadcast opens the webcam exclusively" failure
drive the probe without real hardware (0-cam no-alarm, 1-cam locked → identity adopted, lock-fail
→ alert → clears on retry). **Attribution correction:** the "NVIDIA Broadcast opens the webcam exclusively" failure
below is a *suspect-list* claim — `CameraConflictProbe` reads only running process names, no device
handles; contention symptoms fit shared-mode/bandwidth just as well (see `MyMistakes.md`).
The **YouTube Chat layer follows the same one-per-layout rule** (TASK 27):
+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;
}
}
@@ -52,6 +52,7 @@ public sealed class WebcamStartupResourceTests
Assert.Single(harness.Started);
Assert.Equal("dev1", harness.Started[0]);
Assert.NotNull(vm.StagedScene);
Assert.True(vm.CanAddWebcam, "the single camera becomes the app default — the Web Cam layer is offered");
});
private void RunZero() => RunWith(sceneBoilerplate: true, cameraCount: 0, failOnStart: false,
@@ -149,7 +150,9 @@ public sealed class WebcamStartupResourceTests
public string DeviceId { get; }
private readonly Harness _harness;
public event Action<VideoFrame>? FrameAvailable;
#pragma warning disable CS0067
public event Action<string>? SourceFailed;
#pragma warning restore CS0067
public FakeWebcamSource(string deviceId, Harness harness)
{