From 708b336da7f78af9103f72feaccb4a23c5d77068 Mon Sep 17 00:00:00 2001 From: gramps Date: Mon, 24 Aug 2026 09:08:03 -0700 Subject: [PATCH] YouTube Chat layer: one-per-layout gate + legacy label heal (TASK 27) --- HANDOFF.md | 42 ++++------ MainWindow.xaml | 3 +- TASKS.md | 1 + ViewModels/MainViewModel.cs | 21 ++++- ai.md | 5 ++ ytLive.Tests/ChatLayerGateTests.cs | 124 +++++++++++++++++++++++++++++ 6 files changed, 169 insertions(+), 27 deletions(-) create mode 100644 ytLive.Tests/ChatLayerGateTests.cs diff --git a/HANDOFF.md b/HANDOFF.md index 91736a3..61fc082 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,44 +1,36 @@ # HANDOFF — Session State ## Branch -`task26-webcam-menu-gate` @ working tree — **code + docs complete, NOT yet committed** (commit on your word). Parent: `task25-backgrounds` @ `7dd140a`, committed and pushed, clean. +`task26-webcam-menu-gate` @ `ef710c5` — TASK 26 committed (not pushed). Working tree now carries **TASK 27, NOT yet committed** (commit on your word). Parent chain: `task25-backgrounds` @ `7dd140a` (pushed), still unmerged to main. + +## Shipped: TASK 27 — YouTube Chat layer gate + legacy label heal + +Creator follow-up to TASK 26: "do the same thing with the YouTube chat layer" + make the Layers-row label match the (+) picklist. + +1. **Gate:** `CanAddYouTubeChat` = `StagedScene != null && !Scenes.Any(s => s.Elements.OfType().Any(e => e.Type == SourceType.ChatBox))` — one chat layer per layout; the (+) item greys out while any scene carries one. Raised in the StagedScene setter + `OnElementsChanged`; `AddSource` refuses a second as defense in depth. XAML `IsEnabled` + tooltip "One chat layer at a time — it's already in your stream". +2. **Label heal:** layers added by commit `65641d8` were named "Chat"; LoadLayout heals exactly that un-renamed default to "YouTube Chat" so the Layers row matches the picklist. Creator renames untouched; idempotent. +3. **Test (Good Dog Rule — ONE):** `ChatLayerGateTests.LegacyChatName_Heals_And_CanAddYouTubeChat_Gates_On_The_Existing_Layer` — real window + temp DB with a legacy "Chat" ChatBox row in Starting: healed on load, persisted on save, greyed cross-scene + in-scene, re-enabled after `RemoveSourceCommand`. +4. **Suite: 225 total, 224 pass** — only failure is the known pre-existing `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`. Build 0 warnings. +5. **Docs same commit:** ai.md (chat rule + heal next to the webcam gate), TASKS.md TASK 3 row 28, this file. ## Shipped: TASK 26 — webcam (+) menu gates on the app-wide identity -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. **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. +`CanAddWebcamToStagedScene` → **`CanAddWebcam`** = `StagedScene != null && _webcam == null` (`MainViewModel.cs:308`), raised at StagedScene setter / `ReacquireWebcam` / `OnElementsChanged` / `_webcam` create / last-config removal; XAML binding renamed, tooltip verbatim. ONE integration test `WebcamMenuGateTests.CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity`. Committed `ef710c5` with ai.md/TASKS row 27/HANDOFF docs. ## ⚠️ 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 ONE** (`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`) — not caused by this branch. +- **Pre-existing test failure is ONE** (`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`) — not caused by these branches. - **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, 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`. +- **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` are wired in the VM but bound nowhere — audit item, separate branch if wanted. ## Next steps -1. Commit this branch (code + docs, one commit), then merge/PR per Good Dog Rule (one integration test ✓). -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. +1. Commit TASK 27 (code + docs, one commit), push both branches. +2. Creator visual pass on Windows: add YouTube Chat to any scene → (+) item greys everywhere; legacy "Chat"-named row (if present) shows as "YouTube Chat"; remove → re-enables. +3. Merge `task25-backgrounds` + `task26-webcam-menu-gate` + this branch to main per Good Dog Rule (one integration test each ✓); relaunch app once so the real `%APPDATA%` DB self-heals (backgrounds + chat label). ## Build ```bash diff --git a/MainWindow.xaml b/MainWindow.xaml index 1feb2d8..5e69190 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -286,7 +286,8 @@ + IsEnabled="{Binding CanAddYouTubeChat}" + ToolTip="One chat layer at a time — it's already in your stream"/> public bool CanAddWebcam => StagedScene != null && _webcam == null; + /// The Add → YouTube Chat menu item: enabled only while no chat layer exists + /// anywhere — one chat overlay per layout. + public bool CanAddYouTubeChat + => StagedScene != null + && !Scenes.Any(s => s.Elements.OfType().Any(e => e.Type == SourceType.ChatBox)); + // ─── Transitions (TASK 19/23) ─── public TransitionType SelectedTransitionType @@ -1770,6 +1777,15 @@ public class MainViewModel : ViewModelBase ClampWebcamToBounds(config, scene.Name); HealLegacySquareRect(config); } + + // One-time heal: ChatBox layers created before the "YouTube Chat" + // default still carry the old "Chat" name — creator renames untouched. + foreach (var scene in Scenes) + foreach (var source in scene.Elements.OfType()) + if (source.Type == SourceType.ChatBox + && string.Equals(source.Name, "Chat", StringComparison.OrdinalIgnoreCase)) + source.Name = "YouTube Chat"; + AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded"); } finally @@ -2264,6 +2280,7 @@ public class MainViewModel : ViewModelBase foreach (SceneElement element in e.OldItems) element.PropertyChanged -= OnElementPropertyChanged; OnPropertyChanged(nameof(CanAddWebcam)); + OnPropertyChanged(nameof(CanAddYouTubeChat)); ScheduleSave(); } @@ -2383,9 +2400,11 @@ public class MainViewModel : ViewModelBase // The (+) menu only adds regular layers. The Background is a locked // per-screen layer managed by EnsureBackground/NormalizeBackgrounds — - // never user-created, never deleted. + // never user-created, never deleted. YouTube Chat is one-per-layout. if (sourceType is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.Background) return; + if (sourceType == SourceType.ChatBox && !CanAddYouTubeChat) + return; var source = new Source { diff --git a/ai.md b/ai.md index 973f35c..6b1a1cd 100644 --- a/ai.md +++ b/ai.md @@ -318,6 +318,11 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr 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. + The **YouTube Chat layer follows the same one-per-layout rule** (TASK 27): + `CanAddYouTubeChat` greys out the (+) item while any scene carries a ChatBox source (raised on + staging + elements change; `AddSource` refuses a second as defense in depth). Chat layers created + before the "YouTube Chat" default still carried the legacy name "Chat" (commit `65641d8` era) — + LoadLayout heals exactly that un-renamed default to "YouTube Chat"; creator renames are untouched. - **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/ChatLayerGateTests.cs b/ytLive.Tests/ChatLayerGateTests.cs new file mode 100644 index 0000000..77358a3 --- /dev/null +++ b/ytLive.Tests/ChatLayerGateTests.cs @@ -0,0 +1,124 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Models; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// TASK 27 integration: the (+) menu's YouTube Chat row gates on one chat layer +/// per layout (same rule as the webcam's app-wide identity), and ChatBox layers +/// created before the "YouTube Chat" default (legacy name "Chat") heal to the +/// picklist's label on load — creator renames stay untouched. +/// Real-App + temp-DB pattern (see WebcamMenuGateTests). +/// +[Collection("RealApp")] +public sealed class ChatLayerGateTests +{ + [Fact] + public void LegacyChatName_Heals_And_CanAddYouTubeChat_Gates_On_The_Existing_Layer() + { + _app.Run(Run); + } + + private readonly RealAppHost _app; + + public ChatLayerGateTests(RealAppHost app) => _app = app; + + private void Run() + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-chatgate-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + var window = default(MainWindow); + try + { + using (var schema = new ytLive.Services.LayoutStore(tempDb)) { } + SqliteConnection.ClearAllPools(); + SeedDatabaseWithLegacyChatLayer(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)); + + var chatLayer = Assert.Single(starting.Elements.OfType() + .Where(s => s.Type == SourceType.ChatBox).ToList()); + Assert.Equal("YouTube Chat", chatLayer.Name); + + vm.StagedScene = live; + Assert.False(vm.CanAddYouTubeChat, + "chat layer exists in another scene — Add YouTube Chat must be greyed out"); + + vm.StagedScene = starting; + Assert.False(vm.CanAddYouTubeChat, "staged scene itself has the chat layer"); + + vm.SaveLayoutNow(); + using (var connection = new SqliteConnection($"Data Source={tempDb}")) + { + connection.Open(); + using var names = connection.CreateCommand(); + names.CommandText = + "SELECT Name FROM Source WHERE Type = 'ChatBox';"; + Assert.Equal("YouTube Chat", (string)names.ExecuteScalar()!); + } + + vm.RemoveSourceCommand.Execute(chatLayer); + Assert.True(vm.CanAddYouTubeChat, + "the only chat layer was removed — the (+) item re-enables"); + } + finally + { + window?.Close(); + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } + } + } + + /// Five canonical scenes + a ChatBox layer still carrying the + /// pre-rename default name "Chat" in Starting. + private static void SeedDatabaseWithLegacyChatLayer(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 background = connection.CreateCommand()) + { + background.CommandText = + @"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, X, Y, Width, Height, IsBackground, SortOrder) + VALUES ('bg-1', 'scene-starting', 'Background', 'Background', 1, 0, 0, 1920, 1080, 1, 0);"; + background.ExecuteNonQuery(); + } + + using (var chat = connection.CreateCommand()) + { + chat.CommandText = + @"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, X, Y, Width, Height, IsBackground, SortOrder) + VALUES ('chat-1', 'scene-starting', 'ChatBox', 'Chat', 1, 1400, 20, 500, 648, 0, 1);"; + chat.ExecuteNonQuery(); + } + } +}