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.
This commit is contained in:
+29
-19
@@ -23,33 +23,41 @@ resource model): **the webcam is an app-level resource — ONE app-wide default
|
|||||||
every scene that can host it; it is NOT blocked by other scenes' placements.** Scope of this slice:
|
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.
|
the availability layer. TASK 26's app-wide gate is **superseded** by creator directive.
|
||||||
|
|
||||||
- **Per-scene gate:** `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && _webcam != null`.
|
- **Per-scene gate:** `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && IsWebcamAttainable`.
|
||||||
A scene already hosting the webcam stays gray (one webcam per stream); another scene holding it
|
A scene already hosting the webcam stays gray (one webcam per stream); another scene holding it
|
||||||
does NOT gray the row elsewhere.
|
does NOT gray the row elsewhere.
|
||||||
- **Add places the default directly:** when `_webcam != null`, "Add Webcam" creates the placement
|
- **Attainable = a live lock, not a saved identity** (creator refinement): `IsWebcamAttainable` =
|
||||||
with NO picker; the Windows picker runs only for the initial selection (`_webcam == null`;
|
`_webcam != null && CameraManager.IsRunning(_webcam.DeviceId)`. An identity whose camera is
|
||||||
choosing there swaps app-wide via the existing `SwapWebcamIdentityAsync`).
|
unplugged / can't start leaves the row greyed (tooltip "No webcam is currently available…"), and
|
||||||
|
it un-greys the moment a session is running (startup lock, first frame, picker + acquire).
|
||||||
|
- **Add places the default directly:** when attainable, "Add Webcam" creates the placement with NO
|
||||||
|
picker; the Windows picker runs only for the initial selection (`_webcam == null`).
|
||||||
- **Identity survives last-placement removal:** `RemoveElement` no longer nulls `_webcam` (that was
|
- **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.
|
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`,
|
- **Startup lays an app-wide BASE lock:** the single-camera branch ALWAYS acquires (the old
|
||||||
after the successful single-cam lock): a clean/empty layout now offers the Web Cam layer in
|
`IsRunning` skip is gone) — on an already-running session it just bumps the refcount, and that ref
|
||||||
Live/Chat immediately; `CameraManager.IsRunning` skip means a loaded identity already holding a
|
is the app's own hold. Result: removing every scene's placement leaves `RefCount = 1`, the session
|
||||||
session is never overwritten.
|
stays alive, and the row stays offered (the app default outlives the scenes). It still **adopts a
|
||||||
- **Dynamic why-gray tooltip:** `WebcamAddToolTip` (raised alongside the gates) — "Already in this
|
solo camera as the app default** when no identity exists, so a clean layout offers Web Cam at once.
|
||||||
scene — one webcam per stream. A second camera means you've graduated to OBS." / "No webcam
|
- **Dynamic why-gray tooltip:** `WebcamAddToolTip` (raised wherever the gate can flip: staging,
|
||||||
detected…" / "Adds the app default webcam to this scene."
|
removal, startup lock success, first frame, camera failure, identity swap) — "Already in this
|
||||||
- **Good Dog integration test** `WebcamMenuGateTests` rewritten for the new model (real app + temp
|
scene — one webcam per stream. A second camera means you've graduated to OBS." / "No webcam is
|
||||||
DB + camera seams): Chat's identity does NOT gray Live; Add in Live places the same `wc-1`
|
currently available — plug one in, or allow camera access in Windows." / "Adds the app default
|
||||||
without a picker; scene-with-placement stays gray; identity survives both removals (DB row count
|
webcam to this scene."
|
||||||
keeps 1). `WebcamStartupResourceTests` test 1 strengthened: after startup the single camera is
|
- **Good Dog integration tests** `WebcamMenuGateTests` (real app + temp DB + camera seams): (1) Chat's
|
||||||
adopted → `CanAddWebcam` true. **304/304 green, build 0 warnings.**
|
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, session kept by the
|
||||||
|
base lock). (2) **Negation:** identity loaded but its session can't start → row NOT offered.
|
||||||
|
`WebcamStartupResourceTests` test 1 strengthened: after startup the single camera is adopted →
|
||||||
|
`CanAddWebcam` true. **305 tests (304 pass + the known audio flake, see Landmines); build 0 warnings.**
|
||||||
|
|
||||||
**Task docs:** `TASKS.md` Open items + `ai.md` Webcam section updated (supersession recorded).
|
**Task docs:** `TASKS.md` Open items + `ai.md` Webcam section updated (supersession recorded).
|
||||||
|
|
||||||
## ✅ Earlier committed — webcam resource lifecycle (startup slice, `1e4017d`)
|
## ✅ Earlier committed — webcam resource lifecycle (startup slice, `1e4017d`)
|
||||||
|
|
||||||
Startup poll + tri-state (`ValidateWebcamResourceStartupAsync` after `LoadLayout`): 0 → run on,
|
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
|
no alarm; **1 → `AcquireAsync` as app-wide lock** (now always runs — see base-lock note above);
|
||||||
|
**≥2 → no auto-lock** (App Settings selector
|
||||||
next). Lock failure → **persistent red alert** (`WebcamLockAlert` in the Layers panel + Retry),
|
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.
|
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
|
`CameraManager.IsRunning(deviceId)` = session exists (started OR starting). Camera test seams
|
||||||
@@ -98,8 +106,10 @@ claim — `CameraConflictProbe` reads process names only, no device handles; do
|
|||||||
- Build/tests: **Windows dotnet host** (`/mnt/c/Program Files/dotnet/dotnet.exe`). 0 warnings —
|
- Build/tests: **Windows dotnet host** (`/mnt/c/Program Files/dotnet/dotnet.exe`). 0 warnings —
|
||||||
only `./scripts/verify.sh "<files>"`'s clean build counts. Running `ytLive.csproj` alone does NOT
|
only `./scripts/verify.sh "<files>"`'s clean build counts. Running `ytLive.csproj` alone does NOT
|
||||||
rebuild `ytLive.Tests.dll` — run the Tests csproj before `vstest`. Known audio flake:
|
rebuild `ytLive.Tests.dll` — run the Tests csproj before `vstest`. Known audio flake:
|
||||||
`Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` (float precision) — re-run if it trips alone;
|
`Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` — a stale loopback sample still in the pipe
|
||||||
unrelated to camera work.
|
when the mute flips (phase 2's `Assert.Equal(0f, f, 6)` sees 0.4). **Confirmed pre-existing and
|
||||||
|
currently deterministic in this environment: it fails on clean `9d00955` (stash test) and on the
|
||||||
|
working tree, alone and in-suite.** Unrelated to camera work; do not "fix" it inside a webcam slice.
|
||||||
- **Assert footguns in this repo's xUnit:** `Assert.Null(x, "msg")`/`Assert.Single(coll, "msg")` do
|
- **Assert footguns in this repo's xUnit:** `Assert.Null(x, "msg")`/`Assert.Single(coll, "msg")` do
|
||||||
NOT take a message — the 2-arg overloads mean something else (predicate/item). Empty message = 1 arg.
|
NOT take a message — the 2-arg overloads mean something else (predicate/item). Empty message = 1 arg.
|
||||||
- The webcam's real state lives in `%APPDATA%\ytLlive\ytLlive.db` (`layout.db` there is a 0-byte
|
- The webcam's real state lives in `%APPDATA%\ytLlive\ytLlive.db` (`layout.db` there is a 0-byte
|
||||||
|
|||||||
@@ -68,10 +68,11 @@
|
|||||||
- **Webcam resource lifecycle (2026-09-17)** — startup slice shipped (OS poll at start, single-camera
|
- **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
|
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
|
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.
|
default directly, offered only when a camera is ATTAINABLE (identity + live session, not just a
|
||||||
TASK 26's app-wide gate superseded by creator directive. Next slices (user-queued): multi-camera
|
saved identity), app base lock keeps the session after the last placement is removed, startup
|
||||||
webcam selector in the App Settings dialog (gear), static (+) catalog rows (Background,
|
adopts a solo camera as default. TASK 26's app-wide gate superseded by creator directive. Next
|
||||||
YouTubeEvent, quoted labels) with reason-greying.
|
slices (user-queued): multi-camera webcam selector in the App Settings dialog (gear), static (+)
|
||||||
|
catalog rows (Background, YouTubeEvent, quoted labels) with reason-greying.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -23,17 +23,22 @@ public partial class MainViewModel
|
|||||||
private DispatcherTimer? _webcamLockPollTimer;
|
private DispatcherTimer? _webcamLockPollTimer;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Add → Webcam menu item: the webcam is an app-level resource (one camera —
|
/// Add → Webcam menu item. The row is offered iff the SCENE has no webcam
|
||||||
/// the app default). The row is offered in any scene that doesn't already
|
/// config yet AND a camera is attainable — an app default is selected and
|
||||||
/// host it, wherever a camera is selected; it is NOT blocked by other scenes
|
/// its session is RUNNING right now (identity alone is not enough: the
|
||||||
/// holding their own placement (per-scene max, not app-wide).
|
/// device may have been unplugged, or the lock may still be failing, in
|
||||||
|
/// which case the row stays grey and the persistent alert says why).
|
||||||
|
/// Per-scene max is per-scene: another scene holding its own placement does
|
||||||
|
/// NOT grey this one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool CanAddWebcam => StagedScene is { WebcamConfig: null } && _webcam != null;
|
public bool CanAddWebcam => StagedScene is { WebcamConfig: null } && IsWebcamAttainable;
|
||||||
|
|
||||||
|
private bool IsWebcamAttainable => _webcam != null && _cameraManager.IsRunning(_webcam.DeviceId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Why the Webcam row is greyed, when it is — never a silent bool. The max-1
|
/// 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
|
/// reason gets the gentle "graduated to OBS" line; no attainable camera gets
|
||||||
/// own wording. Shown as the row's tooltip.
|
/// its own wording. Shown as the row's tooltip.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string WebcamAddToolTip
|
public string WebcamAddToolTip
|
||||||
{
|
{
|
||||||
@@ -41,12 +46,20 @@ public partial class MainViewModel
|
|||||||
{
|
{
|
||||||
if (StagedScene?.WebcamConfig != null)
|
if (StagedScene?.WebcamConfig != null)
|
||||||
return "Already in this scene — one webcam per stream. A second camera means you've graduated to OBS.";
|
return "Already in this scene — one webcam per stream. A second camera means you've graduated to OBS.";
|
||||||
if (_webcam == null)
|
if (!IsWebcamAttainable)
|
||||||
return "No webcam detected — plug one in, or allow camera access in Windows.";
|
return "No webcam is currently available — plug one in, or allow camera access in Windows.";
|
||||||
return "Adds the app default webcam to this scene.";
|
return "Adds the app default webcam to this scene.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-raise the rows that read live CameraManager session state: the offer
|
||||||
|
// flips the moment a session starts (lock acquired, first frame) or dies.
|
||||||
|
private void RaiseWebcamGateChanged()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(CanAddWebcam));
|
||||||
|
OnPropertyChanged(nameof(WebcamAddToolTip));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
|
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
|
||||||
/// webcam config (add one) or hides it (unhide — keeps the config row).
|
/// webcam config (add one) or hides it (unhide — keeps the config row).
|
||||||
@@ -193,6 +206,7 @@ public partial class MainViewModel
|
|||||||
if (_webcam?.DeviceId != deviceId) return;
|
if (_webcam?.DeviceId != deviceId) return;
|
||||||
WebcamError = null;
|
WebcamError = null;
|
||||||
WebcamLockAlert = null; // the camera is provably alive — alert resolved.
|
WebcamLockAlert = null; // the camera is provably alive — alert resolved.
|
||||||
|
RaiseWebcamGateChanged();
|
||||||
foreach (var scene in Scenes)
|
foreach (var scene in Scenes)
|
||||||
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
||||||
config.VideoImageSource = bitmap;
|
config.VideoImageSource = bitmap;
|
||||||
@@ -204,6 +218,7 @@ public partial class MainViewModel
|
|||||||
{
|
{
|
||||||
if (_webcam?.DeviceId != deviceId) return;
|
if (_webcam?.DeviceId != deviceId) return;
|
||||||
WebcamError = $"Webcam offline: {message}";
|
WebcamError = $"Webcam offline: {message}";
|
||||||
|
RaiseWebcamGateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -239,26 +254,23 @@ public partial class MainViewModel
|
|||||||
|
|
||||||
var device = cameras![0];
|
var device = cameras![0];
|
||||||
|
|
||||||
// A session already started/starting for this device (loaded identity)
|
// Lay the app-wide base lock. AcquireAsync on an already-running session
|
||||||
// means we already hold the lock — nothing to do.
|
// (loaded identity's configs) just bumps its refcount — that bump is the
|
||||||
if (_cameraManager.IsRunning(device.Id))
|
// app's own hold, so removing EVERY scene's placement keeps the session
|
||||||
{
|
// alive and the Web Cam row offered (the app default outlives the scenes).
|
||||||
WebcamLockAlert = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var started = await _cameraManager.AcquireAsync(device.Id);
|
var started = await _cameraManager.AcquireAsync(device.Id);
|
||||||
if (started)
|
if (started)
|
||||||
{
|
{
|
||||||
// The single camera becomes the app default so the Web Cam layer is
|
// The camera is now provably attainable: adopt the new identity when
|
||||||
// offered in the capabled scenes (Live/Chat) even on a clean layout.
|
// there wasn't one, then re-raise the offer either way
|
||||||
|
// (RaiseWebcamGateChanged) so an existing identity whose session was
|
||||||
|
// down un-greys the row the moment the lock is real.
|
||||||
if (_webcam == null)
|
if (_webcam == null)
|
||||||
{
|
{
|
||||||
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
|
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
|
||||||
OnPropertyChanged(nameof(CanChangeWebcam));
|
OnPropertyChanged(nameof(CanChangeWebcam));
|
||||||
OnPropertyChanged(nameof(CanAddWebcam));
|
|
||||||
OnPropertyChanged(nameof(WebcamAddToolTip));
|
|
||||||
}
|
}
|
||||||
|
RaiseWebcamGateChanged();
|
||||||
WebcamLockAlert = null;
|
WebcamLockAlert = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -344,6 +356,8 @@ public partial class MainViewModel
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RaiseWebcamGateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Change Webcam…" from the webcam's context menu: picker, then swap the
|
// "Change Webcam…" from the webcam's context menu: picker, then swap the
|
||||||
|
|||||||
@@ -418,12 +418,16 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
|
|||||||
DIRECTLY (no picker); the Windows camera picker runs only for the initial selection
|
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
|
(`_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**
|
`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`
|
(TASK 26 superseded by creator directive 2026-09-17): `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && IsWebcamAttainable`
|
||||||
— a scene holding its own placement greys the row (one webcam per stream), but ANOTHER scene
|
— 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
|
holding one does not, so Chat's camera never greys Live. **Attainable means a live lock, not just a
|
||||||
(`WebcamAddToolTip`, incl. the gentle "a second camera means you've graduated to OBS" line).
|
saved identity:** `IsWebcamAttainable` = `_webcam != null && CameraManager.IsRunning(_webcam.DeviceId)`
|
||||||
|
— an identity whose device was unplugged, or whose session can't start, leaves the row greyed with
|
||||||
|
`WebcamAddToolTip` = "No webcam is currently available…" (identity alone no longer suffices). The
|
||||||
|
max-1 reason gets 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
|
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.
|
nulled by removal) — Add stays offered for the same camera, because the app holds its own base lock
|
||||||
|
(below).
|
||||||
- **Startup resource lifecycle (2026-09-17):** the webcam is a resource the app *allocates and
|
- **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
|
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
|
instead of a DB spelunk. `MainViewModel.ValidateWebcamResourceStartupAsync` (fired fire-and-forget
|
||||||
@@ -433,14 +437,16 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
|
|||||||
(`WebcamLockAlert`, bottom of the Layers panel with a Retry button) re-polls every 5 s
|
(`WebcamLockAlert`, bottom of the Layers panel with a Retry button) re-polls every 5 s
|
||||||
(`_webcamLockPollTimer`) until a lock succeeds, and any first real frame also clears it; **≥2** →
|
(`_webcamLockPollTimer`) until a lock succeeds, and any first real frame also clears it; **≥2** →
|
||||||
deliberately no auto-lock — selection belongs to the App Settings dialog (gear) **next slice**.
|
deliberately no auto-lock — selection belongs to the App Settings dialog (gear) **next slice**.
|
||||||
A session already running for the device (loaded identity's configs) counts as the lock, so the
|
The single-camera branch **always acquires** (it no longer skips when configs already hold the
|
||||||
pass never double-acquires: `CameraManager.IsRunning(deviceId)` = a session exists (started OR
|
session): that acquire is the app's own BASE ref, so removing every scene's placement leaves
|
||||||
still starting — a mid-start session is not "locked yet", it's in flight), and a rolled-back
|
`RefCount = 1` and the session alive — the gate stays satisfied and the Web Cam row stays offered,
|
||||||
(failed) session leaves the dictionary so the pass can retry. After a successful single-cam lock,
|
i.e. the app default outlives the scenes that render it. Acquire on a running session just bumps
|
||||||
the pass **adopts the camera as the app default** if no identity exists yet (fresh layout) — the
|
the refcount. `CameraManager.IsRunning(deviceId)` = a session exists (started OR still starting —
|
||||||
Web Cam layer is then offered in Live/Chat immediately (`CanAddWebcam` true); `IsRunning` skip
|
a mid-start session is not "locked yet", it's in flight), and a rolled-back (failed) session leaves
|
||||||
means a loaded identity whose session is already up never overwrites it. Test seams mirror
|
the dictionary so the pass can retry. After a successful single-cam lock, the pass **adopts the
|
||||||
`LayoutPathOverride`:
|
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); an already-loaded identity is never
|
||||||
|
overwritten. Test seams mirror `LayoutPathOverride`:
|
||||||
`CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` let `WebcamStartupResourceTests`
|
`CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` let `WebcamStartupResourceTests`
|
||||||
drive the probe without real hardware (0-cam no-alarm, 1-cam locked → identity adopted, lock-fail
|
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
|
→ alert → clears on retry). **Attribution correction:** the "NVIDIA Broadcast opens the webcam exclusively" failure
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.IO;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ytLive.Models;
|
||||||
using ytLive.Services;
|
using ytLive.Services;
|
||||||
using ytLive.ViewModels;
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
@@ -28,29 +29,19 @@ public sealed class WebcamMenuGateTests
|
|||||||
_app.Run(Run);
|
_app.Run(Run);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WebcamNotOffered_WhenIdentityExists_ButNoCameraIsAttainable()
|
||||||
|
{
|
||||||
|
_app.Run(RunNotAttainable);
|
||||||
|
}
|
||||||
|
|
||||||
private readonly RealAppHost _app;
|
private readonly RealAppHost _app;
|
||||||
|
|
||||||
public WebcamMenuGateTests(RealAppHost app) => _app = app;
|
public WebcamMenuGateTests(RealAppHost app) => _app = app;
|
||||||
|
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
=> RunWith(failLock: false, sentinel: (vm, starting, live) =>
|
||||||
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
|
|
||||||
{
|
{
|
||||||
using (var schema = new ytLive.Services.LayoutStore(tempDb)) { }
|
|
||||||
SqliteConnection.ClearAllPools();
|
|
||||||
SeedDatabaseWithWebcam(tempDb);
|
|
||||||
|
|
||||||
window = new MainWindow();
|
|
||||||
var vm = (MainViewModel)window.DataContext;
|
|
||||||
|
|
||||||
var starting = vm.Scenes.Single(s => s.Name == "Starting");
|
|
||||||
var live = vm.Scenes.Single(s => s.Name == "Live");
|
|
||||||
|
|
||||||
vm.StagedScene = live;
|
vm.StagedScene = live;
|
||||||
Assert.Null(live.WebcamConfig);
|
Assert.Null(live.WebcamConfig);
|
||||||
Assert.True(vm.CanAddWebcam,
|
Assert.True(vm.CanAddWebcam,
|
||||||
@@ -74,13 +65,45 @@ public sealed class WebcamMenuGateTests
|
|||||||
Assert.True(vm.CanAddWebcam, "the app default is still selected — Add stays offered");
|
Assert.True(vm.CanAddWebcam, "the app default is still selected — Add stays offered");
|
||||||
|
|
||||||
vm.SaveLayoutNow();
|
vm.SaveLayoutNow();
|
||||||
using (var connection = new SqliteConnection($"Data Source={tempDb}"))
|
using (var connection = new SqliteConnection($"Data Source={MainViewModel.LayoutPathOverride}"))
|
||||||
{
|
{
|
||||||
connection.Open();
|
connection.Open();
|
||||||
using var webcams = connection.CreateCommand();
|
using var webcams = connection.CreateCommand();
|
||||||
webcams.CommandText = "SELECT COUNT(*) FROM Webcam;";
|
webcams.CommandText = "SELECT COUNT(*) FROM Webcam;";
|
||||||
Assert.Equal(1L, (long)webcams.ExecuteScalar()!);
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -149,15 +172,21 @@ public sealed class WebcamMenuGateTests
|
|||||||
private sealed class FakeFrameSource : ICameraFrameSource
|
private sealed class FakeFrameSource : ICameraFrameSource
|
||||||
{
|
{
|
||||||
public string DeviceId { get; }
|
public string DeviceId { get; }
|
||||||
|
private readonly bool _failLock;
|
||||||
public event Action<VideoFrame>? FrameAvailable;
|
public event Action<VideoFrame>? FrameAvailable;
|
||||||
#pragma warning disable CS0067
|
#pragma warning disable CS0067
|
||||||
public event Action<string>? SourceFailed;
|
public event Action<string>? SourceFailed;
|
||||||
#pragma warning restore CS0067
|
#pragma warning restore CS0067
|
||||||
|
|
||||||
public FakeFrameSource(string deviceId) => DeviceId = deviceId;
|
public FakeFrameSource(string deviceId, bool failLock = false)
|
||||||
|
{
|
||||||
|
DeviceId = deviceId;
|
||||||
|
_failLock = failLock;
|
||||||
|
}
|
||||||
|
|
||||||
public Task StartAsync()
|
public Task StartAsync()
|
||||||
{
|
{
|
||||||
|
if (_failLock) throw new InvalidOperationException("Camera is in use by another app.");
|
||||||
FrameAvailable?.Invoke(new VideoFrame(2, 2, new byte[16]));
|
FrameAvailable?.Invoke(new VideoFrame(2, 2, new byte[16]));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user