diff --git a/Assets/archive/starting-background-v1-2026-11-06.jpg b/Assets/archive/starting-background-v1-2026-11-06.jpg new file mode 100644 index 0000000..e23965e Binary files /dev/null and b/Assets/archive/starting-background-v1-2026-11-06.jpg differ diff --git a/Assets/starting-background.jpg b/Assets/starting-background.jpg index e23965e..555915c 100644 Binary files a/Assets/starting-background.jpg and b/Assets/starting-background.jpg differ diff --git a/ViewModels/MainViewModel.Background.cs b/ViewModels/MainViewModel.Background.cs index 37135bb..97b12f6 100644 --- a/ViewModels/MainViewModel.Background.cs +++ b/ViewModels/MainViewModel.Background.cs @@ -429,7 +429,7 @@ public partial class MainViewModel /// drops duplicates/stale rows, re-seeds missing ones. /// Bump when a bundled background image is replaced. Layouts stamped with an older version /// re-seed their DEFAULT art on next load; a creator's own custom background is never touched. - private const int CurrentDefaultArtVersion = 2; + private const int CurrentDefaultArtVersion = 4; /// Re-stamp the shipped art for scenes still using the default. Without this, a replaced /// starting-background.jpg reaches new installs only: the asset is hash-keyed into the layout @@ -438,12 +438,19 @@ public partial class MainViewModel { if (_layoutStore.LoadDefaultArtVersion() >= CurrentDefaultArtVersion) return; - foreach (var background in AllBackgrounds()) + // Every scene's background, not AllBackgrounds(): that helper only returns the LIVE scene's + // capture-flavoured rows, which is why version 2 healed nothing (2026-11-06). + var cleared = 0; + foreach (var scene in Scenes) + foreach (var background in scene.Elements.OfType().Where(element => element.IsBackground)) { - if (!background.UseDefaultBackground) continue; // the creator chose their own art - background.AssetId = null; // seed re-stamps it below + if (!background.UseDefaultBackground && !string.IsNullOrWhiteSpace(background.CustomBackgroundPath)) + continue; // the creator chose their own art + background.AssetId = null; // the seed below re-stamps the bundled art + cleared++; } + AppLog.Write($"Default art version {CurrentDefaultArtVersion}: re-seeding {cleared} background(s)"); _layoutStore.SaveDefaultArtVersion(CurrentDefaultArtVersion); } @@ -516,7 +523,10 @@ public partial class MainViewModel try { - var uri = new Uri($"pack://application:,,,/Assets/{asset}", UriKind.Absolute); + // Assembly-qualified on purpose: the unqualified form resolves against the ENTRY assembly, + // which is LlamaCasty.exe in production but the test host under `dotnet test` — so the seed + // silently did nothing there (and the art test could not see the bundled image). + var uri = new Uri($"pack://application:,,,/LlamaCasty;component/Assets/{asset}", UriKind.Absolute); using var stream = Application.GetResourceStream(uri)?.Stream; if (stream == null) return; using var ms = new MemoryStream(); diff --git a/ytLive.Tests/BackgroundDefaultArtTests.cs b/ytLive.Tests/BackgroundDefaultArtTests.cs new file mode 100644 index 0000000..e567b4d --- /dev/null +++ b/ytLive.Tests/BackgroundDefaultArtTests.cs @@ -0,0 +1,91 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Services; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// 2026-11-06: replacing a bundled background reached new installs only. Default art is hash-keyed into the +/// layout database on first seed, so an existing profile kept the old image for ever — and the first +/// attempt at a version heal silently did nothing because it iterated `AllBackgrounds()`, which returns +/// only the LIVE scene's rows. +/// +[Collection("RealApp")] +public sealed class BackgroundDefaultArtTests +{ + private readonly RealAppHost _app; + + public BackgroundDefaultArtTests(RealAppHost app) => _app = app; + + [Fact] + public void ReplacingTheBundledArt_ReSeedsAnExistingProfile() => _app.Run(() => + { + // The bundled art is a WPF resource, so this needs a real Application to resolve the pack URI. + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-art-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + try + { + // First launch: the seeded art is whatever ships today. + var first = new MainViewModel(); + var firstId = StartingBackgroundId(first); + Assert.False(string.IsNullOrWhiteSpace(firstId), "the bundled Starting art never seeded"); + var firstBytes = AssetLength(tempDb, firstId!); + + // Simulate a profile stamped with the OLD art: point the Starting background at a planted + // asset and mark the layout as an older art vintage. The heal must re-stamp the shipped art. + using (var connection = new SqliteConnection($"Data Source={tempDb}")) + { + connection.Open(); + using var plant = connection.CreateCommand(); + plant.CommandText = """ + INSERT OR REPLACE INTO Asset (Id, Hash, Data, PixelWidth, PixelHeight) + VALUES ('old-art', 'deadbeef', zeroblob(1024), 1920, 1080); + UPDATE Source SET AssetId = 'old-art' WHERE IsBackground = 1; + """; + plant.ExecuteNonQuery(); + } + SqliteConnection.ClearAllPools(); + using (var store = new LayoutStore(tempDb)) + store.SaveDefaultArtVersion(1); + + var second = new MainViewModel(); + var secondId = StartingBackgroundId(second); + Assert.NotNull(secondId); + + // Re-stamped to the SHIPPED art — which, being the same bytes, is the same asset row the + // first launch created. What matters is that the planted "old art" is gone. + Assert.NotEqual("old-art", secondId); + Assert.Equal(firstId, secondId); + Assert.Equal(File.ReadAllBytes(Path.Combine( + AppContext.BaseDirectory, "..", "..", "..", "..", "Assets", "starting-background.jpg")).Length, + AssetLength(tempDb, secondId!)); + Assert.NotEqual(firstBytes, 0); + } + finally + { + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } + } + }); + + private static string? StartingBackgroundId(MainViewModel vm) + => vm.Scenes + .Where(scene => ytLive.Models.SceneCatalog.Is(scene.Name, ytLive.Models.SceneCatalog.Starting)) + .SelectMany(scene => scene.Elements.OfType()) + .FirstOrDefault(element => element.IsBackground)?.AssetId; + + private static int AssetLength(string dbPath, string assetId) + { + using var connection = new SqliteConnection($"Data Source={dbPath}"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT length(Data) FROM Asset WHERE Id = $id;"; + cmd.Parameters.AddWithValue("$id", assetId); + return Convert.ToInt32(cmd.ExecuteScalar()); + } +}