From ef710c553730acbe68c9a11816dec3dfdecf241e Mon Sep 17 00:00:00 2001 From: gramps Date: Mon, 24 Aug 2026 08:56:17 -0700 Subject: [PATCH] Webcam menu gates on the app-wide identity (TASK 26) --- HANDOFF.md | 60 ++++++-------- MainWindow.xaml | 2 +- TASKS.md | 1 + ViewModels/MainViewModel.cs | 16 ++-- ai.md | 10 +-- ytLive.Tests/WebcamMenuGateTests.cs | 123 ++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 49 deletions(-) create mode 100644 ytLive.Tests/WebcamMenuGateTests.cs diff --git a/HANDOFF.md b/HANDOFF.md index f00be99..91736a3 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,54 +1,44 @@ # HANDOFF — Session State ## Branch -`task25-backgrounds` @ `7dd140a` — **committed and pushed** (includes TASK 24 `b400ab5`). Working tree clean. +`task26-webcam-menu-gate` @ working tree — **code + docs complete, NOT yet committed** (commit on your word). Parent: `task25-backgrounds` @ `7dd140a`, committed and pushed, clean. -## Shipped: TASK 25 background consolidation (+ TASK 24 toasts beneath it) +## Shipped: TASK 26 — webcam (+) menu gates on the app-wide identity -Locked model (user directive): exactly ONE "Background" per screen at index 0, non-deletable/ -non-reorderable, NOT in the (+) menu; capture context-sensitivity only on the Live screen -(Show Desktop/monitor switching kept intact); exactly five default images in the DB; stale rows cleaned. +Creator's visual pass of TASK 25 came back green except one item: the (+) menu's Webcam row wasn't +greyed out when a webcam was already deployed in another scene. Root cause: `CanAddWebcamToStagedScene` +only checked the staged scene's config; the truth is the `_webcam` singleton. -1. **Seeders consolidated** (`ViewModels/MainViewModel.cs`): five clone seeders + `EnforceBackgroundPolicy` - deleted. Now: `EnsureBackground` → `CreateBackground(name)` — Live = DisplayCapture row, others = - static Background art row, all named "Background". `NormalizeBackgrounds(scenes)` heals any layout: - correct-flavor keep (in-place Type conversion re-asserts `IsBackground` because `Source.Type`'s setter - derives it), duplicates dropped, missing seeded + INSERTED, index 0, non-canonical stripped. - `HealBackgrounds()` = Normalize + `StampDefaultBackgroundAsset` (pack-resource art via `AddAsset`; - custom Browse wins). Called from LoadLayout, StagedScene setter, ReacquireScreenCaptures. -2. **Settings purge:** `LayoutStore.Save` deletes orphaned `BackgroundUseDefault_{id}`/`BackgroundPath_{id}` - keys (ids gone from Source). Fixes the real DB's 16 stale keys. -3. **UI:** (+) menu Screen+Background items removed (`MainWindow.xaml`); `AddSource` refuses capture/background - params. `CanChangeBackground` = staged-is-Live → gates preview CanvasGrid menu directly and the layer-row - menu via new MultiBinding converter `Helpers/AllTrueToVisibilityConverter.cs` (row Tag carries VM bool). -4. **Mini rule:** minis never render captures. `LoadBackgroundImage` is flavor-blind (`IsBackground`) — fixes - green mini bug (was `Type==Background`, missed Live's DisplayCapture row → placeholder twin at old :951 too); - `RefreshSnapshotsAsync` shows green placeholder only while Live is staged, otherwise its stored art. -5. **Tests:** suite 221/218 → **223 total, 220 pass**. The ONE integration test: - `BackgroundHealIntegrationTests.Dirty_Layout_Heals_To_One_Locked_Background_Per_Scene` (seeds dirty temp DB, - real window, asserts heal + save purges keys). Unit: SceneCatalogTests rewritten (NormalizeBackgrounds heal + - strip + per-scene flavor), BackgroundTests empty-scene flavor updated, SourceNamingTests filters IsBackground. - The two stale landmine tests (#2/#3) are healed by this branch — see below. -6. **Docs same commit:** ai.md (TASK 25 model section replaces backdrop-era text), TASKS.md TASK 25 entry, - Services/index.md (LayoutStore purge), Helpers/index.md (new converter), this file. -7. Build **0 warnings**. +1. **Renamed + regated:** `CanAddWebcamToStagedScene` → **`CanAddWebcam`** = + `StagedScene != null && _webcam == null` (`MainViewModel.cs:308`). Raised at all five sites: + StagedScene setter, `ReacquireWebcam` (layout load), `OnElementsChanged`, `_webcam` create + (`AddWebcamToStagedSceneAsync`), last-config removal (`RemoveElement`). +2. **XAML:** the Webcam MenuItem's `IsEnabled` binding renamed to match; tooltip verbatim. +3. **Test (Good Dog Rule — ONE):** `WebcamMenuGateTests.CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity` + — real window + temp DB seeded with a `Webcam` row + one config in Starting; asserts greyed while + Live is staged (the old gate's false positive), greyed while Starting staged, re-enabled after + `RemoveSourceCommand` drops the last config, and zero `Webcam` rows after save. +4. **Suite: 224 total, 223 pass.** The only failure is the known pre-existing + `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` (unrelated). + Build 0 warnings. +5. **Docs same commit:** ai.md (gate rule + rename; dropped the stale "empty-canvas right-click + Show Webcam" claim — that XAML never shipped), TASKS.md TASK 3 row 27, this file. +6. **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` are wired + in the VM but bound nowhere — audit item, separate branch if wanted. ## ⚠️ Landmines - **User verdict (2026-08-23):** "The AI wrote fucked up code after repeatedly and intentionally misunderstanding instructions." Extends the ai.md 2026-08-22 audit flag — trust nothing from those sessions without manual verification. -- **Pre-existing test failure is now ONE (not caused by these branches):** - 1. `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` - - Former #2 SourceNaming / #3 SceneCatalog policy tests were repaired BY TASK 25 (expectation updates to the locked model) — not skipped, not deleted. +- **Pre-existing test failure is ONE** (`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`) — not caused by this branch. - **Real-MainWindow tests MUST set `LayoutPathOverride`** (ai.md) — DB pollution wiped real data once. - **`Source.Type` setter derives `IsBackground`** (Models/Source.cs:40) — any Type change must re-set IsBackground after. -- **vstest quirk:** only one `--tests:` param allowed; plain substrings work (`--tests:BackgroundHeal`), alternation regexes silently match nothing. +- **vstest quirk:** only one `--tests:` param allowed; plain substrings work, alternation regexes silently match nothing. - Notification.Wpf gotchas (ai.md/TASKS.md): area routing by XAML `Name`; unknown AreaName silently drops; `NeverExpires()` = `TimeSpan.MaxValue`; `ToHex()` = `#AARRGGBB`. ## Next steps 1. Commit this branch (code + docs, one commit), then merge/PR per Good Dog Rule (one integration test ✓). -2. User visual pass on Windows: minis show art when unstaged (Live included), green placeholder while staged; - no Game Capture rows in layer lists; (+) menu trimmed; capture controls only on staged Live. -3. Then relaunch app once so the user's real `%APPDATA%` DB self-heals on load + first save purges stale keys. +2. Creator visual pass on Windows: add a webcam to any scene → the (+) menu's Webcam row greys out everywhere; remove it → row re-enables. +3. Then merge `task25-backgrounds` → main (still pending from last session), relaunch app once so the real `%APPDATA%` DB self-heals. ## Build ```bash diff --git a/MainWindow.xaml b/MainWindow.xaml index f67cafc..1feb2d8 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -276,7 +276,7 @@ diff --git a/TASKS.md b/TASKS.md index 2372b1e..cd5b67c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -128,6 +128,7 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional 26. ✅ **Left panel spacing + context menu reorder** — HR margins matched, Row 4 changed from `*` to `Auto`. Context menu reordered: Border Thickness first, then Opacity, Color, Effect. 21. ✅ **Logo + richer in-app About (2026-08-13, queued → 2026-08-14 SHIPPED)** — the ytLlive wordmark in the top bar opens the About overlay (already wired); the About overlay is now the **creator hub**: the real logo (the "llama fortnite superman logo" from the creator's vault, copied to `Assets/llama-logo.png` — the creator's own art, no third-party license), plus **creator-hub links** — llama chile shop on YouTube (`MainViewModel.ChannelUrl`), Mastodon (`https://mastodon.llamachile.tube/@gramps`), **Buy me a coffee** (`https://buymeacoffee.com/llamachiley` — live), and **Unlock Premium** (greyed "coming soon" — the **billing** product URL is a tabled seam, `PremiumUrl`, until TASK 10 picks the subscription provider). A **Licenses & legal** button flips the About overlay to an in-app scrolling panel that loads the full `THIRD-PARTY-NOTICES.txt` text (`MainViewModel.ShowLicenses` reads the shipped file from the executable directory on first open; graceful "not found" fallback — **never the OS viewer, everything stays in-app**); "← Back to About" returns. The About overlay is also the in-app home of the notices — the top-bar About button that opened the file in the OS viewer was removed on 2026-08-13 for exactly this reason. Integration test (Good Dog Rule — ONE): `AboutHubTests.About_Opens_InApp_Licensing_Loads_Shipped_Notices` drives the real window + VM, asserting the hub opens, the link URLs are real, the licensing panel loads the shipped notices text (contains "Third-Party Notices" + "LGPL"), and back returns to the hub. 174 tests passing, 0 warnings 22. ✅ **Voice filters on the mic channel (2026-08-13 queued → SHIPPED 2026-08-14 inside TASK 9, the audio milestone)** — the standard four applied to the sound input path (before the meter/encoder mix): **bass boost, treble, noise suppression, compressor** (set decided with the creator 2026-08-13). Noise suppression = a **pure-C# noise gate** (creator chose over RNNoise / a second ffmpeg `afftdn` pipe, 2026-08-14 — KISS). Always-on — no UI knobs; the mic stays the creator's single audio control +27. ✅ **Webcam row gates on the app-wide identity (2026-08-24, TASK 26)** — the (+) menu's Webcam item greys out whenever a webcam exists **anywhere** (`CanAddWebcam` = `StagedScene != null && _webcam == null`; renamed from `CanAddWebcamToStagedScene` whose per-scene rule let a second picker run from a scene lacking the config), raised at both `_webcam` mutation sites (create / last-config removal) + scene staging + elements change. Creator's visual pass found it: minis/no-capture-rows/capture-controls all good. ONE integration test `WebcamMenuGateTests.CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity` (real window + temp DB seeded with a webcam in Starting; asserts greyed while Live staged, re-enabled after `RemoveSourceCommand` clears the last config + the `Webcam` DB row). Stale map fixed in the same commit: ai.md's "empty-canvas right-click Show Webcam" claim dropped — that XAML never shipped (`ShowWebcamCommand`/`CanShowWebcamInStagedScene` are wired but unbound dead code, audit item). 224 tests (223 pass; the pre-existing AudioPipelineTests failure is unrelated) ### The Minimal Source Set (design decision — do not expand casually) diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 50da029..cf0212a 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -212,7 +212,7 @@ public class MainViewModel : ViewModelBase OnPropertyChanged(nameof(ShowPreviewPlaceholder)); OnPropertyChanged(nameof(ShowSourcesEmptyHint)); OnPropertyChanged(nameof(CanChangeBackground)); - OnPropertyChanged(nameof(CanAddWebcamToStagedScene)); + OnPropertyChanged(nameof(CanAddWebcam)); OnPropertyChanged(nameof(CanShowWebcamInStagedScene)); OnPropertyChanged(nameof(IsCountdownAvailable)); UpdateActiveBackground(); @@ -303,8 +303,9 @@ public class MainViewModel : ViewModelBase } } - /// The Add → Webcam menu item: enabled when the active scene doesn't show the webcam yet. - public bool CanAddWebcamToStagedScene => StagedScene?.WebcamConfig == null; + /// The Add → Webcam menu item: enabled only while no webcam exists anywhere — + /// one camera identity app-wide; re-adding always opens the picker. + public bool CanAddWebcam => StagedScene != null && _webcam == null; // ─── Transitions (TASK 19/23) ─── @@ -344,8 +345,6 @@ public class MainViewModel : ViewModelBase _transition.Start(SelectedTransitionType, TransitionDurationMs, null); } - /// The Add → Webcam menu item: enabled when the active scene doesn't show the webcam yet. - /// /// 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). @@ -1815,7 +1814,7 @@ public class MainViewModel : ViewModelBase _ = _cameraManager.ReleaseAllAsync(previousDevice); OnPropertyChanged(nameof(CanChangeWebcam)); - OnPropertyChanged(nameof(CanAddWebcamToStagedScene)); + OnPropertyChanged(nameof(CanAddWebcam)); if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return; if (previousDevice != newDevice) @@ -2264,7 +2263,7 @@ public class MainViewModel : ViewModelBase if (e.OldItems != null) foreach (SceneElement element in e.OldItems) element.PropertyChanged -= OnElementPropertyChanged; - OnPropertyChanged(nameof(CanAddWebcamToStagedScene)); + OnPropertyChanged(nameof(CanAddWebcam)); ScheduleSave(); } @@ -2459,6 +2458,7 @@ public class MainViewModel : ViewModelBase { _webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName }; OnPropertyChanged(nameof(CanChangeWebcam)); + OnPropertyChanged(nameof(CanAddWebcam)); } else if (_webcam.DeviceId != device.Id) { @@ -2690,7 +2690,7 @@ public class MainViewModel : ViewModelBase { _webcam = null; OnPropertyChanged(nameof(CanChangeWebcam)); - OnPropertyChanged(nameof(CanAddWebcamToStagedScene)); + OnPropertyChanged(nameof(CanAddWebcam)); OnPropertyChanged(nameof(CanShowWebcamInStagedScene)); } diff --git a/ai.md b/ai.md index bf9d59d..973f35c 100644 --- a/ai.md +++ b/ai.md @@ -313,11 +313,11 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr 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 greys out when the **active** - scene already has a config (`CanAddWebcamToActiveScene`); showing a hidden webcam reuses the - existing config (`CanShowWebcamInActiveScene` / empty-canvas right-click "Show Webcam" — which, on - a config-less canvas, delegates to Add Webcam and so also picks). **Removing the last webcam - config anywhere clears the identity** (`_webcam = null`), which also drops "Change Webcam…". + 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. - **Round→rect restores the aspect (persisted, schema v4):** `SceneElement.ToggleClipShape()` snapshots the rectangular Width/Height into public `RectWidth`/`RectHeight` before going Round and restores them when switching back — otherwise the Round resize lock (square) would leave a square diff --git a/ytLive.Tests/WebcamMenuGateTests.cs b/ytLive.Tests/WebcamMenuGateTests.cs new file mode 100644 index 0000000..069ea2e --- /dev/null +++ b/ytLive.Tests/WebcamMenuGateTests.cs @@ -0,0 +1,123 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Models; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// 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). +/// +[Collection("RealApp")] +public sealed class WebcamMenuGateTests +{ + [Fact] + public void CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity() + { + _app.Run(Run); + } + + private readonly RealAppHost _app; + + public WebcamMenuGateTests(RealAppHost app) => _app = app; + + private void Run() + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-wcgate-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + 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 => SceneCatalog.Is(s.Name, SceneCatalog.Starting)); + var live = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.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.True(vm.CanAddWebcam, + "last webcam config anywhere was removed — identity cleared, picker may run again"); + + vm.SaveLayoutNow(); + using (var connection = new SqliteConnection($"Data Source={tempDb}")) + { + connection.Open(); + using var webcams = connection.CreateCommand(); + webcams.CommandText = "SELECT COUNT(*) FROM Webcam;"; + Assert.Equal(0L, (long)webcams.ExecuteScalar()!); + } + } + finally + { + window?.Close(); + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } + } + } + + /// 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". + 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(); + } + } +}