diff --git a/HANDOFF.md b/HANDOFF.md index 56c8127..bbc01d0 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,30 +1,54 @@ # HANDOFF — Session State ## Branch -`task24-notifications` (from `main` @ `809c066`, which is pushed) — **dirty, uncommitted**, TASK 24 complete and green. +`task25-backgrounds` (from `task24-notifications` @ `b400ab5`, committed) — **dirty, uncommitted**, TASK 25 complete and green. -## Uncommitted work: TASK 24 toast notifications — COMPLETE, ready to commit +## Uncommitted work: TASK 25 background consolidation — COMPLETE, ready to commit -1. **Library:** `Notification.Wpf` 11.0.0 added to `ytLive.csproj` (MIT; notice #10 in `THIRD-PARTY-NOTICES.txt`). Restore pulls `Notification.CoreUI` 11.0.0 + `Microsoft.Xaml.Behaviors.Wpf` 1.1.142 + DI.Abstractions 10.0.8. -2. **Seam:** `Services/INotificationService.cs` (`Show(title, message, AppNotificationSeverity)` + Info/Success/Warning/Error extensions); `Services/NotificationService.cs` impl — pure `BuildRequest` mapping + `Cards` tint table, Dispatcher-marshalled Show. Host: `NotificationArea x:Name="ToastArea"` last child of MainWindow root grid (`RowSpan=4`, bottom-right above footer, `Margin="0,0,12,96"`). -3. **Call sites** (`ViewModels/MainViewModel.cs`): 5 MessageBox migrations (webcam ×2 → Warning, image read → Warning, sign-in unsuccessful → Warning / failed → Error) + 7 log-only promotions (go-live prep ×3 Error, frame-pump death Error, mic-missing Warning, premium-lapse Warning, saved-session sign-out Info). Offline license re-validation skip left log-only on purpose; SocialsDialog slot-delete confirm stays modal. `_notifications` field initialized at declaration (VM built from XAML). -4. **Tests:** unit `NotificationServiceTests` (8 cases: routing/lifetimes/tints) + the ONE integration `NotificationAreaIntegrationTests.Info_Auto_Dismisses_And_Error_Sticks` (real window-hosted area; visual-tree reads marshalled via `_app.Run`). -5. Build **0 warnings**; **221 total, 218 pass** — only the 3 pre-existing failures below. +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. + +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**. ## ⚠️ 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 failures are THREE (not caused by this branch):** +- **Pre-existing test failure is now ONE (not caused by these branches):** 1. `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` - 2. `SourceNamingTests.Duplicate_Sources_Get_Next_Free_Numbered_Name` (StagedScene seeds a "Game Capture" the test doesn't filter) - 3. `SceneCatalogTests.EnforceBackgroundPolicy_Sets_Flag_For_All_Scenes` (asserts against elements never inserted) + - Former #2 SourceNaming / #3 SceneCatalog policy tests were repaired BY TASK 25 (expectation updates to the locked model) — not skipped, not deleted. - **Real-MainWindow tests MUST set `LayoutPathOverride`** (ai.md) — DB pollution wiped real data once. -- Notification.Wpf gotchas (recorded in ai.md/TASKS.md): area routing by XAML `Name`; unknown AreaName silently drops; `NeverExpires()` = `TimeSpan.MaxValue`; `ToHex()` = `#AARRGGBB`. +- **`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. +- 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 + notices, one commit), then merge/PR per Good Dog Rule (one integration test ✓). -2. User visual pass on Windows: toasts look right bottom-right, error sticky with close button, no overlap with footer/chat. -3. Then the stale background tests repair queue (unit-level): SourceNaming filter + SceneCatalog policy test. +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. ## Build ```bash diff --git a/Helpers/AllTrueToVisibilityConverter.cs b/Helpers/AllTrueToVisibilityConverter.cs new file mode 100644 index 0000000..07802f3 --- /dev/null +++ b/Helpers/AllTrueToVisibilityConverter.cs @@ -0,0 +1,14 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace ytLive.Helpers; + +public class AllTrueToVisibilityConverter : IMultiValueConverter +{ + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + => values.All(v => v is true) ? Visibility.Visible : Visibility.Collapsed; + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/Helpers/index.md b/Helpers/index.md index f008edc..0de6cb2 100644 --- a/Helpers/index.md +++ b/Helpers/index.md @@ -12,6 +12,7 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv | `TokenStore.cs` | DPAPI-protected OAuth session persistence (`%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope); `Save`/`Load`/`Clear` — sign-in survives restarts | | `ImageCache.cs` | Image byte caching (assets live in the DB) | | `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings | +| `AllTrueToVisibilityConverter.cs` | Multi-value AND-gate → Visible; gates the layer-row capture menu items on row-IsBackground + staged-scene-is-Live (TASK 25) | | `CameraConflictProbe.cs` | Best-effort diagnostic: when a camera won't start, enumerates running processes known to hold cameras (OBS, Zoom, Teams, NVIDIA Broadcast, browsers, etc.) — Windows doesn't expose "which process has this device" via any public API, so this is a suspect list, not a verdict | Related: [`Themes/Controls.xaml`](../Themes/Controls.xaml) styles the lists this diff --git a/MainWindow.xaml b/MainWindow.xaml index f9c973a..f67cafc 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -41,6 +41,8 @@ xmlns:Helpers="clr-namespace:ytLive.Helpers"/> + @@ -276,8 +278,6 @@ - - - + @@ -323,11 +324,30 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Services/LayoutStore.cs b/Services/LayoutStore.cs index d33a4d1..c80f98f 100644 --- a/Services/LayoutStore.cs +++ b/Services/LayoutStore.cs @@ -390,7 +390,7 @@ public class LayoutStore : IDisposable // v5 → v6: Scene gains HasBackground. Added columns default to true; the // ONE-TIME backfill (runs only when the column is first added) turns every // non-Live canonical scene off and drops their background sources — the fix for - // DBs saved before the Live-only policy. MainViewModel.EnforceBackgroundPolicy + // DBs saved before the Live-only policy. MainViewModel.NormalizeBackgrounds // re-runs the same normalization on every load for DBs that miss the backfill. private void MigrateSceneTable() { @@ -1059,6 +1059,19 @@ public class LayoutStore : IDisposable UpsertSetting(defaultKey, bg.UseDefaultBackground ? "1" : "0"); } + // Purge background settings left behind by deleted/duplicated rows + // (TASK 25 heal drops extras; their keys must not accumulate). + using (var purge = _connection.CreateCommand()) + { + purge.Transaction = tx; + purge.CommandText = + @"DELETE FROM Settings + WHERE (Key LIKE 'BackgroundUseDefault\_%' ESCAPE '\' + OR Key LIKE 'BackgroundPath\_%' ESCAPE '\') + AND SUBSTR(Key, INSTR(Key, '_') + 1) NOT IN (SELECT Id FROM Source);"; + purge.ExecuteNonQuery(); + } + tx.Commit(); } diff --git a/Services/index.md b/Services/index.md index 572829f..ea12128 100644 --- a/Services/index.md +++ b/Services/index.md @@ -9,7 +9,7 @@ External-facing logic: YouTube API, persistence. See | `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` | | `StreamHealthReporter.cs` | Pure report-by-exception decision (TASK 9 item 3): `BannerFor(issues)` → `HealthIssueReport(Text?, IsError)` — null text on good/ok/noData/info-only, first warning/error issue produces its type text, error beats warning for color | | `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` | -| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.EnforceBackdropPolicy` re-normalizes on every load). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup) | +| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup) | | `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam | | `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device | | `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) | diff --git a/TASKS.md b/TASKS.md index 2072012..2372b1e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1096,6 +1096,55 @@ Deliberately left alone: offline license re-validation skip (log-only — would --- +## TASK 25 — Background consolidation: one locked Background per screen + mini-view rule + +### Status: ✅ Done (shipped 2026-08-23, branch `task25-backgrounds`) + +The creator rejected the multi-concept background model ("why are there multiple background +things?"). Locked model, verbatim intent: + +1. **Exactly ONE background per screen**, named "Background", at position 0. +2. It cannot be re-ordered or deleted; **no Background/Screen item in the (+) menu**. +3. Context-sensitivity exists **only on the Live screen** (Show Desktop toggle + monitor + switching — behavior kept intact). +4. **Exactly five default background images** live in the DB; stale rows are cleaned up. + +### What shipped: +- **Seeder consolidation:** the five near-clone seeders (`Seed{Starting,Brb,Ending,Chat}Background`, + `SeedLiveBackgroundAsset`, `EnsureDefaultBackground`) are gone. One path now: + `EnsureBackground(scene)` → `CreateBackground(name)` — Live = `DisplayCapture` row (capture + machinery untouched), everything else = static `Background` art row; both named "Background". +- **Heal on every load:** `NormalizeBackgrounds(scenes)` keeps the correctly-flavored row, + converts a wrong-flavor survivor in place (`Source.Type`'s setter derives `IsBackground` — + conversions must re-assert the flag), drops duplicates, seeds missing ones, renames, pins to + index 0; non-canonical scenes lose backgrounds + flag. `HealBackgrounds()` stamps default art + (`Assets/{scene}-background.jpg` via `AddAsset`; custom Browse art wins). This fixed the real DB's + rot: four scenes carried a stray "Game Capture" duplicate beside their static row, and Settings + held 16 orphaned `BackgroundUseDefault_{guid}` keys. +- **Settings purge:** `LayoutStore.Save` deletes `BackgroundUseDefault_{id}` / `BackgroundPath_{id}` + keys whose id is no longer a Source row. +- **(+) menu:** Screen + Background items removed → Webcam/Image/Text/Countdown/Web/YouTube Chat; + `AddSource` also hard-refuses DisplayCapture/WindowCapture/Background parameters. +- **Capture controls Live-only:** `CanChangeBackground` = staged scene is Live. The preview + CanvasGrid menu (Show Desktop/Capture Desktop/Refresh Desktop) binds it directly; the layer-row + context menu MultiBindings it with the row's `IsBackground` through a new + `Helpers/AllTrueToVisibilityConverter`. Non-Live screens keep the Use-default pill + Browse. +- **Mini rule:** minis never render live captures. While Live is staged its mini shows the green + placeholder; unstaged it shows `live-background.jpg`. Fixed by making `LoadBackgroundImage` + flavor-blind (`IsBackground`) and gating staged-Live to the placeholder in `RefreshSnapshotsAsync`. + (Real-time rendering stays center-monitor-only — preview lag during live gameplay is a known + unsolved OS-level problem and must not be compounded.) + +### Tests (suite went 221/218 → 223/220; the two stale background-policy landmine tests healed here): +- Integration (the ONE): `BackgroundHealIntegrationTests` — seeds a dirty temp DB (duplicate rows, + misnamed Live row, orphaned keys), drives the real window, asserts one Background per scene at + index 0 with correct flavor/name, save purges the orphaned keys. +- Unit: SceneCatalogTests rewritten for NormalizeBackgrounds/EnsureBackground flavors; + BackgroundTests empty-scene flavor updated; SourceNamingTests excludes the always-present + Background from numbered-name expectations. + +--- + ## Backlog (future versions) 1. v1.1 — Stream Deck / Loupedeck integration (requires hotkey foundation from TASK 20) diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 51cbf76..50da029 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -201,7 +201,12 @@ public class MainViewModel : ViewModelBase s.IsStaged = s == value; SelectedElement = null; - EnsureDefaultBackground(value); + if (value != null) + { + var background = EnsureBackground(value); + if (background != null) + StampDefaultBackgroundAsset(background, value.Name); + } OnPropertyChanged(nameof(ShowChatInactiveMessage)); OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowPreviewPlaceholder)); @@ -904,7 +909,10 @@ public class MainViewModel : ViewModelBase public bool ShowAddScene => MissingScenes.Any(); /// "Change Capture…"/"Refresh Capture" apply to the active scene's background. - public bool CanChangeBackground => StagedScene?.HasBackground == true; + // Capture controls (Show Desktop / monitor pick / refresh) exist only on + // the Live screen's Background. Static-art screens never expose them. + public bool CanChangeBackground => + StagedScene != null && SceneCatalog.Is(StagedScene.Name, SceneCatalog.Live); // Paid unlock flips this off (see ai.md "Monetization"). When disabled the // cadence timer is stopped and any active flash is hidden immediately. @@ -935,6 +943,10 @@ public class MainViewModel : ViewModelBase private void UpdateActiveBackground() { + // Static-art rows only. Capture-flavored rows (Live) must NEVER feed + // ActiveBackgroundImage — it renders above BackgroundImage, so it would + // permanently cover the desktop/game capture. Live's canvas shows + // DisplaySource instead (capture, falling back to live-background.jpg). var background = StagedScene?.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background); ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId) ? ImageCache.Get(background.AssetId) @@ -994,17 +1006,22 @@ public class MainViewModel : ViewModelBase { foreach (var scene in Scenes) { - var img = LoadBackgroundImage(scene); + // Minis never render live captures. While the Live screen is staged + // (being previewed) its mini shows the green placeholder; unstaged, + // it shows the stored default art. Real-time surface = center only. + var stagedLive = ReferenceEquals(scene, StagedScene) + && SceneCatalog.Is(scene.Name, SceneCatalog.Live); + var img = stagedLive ? null : LoadBackgroundImage(scene); if (img != null) scene.Snapshot = img; - else if (scene.Snapshot == null) + else if (scene.Snapshot == null || stagedLive) scene.Snapshot = CreatePlaceholderSnapshot(scene.Name); } } - private static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene) + internal static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene) { - var bg = scene.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background); + var bg = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground); if (bg != null && !bg.UseDefaultBackground && !string.IsNullOrWhiteSpace(bg.CustomBackgroundPath) && System.IO.File.Exists(bg.CustomBackgroundPath)) @@ -1746,15 +1763,7 @@ public class MainViewModel : ViewModelBase foreach (var name in SceneCatalog.All) AddScene(name); } - SeedStartingBackground(); - SeedBrbBackground(); - SeedEndingBackground(); - SeedChatBackground(); - - // The live background belongs to Live only; a pre-policy DB may have - // backgrounds lingering in other scenes — drop them, then - // ReacquireScreenCaptures heals Live's. - EnforceBackgroundPolicy(Scenes); + HealBackgrounds(); foreach (var scene in Scenes) foreach (var config in scene.Elements.OfType()) @@ -1853,10 +1862,22 @@ public class MainViewModel : ViewModelBase var background = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground); if (background != null) return background; - background = new Source + background = CreateBackground(scene.Name); + scene.Elements.Insert(0, background); + return background; + } + + /// The one Background row for a scene. Live gets the capture flavor + /// (Show Desktop / monitor switch / fullscreen-game detect) falling back to + /// live-background.jpg; every other screen is a static default-art layer. + /// Both are named "Background" — the user never sees capture jargon here. + internal static Source CreateBackground(string sceneName) + { + var isLive = SceneCatalog.Is(sceneName, SceneCatalog.Live); + return new Source { - Name = "Game Capture", - Type = SourceType.DisplayCapture, + Name = "Background", + Type = isLive ? SourceType.DisplayCapture : SourceType.Background, IsBackground = true, IsEnabled = true, X = 0, @@ -1864,19 +1885,64 @@ public class MainViewModel : ViewModelBase Width = MasterFrameWidth, Height = MasterFrameHeight, }; - scene.Elements.Insert(0, background); - return background; } - // Every scene has a background layer. Normalize the flag by scene name. - internal static void EnforceBackgroundPolicy(IEnumerable scenes) + /// Heals any loaded layout to the locked Background model: exactly + /// one Background per canonical scene, correct flavor for the scene (static + /// art everywhere, DisplayCapture on Live only), at index 0, named + /// "Background". Duplicates and stale rows are dropped; missing ones seeded; + /// non-canonical scenes get HasBackground=false and lose any backgrounds. + internal static void NormalizeBackgrounds(IEnumerable scenes) { foreach (var scene in scenes) - scene.HasBackground = SceneCatalog.HasBackground(scene.Name); + { + if (!SceneCatalog.HasBackground(scene.Name)) + { + scene.HasBackground = false; + foreach (var stale in scene.Elements.OfType().Where(s => s.IsBackground).ToList()) + scene.Elements.Remove(stale); + continue; + } + + scene.HasBackground = true; + var isLive = SceneCatalog.Is(scene.Name, SceneCatalog.Live); + var wantedType = isLive ? SourceType.DisplayCapture : SourceType.Background; + + var candidates = scene.Elements.OfType().Where(s => s.IsBackground).ToList(); + var keep = candidates.FirstOrDefault(b => b.Type == wantedType) ?? candidates.FirstOrDefault(); + + foreach (var extra in candidates.Where(b => !ReferenceEquals(b, keep))) + scene.Elements.Remove(extra); + + if (keep == null) + { + keep = CreateBackground(scene.Name); + scene.Elements.Insert(0, keep); + } + + // Source.Type's setter derives IsBackground from the enum, so a + // flavor correction must re-assert the flag afterwards. + if (keep.Type != wantedType) + { + keep.Type = wantedType; + keep.IsBackground = true; + } + + keep.Name = "Background"; + keep.IsEnabled = true; + + var index = scene.Elements.IndexOf(keep); + if (index > 0) + scene.Elements.Move(index, 0); + } } + // Only the Live screen's background is a real-time surface. Every other + // lookup below (auto-capture, focus hooks, refresh) must ignore the static + // default-art rows so they can never start a desktop capture session. private IEnumerable AllBackgrounds() - => Scenes.SelectMany(s => s.Elements.OfType().Where(x => x.IsBackground)); + => Scenes.Where(s => SceneCatalog.Is(s.Name, SceneCatalog.Live)) + .SelectMany(s => s.Elements.OfType().Where(x => x.IsBackground)); // Full-screen game on its monitor, else null (static bg fallback — no // infinity mirror). Called at launch (from ReacquireScreenCaptures, before @@ -1901,10 +1967,7 @@ public class MainViewModel : ViewModelBase // session. Captures no longer referenced by any scene are released. private void ReacquireScreenCaptures() { - foreach (var scene in Scenes) - EnsureBackground(scene); - - SeedLiveBackgroundAsset(); + HealBackgrounds(); var auto = ResolveAutoCaptureKey(); if (auto == null) @@ -2013,7 +2076,7 @@ public class MainViewModel : ViewModelBase { var scene = StagedScene; if (scene == null) return; - var bg = scene.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background); + var bg = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground); if (bg == null) return; var dlg = new Microsoft.Win32.OpenFileDialog @@ -2236,50 +2299,43 @@ public class MainViewModel : ViewModelBase StagedScene = scene; } - private void SeedStartingBackground() - { - try - { - var starting = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Starting")); - if (starting == null) return; - if (starting.Elements.OfType().Any(s => s.Type == SourceType.Background)) return; + // ─── Background model (TASK 25): exactly ONE Background per screen, index 0, + // never removable or re-addable. Static default art on Starting/BRB/Chat/Ending; + // on Live it's the capture-flavored row (Show Desktop / monitor switch / + // fullscreen-game detect) falling back to live-background.jpg. ─── - var uri = new Uri("pack://application:,,,/Assets/starting-background.jpg", UriKind.Absolute); - using var stream = Application.GetResourceStream(uri)?.Stream; - if (stream == null) return; - using var ms = new MemoryStream(); - stream.CopyTo(ms); - var bytes = ms.ToArray(); - var assetId = AddAsset(bytes); - if (assetId == null) return; - starting.Elements.Add(new Source - { - Name = "Background", - Type = SourceType.Background, - AssetId = assetId, - Width = 1920, - Height = 1080, - }); + private static string? DefaultBackgroundAsset(string sceneName) => sceneName switch + { + SceneCatalog.Starting => "starting-background.jpg", + SceneCatalog.Brb => "brb-background.jpg", + SceneCatalog.Chat => "chat-background.jpg", + SceneCatalog.Ending => "ending-background.jpg", + SceneCatalog.Live => "live-background.jpg", + _ => null, + }; + + /// Heals every scene to the locked Background model after a layout + /// load: keeps the correct flavor per scene at index 0 named "Background", + /// drops duplicates/stale rows, re-seeds missing ones. + private void HealBackgrounds() + { + NormalizeBackgrounds(Scenes); + foreach (var scene in Scenes) + { + var background = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground); + if (background != null) + StampDefaultBackgroundAsset(background, scene.Name); } - catch { /* non-critical — stream works fine without a default bg */ } + OnPropertyChanged(nameof(ShowSourcesEmptyHint)); } - /// - /// When switching to a scene that has no background, seed the default. - /// - private void EnsureDefaultBackground(Scene? scene) + /// Points a background row at its scene's shipped default art when it + /// carries none. A custom image (Browse…) always wins and is never overwritten. + private void StampDefaultBackgroundAsset(Source background, string sceneName) { - if (scene == null) return; - if (scene.Elements.OfType().Any(s => s.Type == SourceType.Background)) return; - - string? asset = scene.Name switch - { - "Starting" => "starting-background.jpg", - "BRB" => "brb-background.jpg", - "Ending" => "ending-background.jpg", - "Chat" => "chat-background.jpg", - _ => null, - }; + if (!string.IsNullOrWhiteSpace(background.AssetId)) return; + if (!background.UseDefaultBackground && !string.IsNullOrWhiteSpace(background.CustomBackgroundPath)) return; + var asset = DefaultBackgroundAsset(sceneName); if (asset == null) return; try @@ -2289,158 +2345,9 @@ public class MainViewModel : ViewModelBase if (stream == null) return; using var ms = new MemoryStream(); stream.CopyTo(ms); - var bytes = ms.ToArray(); - var assetId = AddAsset(bytes); - if (assetId == null) return; - scene.Elements.Add(new Source - { - Name = "Background", - Type = SourceType.Background, - AssetId = assetId, - Width = 1920, - Height = 1080, - }); - UpdateActiveBackground(); + background.AssetId = AddAsset(ms.ToArray()); } - catch { /* non-critical */ } - } - - private void SeedBrbBackground() - { - try - { - var brb = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "BRB")); - if (brb == null) return; - - var uri = new Uri("pack://application:,,,/Assets/brb-background.jpg", UriKind.Absolute); - using var stream = Application.GetResourceStream(uri)?.Stream; - if (stream == null) return; - using var ms = new MemoryStream(); - stream.CopyTo(ms); - var bytes = ms.ToArray(); - var assetId = AddAsset(bytes); - if (assetId == null) return; - - var existing = brb.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background); - if (existing != null) - { - existing.AssetId = assetId; - existing.Width = 1920; - existing.Height = 1080; - } - else - { - brb.Elements.Add(new Source - { - Name = "Background", - Type = SourceType.Background, - AssetId = assetId, - Width = 1920, - Height = 1080, - }); - } - } - catch { /* non-critical — stream works fine without a default bg */ } - } - - private void SeedLiveBackgroundAsset() - { - try - { - var live = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Live")); - if (live == null) return; - var background = live.Elements.OfType().FirstOrDefault(s => s.IsBackground); - if (background == null) return; - - var uri = new Uri("pack://application:,,,/Assets/live-background.jpg", UriKind.Absolute); - using var stream = Application.GetResourceStream(uri)?.Stream; - if (stream == null) return; - using var ms = new MemoryStream(); - stream.CopyTo(ms); - var bytes = ms.ToArray(); - var assetId = AddAsset(bytes); - if (assetId == null) return; - background.AssetId = assetId; - background.Width = 1920; - background.Height = 1080; - } - catch { /* non-critical — stream works fine without a default bg */ } - } - - private void SeedEndingBackground() - { - try - { - var ending = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Ending")); - if (ending == null) return; - - var uri = new Uri("pack://application:,,,/Assets/ending-background.jpg", UriKind.Absolute); - using var stream = Application.GetResourceStream(uri)?.Stream; - if (stream == null) return; - using var ms = new MemoryStream(); - stream.CopyTo(ms); - var bytes = ms.ToArray(); - var assetId = AddAsset(bytes); - if (assetId == null) return; - - var existing = ending.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background); - if (existing != null) - { - existing.AssetId = assetId; - existing.Width = 1920; - existing.Height = 1080; - } - else - { - ending.Elements.Add(new Source - { - Name = "Background", - Type = SourceType.Background, - AssetId = assetId, - Width = 1920, - Height = 1080, - }); - } - } - catch { /* non-critical — stream works fine without a default bg */ } - } - - private void SeedChatBackground() - { - try - { - var chat = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Chat")); - if (chat == null) return; - - var uri = new Uri("pack://application:,,,/Assets/chat-background.jpg", UriKind.Absolute); - using var stream = Application.GetResourceStream(uri)?.Stream; - if (stream == null) return; - using var ms = new MemoryStream(); - stream.CopyTo(ms); - var bytes = ms.ToArray(); - var assetId = AddAsset(bytes); - if (assetId == null) return; - - var existing = chat.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background); - if (existing != null) - { - existing.AssetId = assetId; - existing.Width = 1920; - existing.Height = 1080; - } - else - { - chat.Elements.Add(new Source - { - Name = "Background", - Type = SourceType.Background, - AssetId = assetId, - Width = 1920, - Height = 1080, - }); - } - } - catch { /* non-critical — stream works fine without a default bg */ } + catch { /* non-critical — the app works without default art */ } } private void BeginEditElement(SceneElement? element) @@ -2467,9 +2374,6 @@ public class MainViewModel : ViewModelBase : Enum.TryParse(parameter?.ToString(), true, out var parsed) ? parsed : SourceType.Image; var baseName = sourceType switch { - SourceType.DisplayCapture => "Screen", - SourceType.WindowCapture => "Window", - SourceType.Background => "Background", SourceType.Image => "Image", SourceType.TextOverlay => "Text", SourceType.Countdown => "Countdown", @@ -2478,27 +2382,11 @@ public class MainViewModel : ViewModelBase _ => "Layer", }; - if (sourceType == SourceType.Background) - { - var bytes = PickImageBytes("Choose a background image"); - if (bytes == null) return; - var assetId = AddAsset(bytes); - if (assetId == null) return; - - var existing = scene.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background); - if (existing != null) - { - existing.AssetId = assetId; - UpdateActiveBackground(); - return; - } - - scene.Elements.Add(new Source { Name = baseName, Type = SourceType.Background, AssetId = assetId }); - OnPropertyChanged(nameof(ShowEmptySceneHint)); - OnPropertyChanged(nameof(ShowSourcesEmptyHint)); - UpdateActiveBackground(); + // The (+) menu only adds regular layers. The Background is a locked + // per-screen layer managed by EnsureBackground/NormalizeBackgrounds — + // never user-created, never deleted. + if (sourceType is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.Background) return; - } var source = new Source { diff --git a/ai.md b/ai.md index 8ae0225..bf9d59d 100644 --- a/ai.md +++ b/ai.md @@ -92,7 +92,9 @@ LayoutStore background roundtrip, LayoutStore HasBackground roundtrip + v5→v6 ScreenCaptureManager refcount + shared-bitmap + coalescing (fake `IScreenCaptureSource` + a real background-STA `Dispatcher`), BackgroundTests (EnsureBackground insert/idempotent/heal + HasBackground gate, IsLiveCapture, DisplaySource, INPC), SceneCatalogTests -(the five canonical scenes, Live-only background policy, EnforceBackgroundPolicy), WebcamSafeguardTests +(the five canonical scenes; NormalizeBackgrounds heal-to-locked-model + non-canonical strip; +EnsureBackground per-scene flavor), the TASK 25 dirty-layout heal integration test +(RealApp + seeded temp DB: dedupe/flavor/rename/index-0 + orphaned settings purge), WebcamSafeguardTests (the per-scene size clamp incl. the Chat half-screen-area cap), SceneCompositorTests (the full-scene composite integration test: background + round webcam + mirrored/bordered images + flash; the vertical tier 1080×1920 crop/scale), StretchMathTests (UniformToFill cover-crop + bilinear), FfmpegLocatorTests @@ -180,28 +182,44 @@ The app is a **director's control surface**, not an editor. This replaces the OB **Thumbnails:** `Scene.Snapshot` property. `SceneCompositor.Render()` at 320×180 → `WriteableBitmap`. Only staged scene has live backdrop render; other 4 = static snapshots. -### Screen backdrop capture (TASK 3 ship task #1) +**Thumbnails:** `Scene.Snapshot` property. `RefreshSnapshotsAsync` fills minis from each scene's background art (`LoadBackgroundImage`); **minis never render live captures** — while the Live screen is staged its mini shows the green placeholder, unstaged it shows `live-background.jpg` (real-time surface = center monitor only; user decision 2026-08-23). -The backdrop is the **live desktop/game capture as a permanent, non-deletable bottom layer** -rendered in every scene — the "Screen" source from the minimal set, done as content-swap -instead of a normal draggable source. +### Screen backdrop capture (TASK 3 ship task #1) — model reworked by TASK 25 -- **Model (schema v6):** `Source.IsBackdrop` (persisted) marks the one backdrop per scene; `Source.CaptureKey` - (persisted) names the target — `monitor:`, `window:`, or `picker:`. The backdrop is - a real `Source` of `Type DisplayCapture`, inserted **first** (`MainViewModel.EnsureBackdrop(scene)`, - internal static — runs on layout load + every `AddScene`, healing any scene missing one), fixed at - X=0/Y=0/1920×1080, and excluded from drag/hit-test/remove/reorder (remove is guarded in `RemoveElement`; - `IsDraggableElement` never matches live types; the element template sets `IsHitTestVisible=false` for - backdrops; the row's remove button and "Remove Source" menu item are hidden). **`Scene.HasBackdrop` - (persisted, default off) is the Live-only policy flag** — the backdrop belongs to the canonical Live - scene alone (see `SceneCatalog`). `EnsureBackdrop` returns null for a flag-less scene, so - Starting/BRB/Chat/Ending compose their own layers. The one-time v5→v6 backfill turns those four scenes - off and drops their backdrop sources, and **`EnforceBackdropPolicy` (internal static, runs after every - load)** re-normalizes the flag by scene name and strips any backdrop that lingers in a non-Live scene — - the flag is owned by policy, never the user. There is no scene-list "Backdrop" checkbox anymore - (the old `ToggleSceneBackdropCommand` is gone); "Change Capture…"/"Refresh Capture"/"Capture Display" - only show in the Live scene's preview menu (`CanChangeBackdrop`). The static Background, if any, - renders **above** the backdrop. +**The Background is a locked per-screen layer (TASK 25): exactly one per canonical +scene, always named "Background", pinned at index 0, never addable/removable/reorderable, +absent from the (+) menu. Flavor is decided by scene: Live = `DisplayCapture` (the real-time +desktop/game surface with Show Desktop / monitor switch / fullscreen-game detect, falling back +to `live-background.jpg`); Starting/BRB/Chat/Ending = static `Background` art layers.** +`MainViewModel.NormalizeBackgrounds(scenes)` (internal static) heals any loaded layout to this +model on every load: keeps the correctly-flavored row (or converts a survivor in place — note +`Source.Type`'s setter derives `IsBackground`, so conversions must re-assert the flag), drops +duplicates/stale rows, seeds missing ones via `CreateBackground(name)`, renames, moves to index 0; +non-canonical scenes get `HasBackground=false` and lose any backgrounds. `HealBackgrounds()` +(instance) runs Normalize + stamps default art (`StampDefaultBackgroundAsset` reads +`Assets/{scene}-background.jpg` pack resources through `AddAsset`; custom Browse art wins). +This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Background`, +`SeedLiveBackgroundAsset`, `EnsureDefaultBackground`) and `EnforceBackgroundPolicy`. + +- **Model (schema v6):** `Source.IsBackground` (persisted) marks the one Background per scene; `Source.CaptureKey` + (persisted) names the target — `monitor:`, `window:`, or `picker:`. The Live row is + a real `Source` of `Type DisplayCapture`, inserted **first** (`MainViewModel.EnsureBackground(scene)`, + internal static — also used by the `StagedScene` setter so a scene switch can never expose a + missing Background), fixed at X=0/Y=0/1920×1080, and excluded from drag/hit-test/remove/reorder (remove is guarded in `RemoveElement`; + the element template sets `IsHitTestVisible=false` for + backgrounds; the row's remove button and "Remove Source" menu item are hidden). **`Scene.HasBackground` + (persisted) is true for all five canonical scenes** (see `SceneCatalog.HasBackground`). Capture controls — + "Change Capture…"/"Refresh Capture"/"Capture Display"/"Show Desktop" — exist only while the **Live** scene is + staged (`CanChangeBackground` = staged scene is Live; the preview CanvasGrid menu binds it directly, the layer-row + menu MultiBindings it with the row's own `IsBackground` via `Helpers/AllTrueToVisibilityConverter`). Non-Live + screens keep the properties panel pill ("Use default background") + Browse for custom art. +- **Settings hygiene:** `LayoutStore.Save` purges orphaned `BackgroundUseDefault_{id}` / + `BackgroundPath_{id}` Settings keys whose id is no longer a live Source row (TASK 25 heal drops duplicates; their keys must not accumulate). +- **Layering invariant (regression lesson, 2026-08-23):** `ActiveBackgroundImage` renders *above* + `BackgroundImage`, so it is fed by **static-art rows only** (`Type == Background`). The capture-flavored + Live row must never populate it or it permanently covers the desktop/game capture; Live's canvas shows + `DisplaySource` (capture, falling back to its AssetId art). Flavor-blind `IsBackground` lookups are for + persistence/heal/minis — not this overlay. - **Detection (event-driven):** `Win32FullScreenDetector` hooks `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)` — fires on the UI thread whenever the foreground window changes. The callback runs the same `GetForegroundWindow` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` + `MonitorFromWindow` + @@ -211,8 +229,8 @@ instead of a normal draggable source. `IFullScreenDetector` also exposes `GetDisplays()` (`DisplayInfo`: index/name/resolution/bounds/`IsPrimary`, friendly name via `EnumDisplayDevices`) + `PrimaryMonitorIndex()` for the in-app "Capture Display" submenu + `FullscreenMonitorChanged` event + `StartWatching()`/`StopWatching()`. At launch - `ReacquireScreenCaptures` seeds the static fallback image (`Assets/live-backdrop.jpg`) onto the - backdrop's `AssetId` via `SeedLiveBackdropAsset()`, then keys the backdrop to the full-screen game's + `ReacquireScreenCaptures` heals backgrounds (`HealBackgrounds()`), then keys the Live background to the + full-screen game's monitor; when **no game is detected**, `ResolveAutoCaptureKey()` returns `null` — stale `CaptureKey` values are cleared, no capture session is started, and `Source.DisplaySource` falls back to `_imageSource` (the static asset on the backdrop element). `StartWatching()` is called at the end of @@ -260,8 +278,8 @@ instead of a normal draggable source. WPF presents the window. Recorded 2026-08-13; no mitigation attempted yet (deferred by user decision). - **GPU posture:** same as webcam — CPU frames, WPF hardware-presents; D3DImage GPU compositing deferred to the encoder task. -- **Preview watermark:** the "Preview" placeholder hides while a backdrop renders — - `ShowPreviewPlaceholder` now also checks `BackdropImage` (raised on backdrop change), so a scene with +- **Preview watermark:** the "Preview" placeholder hides while a background capture renders — + `ShowPreviewPlaceholder` also checks `BackgroundImage` (raised on change), so a scene with live capture shows the feed instead of the "nothing here" label. ### Webcam capture (TASK 3 milestone 1) diff --git a/ytLive.Tests/BackgroundHealIntegrationTests.cs b/ytLive.Tests/BackgroundHealIntegrationTests.cs new file mode 100644 index 0000000..9628ae2 --- /dev/null +++ b/ytLive.Tests/BackgroundHealIntegrationTests.cs @@ -0,0 +1,179 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Models; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// TASK 25 integration: a pre-consolidation DB (static Background + stray +/// "Game Capture" duplicates, wrong flavor on Live, orphaned settings keys) +/// heals to the locked model — exactly one Background per screen, index 0, +/// right flavor, right name — and a save purges the orphaned keys. +/// Real-App + temp-DB pattern (see SourceNamingTests). +/// +[Collection("RealApp")] +public sealed class BackgroundHealIntegrationTests +{ + [Fact] + public void Dirty_Layout_Heals_To_One_Locked_Background_Per_Scene() + { + _app.Run(Run); + } + + private readonly RealAppHost _app; + + public BackgroundHealIntegrationTests(RealAppHost app) => _app = app; + + private void Run() + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-bgheal-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + var window = default(MainWindow); + try + { + using (var schema = new ytLive.Services.LayoutStore(tempDb)) { } + SqliteConnection.ClearAllPools(); + SeedDirtyDatabase(tempDb); + + window = new MainWindow(); + var vm = (MainViewModel)window.DataContext; + + foreach (var name in SceneCatalog.All) + { + var scene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, name)); + Assert.True(scene.HasBackground, $"{name}: HasBackground must be true"); + + var background = Assert.Single( + scene.Elements.OfType().Where(s => s.IsBackground).ToList(), + s => s.IsBackground); + Assert.Equal("Background", background.Name); + Assert.Equal(0, scene.Elements.IndexOf(background)); + + var wantedType = SceneCatalog.Is(name, SceneCatalog.Live) + ? SourceType.DisplayCapture + : SourceType.Background; + Assert.True(background.Type == wantedType, + $"{name}: expected {wantedType}, got {background.Type}"); + } + + vm.SaveLayoutNow(); + + using (var connection = new SqliteConnection($"Data Source={tempDb}")) + { + connection.Open(); + + using (var dupes = connection.CreateCommand()) + { + dupes.CommandText = + "SELECT COUNT(*) FROM Source WHERE Name = 'Game Capture';"; + Assert.Equal(0L, (long)dupes.ExecuteScalar()!); + } + + using (var perScene = connection.CreateCommand()) + { + perScene.CommandText = + @"SELECT s.Name, COUNT(*) FROM Source src + JOIN Scene s ON s.Id = src.SceneId + WHERE src.IsBackground = 1 GROUP BY s.Name;"; + using var reader = perScene.ExecuteReader(); + var counts = new System.Collections.Generic.Dictionary(); + while (reader.Read()) + counts[reader.GetString(0)] = reader.GetInt64(1); + var live = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live)); + vm.StagedScene = live; + + // Layering invariant: the Live screen's background is capture-flavored, + // so it must never feed ActiveBackgroundImage (that image renders above + // the capture and would cover it permanently). + Assert.Null(vm.ActiveBackgroundImage); + + foreach (var name in SceneCatalog.All) + Assert.True(counts.TryGetValue(name, out var c) && c == 1, + $"{name}: expected exactly 1 background row"); + } + + using (var staleKeys = connection.CreateCommand()) + { + staleKeys.CommandText = + @"SELECT COUNT(*) FROM Settings + WHERE (Key LIKE 'BackgroundUseDefault\_%' ESCAPE '\' + OR Key LIKE 'BackgroundPath\_%' ESCAPE '\') + AND SUBSTR(Key, INSTR(Key, '_') + 1) IN ('bg-stray-1','bg-stray-2');"; + Assert.Equal(0L, (long)staleKeys.ExecuteScalar()!); + } + } + } + finally + { + window?.Close(); + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } + } + } + + /// Recreates the rot observed in the user's real DB: duplicate + /// capture-flavored rows beside static ones, a misnamed Live background, + /// and settings keys pointing at rows that shouldn't exist. + private static void SeedDirtyDatabase(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(); + } + + InsertSource(connection, "bg-static-1", "scene-starting", "Background", "Background", 0); + InsertSource(connection, "bg-stray-1", "scene-starting", "Game Capture", "DisplayCapture", 1); + InsertSource(connection, "bg-live-1", "scene-live", "Game Capture", "DisplayCapture", 0); + InsertSource(connection, "bg-static-2", "scene-chat", "Background", "Background", 0); + InsertSource(connection, "bg-stray-2", "scene-chat", "Game Capture", "DisplayCapture", 1); + + using (var key1 = connection.CreateCommand()) + { + key1.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('BackgroundUseDefault_bg-stray-1', '0');"; + key1.ExecuteNonQuery(); + } + using (var key2 = connection.CreateCommand()) + { + key2.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('BackgroundPath_bg-stray-2', 'C:\\\\nope.png');"; + key2.ExecuteNonQuery(); + } + } + + private static void InsertSource( + SqliteConnection connection, string id, string sceneId, + string name, string type, int sortOrder) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = + @"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, X, Y, Width, Height, IsBackground, SortOrder) + VALUES ($id, $sceneId, $type, $name, 1, 0, 0, 1920, 1080, 1, $sortOrder);"; + cmd.Parameters.AddWithValue("$id", id); + cmd.Parameters.AddWithValue("$sceneId", sceneId); + cmd.Parameters.AddWithValue("$type", type); + cmd.Parameters.AddWithValue("$name", name); + cmd.Parameters.AddWithValue("$sortOrder", sortOrder); + cmd.ExecuteNonQuery(); + } +} diff --git a/ytLive.Tests/BackgroundTests.cs b/ytLive.Tests/BackgroundTests.cs index e9b55a7..4fde6b9 100644 --- a/ytLive.Tests/BackgroundTests.cs +++ b/ytLive.Tests/BackgroundTests.cs @@ -26,7 +26,9 @@ public class BackgroundTests Assert.Same(background, scene.Elements[0]); Assert.True(background.IsBackground); Assert.True(background.IsEnabled); - Assert.Equal(SourceType.DisplayCapture, background.Type); + // Non-Live scenes get the static-art flavor; only Live captures. + Assert.Equal(SourceType.Background, background.Type); + Assert.Equal("Background", background.Name); Assert.Equal(0, background.X); Assert.Equal(0, background.Y); Assert.Equal(1920, background.Width); diff --git a/ytLive.Tests/SceneCatalogTests.cs b/ytLive.Tests/SceneCatalogTests.cs index cdb007b..616da69 100644 --- a/ytLive.Tests/SceneCatalogTests.cs +++ b/ytLive.Tests/SceneCatalogTests.cs @@ -46,31 +46,65 @@ public class SceneCatalogTests } [Fact] - public void EnforceBackgroundPolicy_Sets_Flag_For_All_Scenes() + public void NormalizeBackgrounds_Heals_Dirty_Layouts_To_The_Locked_Model() { - var chat = new Scene { Name = "Chat", HasBackground = false }; + // Pre-TASK-25 rot: static Background + stray Game Capture duplicates, + // wrong names, wrong z-order, stale flags on non-canonical scenes. + var starting = new Scene { Name = "Starting", HasBackground = false }; + var image = new Source { Name = "Image 1", Type = SourceType.Image }; + starting.Elements.Add(new Source { Name = "Background", Type = SourceType.Background, IsBackground = true, Width = 10 }); + starting.Elements.Add(image); + starting.Elements.Add(new Source { Name = "Game Capture", Type = SourceType.DisplayCapture, IsBackground = true }); + var live = new Scene { Name = "Live", HasBackground = false }; - var chatBackground = MainViewModel.EnsureBackground(chat)!; - var liveBackground = MainViewModel.EnsureBackground(live)!; + live.Elements.Add(new Source { Name = "Game Capture", Type = SourceType.Background, IsBackground = true }); - MainViewModel.EnforceBackgroundPolicy(new[] { chat, live }); + MainViewModel.NormalizeBackgrounds(new[] { starting, live }); + + Assert.True(starting.HasBackground); + var startingBackgrounds = starting.Elements.OfType().Where(s => s.IsBackground).ToList(); + var kept = Assert.Single(startingBackgrounds); + Assert.Equal("Background", kept.Name); + Assert.Equal(SourceType.Background, kept.Type); // correct flavor preferred over the stray capture row + Assert.Same(image, starting.Elements[1]); // regular layers untouched + Assert.Equal(0, starting.Elements.IndexOf(kept)); - Assert.True(chat.HasBackground); - Assert.Contains(chat.Elements, e => ReferenceEquals(e, chatBackground)); Assert.True(live.HasBackground); - Assert.Contains(live.Elements, e => ReferenceEquals(e, liveBackground)); + var liveKept = Assert.Single(live.Elements.OfType(), s => s.IsBackground); + Assert.Equal("Background", liveKept.Name); + Assert.Equal(SourceType.DisplayCapture, liveKept.Type); // flavor corrected to capture + Assert.Equal(0, live.Elements.IndexOf(liveKept)); } [Fact] - public void EnforceBackgroundPolicy_Normalizes_All_Canonical_Scenes() + public void NormalizeBackgrounds_Strips_Backgrounds_From_NonCanonical_Scenes() { - var starting = new Scene { Name = "Starting", HasBackground = false }; - MainViewModel.EnsureBackground(starting); - var live = new Scene { Name = "Live", HasBackground = false }; + var custom = new Scene { Name = "My Scene", HasBackground = true }; + custom.Elements.Add(new Source { Name = "Background", Type = SourceType.Background, IsBackground = true }); + var layer = new Source { Name = "Image 1", Type = SourceType.Image }; + custom.Elements.Add(layer); - MainViewModel.EnforceBackgroundPolicy(new[] { starting, live }); + MainViewModel.NormalizeBackgrounds(new[] { custom }); - Assert.True(starting.HasBackground); - Assert.True(live.HasBackground); + Assert.False(custom.HasBackground); + Assert.DoesNotContain(custom.Elements, e => e is Source { IsBackground: true }); + Assert.Contains(custom.Elements, e => ReferenceEquals(e, layer)); + } + + [Fact] + public void EnsureBackground_Seeds_The_Right_Flavor_Per_Scene() + { + var starting = new Scene { Name = "Starting", HasBackground = true }; + var startingBackground = MainViewModel.EnsureBackground(starting)!; + Assert.Equal(SourceType.Background, startingBackground.Type); + Assert.Equal("Background", startingBackground.Name); + + var live = new Scene { Name = "Live", HasBackground = true }; + var liveBackground = MainViewModel.EnsureBackground(live)!; + Assert.Equal(SourceType.DisplayCapture, liveBackground.Type); + + var noBackgroundScene = new Scene { Name = "My Scene", HasBackground = false }; + Assert.Null(MainViewModel.EnsureBackground(noBackgroundScene)); + Assert.Empty(noBackgroundScene.Elements); } } diff --git a/ytLive.Tests/SourceNamingTests.cs b/ytLive.Tests/SourceNamingTests.cs index 7f69ae3..fbcbd5d 100644 --- a/ytLive.Tests/SourceNamingTests.cs +++ b/ytLive.Tests/SourceNamingTests.cs @@ -44,14 +44,17 @@ public sealed class SourceNamingTests vm.AddSourceCommand.Execute(SourceType.TextOverlay); vm.AddSourceCommand.Execute(SourceType.TextOverlay); - var names = scene.Elements.OfType().Select(s => s.Name).ToList(); + // The locked Background layer is always present but never numbered. + var names = scene.Elements.OfType().Where(s => !s.IsBackground) + .Select(s => s.Name).ToList(); Assert.Equal(new[] { "Text", "Text2", "Text3" }, names); var middle = scene.Elements.OfType().Single(s => s.Name == "Text2"); scene.Elements.Remove(middle); vm.AddSourceCommand.Execute(SourceType.TextOverlay); - var survivors = scene.Elements.OfType().Select(s => s.Name).ToList(); + var survivors = scene.Elements.OfType().Where(s => !s.IsBackground) + .Select(s => s.Name).ToList(); Assert.Equal(new[] { "Text", "Text3", "Text2" }, survivors); } finally