using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; using Microsoft.Data.Sqlite; using Xunit; using ytLive.Helpers; using ytLive.Models; using ytLive.Services; using ytLive.ViewModels; namespace ytLive.Tests; /// /// Scene strip tile artwork (creator ruling 2026-10-10). A persisted scene tile /// is stored as an Asset row; the preview pane keeps rendering each scene's /// ORIGINAL background — the tiles never appear as preview backgrounds. /// [Collection("RealApp")] public sealed class SceneThumbnailTests { private readonly RealAppHost _app; public SceneThumbnailTests(RealAppHost app) => _app = app; [Fact] public void Strip_Shows_The_Persisted_Tile_And_The_Preview_Keeps_The_Original_Background() { _app.Run(() => { EnvGuard? env = null; try { env = ArrangedDb(out var file); env.Apply(); var tileAssetId = LayoutStore.Instance!.UpsertAsset(File.ReadAllBytes(file.TilePath), 320, 180); using (var connection = new SqliteConnection($"Data Source={env.DbPath}")) { connection.Open(); using var cmd = connection.CreateCommand(); cmd.CommandText = "UPDATE Scene SET ThumbnailAssetId = $asset WHERE Name = $scene;"; cmd.Parameters.AddWithValue("$asset", tileAssetId); cmd.Parameters.AddWithValue("$scene", SceneCatalog.Starting); Assert.Equal(1, cmd.ExecuteNonQuery()); } var vm = new MainViewModel(); var starting = SceneByName(vm, SceneCatalog.Starting); Assert.False(string.IsNullOrWhiteSpace(starting.ThumbnailAssetId)); // The strip tile is the seeded file (identity + dims prove it). var tile = ImageCache.Get(starting.ThumbnailAssetId); Assert.NotNull(tile); Assert.Same(tile, starting.Snapshot); Assert.Equal(320, starting.Snapshot!.PixelWidth); Assert.Equal(180, starting.Snapshot.PixelHeight); // The preview pane renders the scene's ORIGINAL background — never // the tile artwork. var originalBackground = ImageCache.Get(env.BackgroundIds[SceneCatalog.Starting]); Assert.NotNull(originalBackground); Assert.Same(originalBackground, vm.BackgroundImage); Assert.Same(originalBackground, vm.ActiveBackgroundImage); Assert.Same(originalBackground, MainViewModel.LoadBackgroundImage(starting)); Assert.NotSame(tile, vm.BackgroundImage); // A scene without its own tile keeps its background art as the strip. var ending = SceneByName(vm, SceneCatalog.Ending); Assert.Null(ending.ThumbnailAssetId); Assert.Same(ImageCache.Get(env.BackgroundIds[SceneCatalog.Ending]), ending.Snapshot); // The mapping and the tile asset survive a save + reload (the // Save-time Asset prune must keep Scene.ThumbnailAssetId rows), and // a second load re-seeds nothing new (UpsertAsset is SHA256-keyed). vm.SaveLayoutNow(); using (var reloaded = new LayoutStore(env.DbPath)) { var reloadedStarting = reloaded.Load().Single(s => s.Name == SceneCatalog.Starting); Assert.Equal(starting.ThumbnailAssetId, reloadedStarting.ThumbnailAssetId); Assert.NotNull(reloaded.GetAssetBytes(starting.ThumbnailAssetId)); Assert.Equal(1, CountAssetRows(env.DbPath, starting.ThumbnailAssetId)); } var secondVm = new MainViewModel(); Assert.Equal(starting.ThumbnailAssetId, SceneByName(secondVm, SceneCatalog.Starting).ThumbnailAssetId); } finally { env?.Restore(); env?.Dispose(); } }); } [Fact] public void Live_Staged_Mini_Shows_Its_Persisted_Tile_Not_The_Capture_Placeholder() { _app.Run(() => { EnvGuard? env = null; try { env = ArrangedDb(out var file); env.Apply(); var tileAssetId = LayoutStore.Instance!.UpsertAsset(File.ReadAllBytes(file.TilePath), 320, 180); using (var connection = new SqliteConnection($"Data Source={env.DbPath}")) { connection.Open(); using var cmd = connection.CreateCommand(); cmd.CommandText = "UPDATE Scene SET ThumbnailAssetId = $asset WHERE Name = $scene;"; cmd.Parameters.AddWithValue("$asset", tileAssetId); cmd.Parameters.AddWithValue("$scene", SceneCatalog.Live); Assert.Equal(1, cmd.ExecuteNonQuery()); } var vm = new MainViewModel(); var live = SceneByName(vm, SceneCatalog.Live); Assert.False(string.IsNullOrWhiteSpace(live.ThumbnailAssetId)); vm.StagedScene = live; // Minis never render captures; a seeded Live tile shows instead of // the green placeholder, while the preview still shows the background. Assert.Same(ImageCache.Get(live.ThumbnailAssetId), live.Snapshot); Assert.Same(ImageCache.Get(env.BackgroundIds[SceneCatalog.Live]), vm.BackgroundImage); } finally { env?.Restore(); env?.Dispose(); } }); } [Fact] public void Staged_Scene_Tile_Shows_The_On_Air_Art_And_It_Follows_The_Scene_Swap() { _app.Run(() => { EnvGuard? env = null; try { env = ArrangedDb(out var file); MainViewModel.OnAirSeedPath = file.OnAirPath; env.Apply(); var vm = new MainViewModel(); var starting = SceneByName(vm, SceneCatalog.Starting); var live = SceneByName(vm, SceneCatalog.Live); var onAir = ImageCache.Get(Services.LayoutStore.Instance!.GetOnAirImageId()!); Assert.NotNull(onAir); // The staged scene's tile (Starting at launch) shows the on-air art. Assert.Same(onAir, starting.Snapshot); // Unstaged scenes keep their own art, never the on-air image. Assert.NotSame(onAir, live.Snapshot); // Swapping scenes moves the on-air tile: staging Live swaps it across, // and Starting's tile returns to its own background art (no thumbnail). vm.StagedScene = live; Assert.Same(onAir, live.Snapshot); Assert.Same(ImageCache.Get(env.BackgroundIds[SceneCatalog.Starting]), starting.Snapshot); // The preview pane still renders the scene's ORIGINAL background — // the on-air art never leaks into the preview. Assert.Same(ImageCache.Get(env.BackgroundIds[SceneCatalog.Live]), vm.BackgroundImage); // Persistence: the on-air asset survives a save + reload (the Save // pruner must keep the OnAirImageAssetId setting's row), and a fresh // VM re-stamps it from the DB instead of re-importing. vm.SaveLayoutNow(); using (var reloaded = new LayoutStore(env.DbPath)) { var id = reloaded.GetOnAirImageId(); Assert.NotNull(id); Assert.NotNull(reloaded.GetAssetBytes(id)); } var secondVm = new MainViewModel(); Assert.Same(onAir, SceneByName(secondVm, SceneCatalog.Starting).Snapshot); } finally { env?.Restore(); env?.Dispose(); } }); } [Fact] public void No_Seed_File_Means_No_Thumbnail_And_Background_Art_Fallback() { _app.Run(() => { EnvGuard? env = null; try { env = ArrangedDb(out _); env.SeedPaths.Clear(); env.Apply(); var vm = new MainViewModel(); foreach (var scene in vm.Scenes) { Assert.Null(scene.ThumbnailAssetId); Assert.Same(ImageCache.Get(env.BackgroundIds[scene.Name]), scene.Snapshot); } } finally { env?.Restore(); env?.Dispose(); } }); } // ─── Fixture plumbing ─── private static Scene SceneByName(MainViewModel vm, string name) => vm.Scenes.Single(s => s.Name == name); private static int CountAssetRows(string dbPath, string assetId) { using var connection = new SqliteConnection($"Data Source={dbPath}"); connection.Open(); using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM Asset WHERE Id = $id;"; cmd.Parameters.AddWithValue("$id", assetId); return Convert.ToInt32(cmd.ExecuteScalar()); } /// Builds a temp DB whose every scene has a REAL background asset row. /// One throwaway VM creates the canonical scenes + heals their Backgrounds /// (the pack:// default-art stamp silently fails under the test host, so the /// contract is tested with an explicit background — the tile must never /// replace it). Seed paths start all-missing; the caller picks which scenes /// get a tile. private static EnvGuard ArrangedDb(out Files files) { files = new Files(); var dbPath = Path.Combine(Path.GetTempPath(), $"ytLive-scenethumb-{Guid.NewGuid():N}.db"); using (new LayoutStore(dbPath)) { } SqliteConnection.ClearAllPools(); var env = new EnvGuard(dbPath, files); try { env.Apply(); var builder = new MainViewModel(); var backgroundIds = new Dictionary(StringComparer.OrdinalIgnoreCase); var i = 0; foreach (var scene in builder.Scenes) { var bg = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground); Assert.NotNull(bg); var bytes = files.NewBackgroundBytes(i++); var id = LayoutStore.Instance!.UpsertAsset(bytes, 320, 180); Assert.NotNull(id); bg!.AssetId = id; backgroundIds[scene.Name] = id; } builder.SaveLayoutNow(); env.BackgroundIds = backgroundIds; return env; } catch { env.Restore(); env.Dispose(); throw; } } private sealed record EnvGuard : IDisposable { public string DbPath { get; } public Files Files { get; } public Dictionary BackgroundIds { get; set; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary SeedPaths { get; } private readonly Dictionary _originalPaths; private readonly string _originalOnAirPath; public EnvGuard(string dbPath, Files files) { DbPath = dbPath; Files = files; _originalPaths = new Dictionary(MainViewModel.SceneThumbnailSeedPaths, StringComparer.OrdinalIgnoreCase); _originalOnAirPath = MainViewModel.OnAirSeedPath; SeedPaths = new Dictionary(StringComparer.OrdinalIgnoreCase); MainViewModel.LayoutPathOverride = dbPath; MainViewModel.OnAirSeedPath = MissingPath(); foreach (var path in _originalPaths) SeedPaths[path.Key] = MissingPath(); } /// Writes the tests' chosen SeedPaths into the live map; any /// canonical scene not overridden stays pointing at a missing file. public void Apply() { MainViewModel.SceneThumbnailSeedPaths.Clear(); foreach (var (key, value) in SeedPaths) MainViewModel.SceneThumbnailSeedPaths[key] = value; } public void Restore() { MainViewModel.SceneThumbnailSeedPaths.Clear(); foreach (var (key, value) in _originalPaths) MainViewModel.SceneThumbnailSeedPaths[key] = value; MainViewModel.OnAirSeedPath = _originalOnAirPath; MainViewModel.LayoutPathOverride = null; SqliteConnection.ClearAllPools(); } public void Dispose() { Files.Dispose(); try { File.Delete(DbPath); } catch { } } } private sealed class Files : IDisposable { public string TilePath { get; } public string OnAirPath { get; } private readonly List _paths = new(); public Files() { TilePath = NewImage(320, 180, Brushes.Teal, Brushes.Red); OnAirPath = NewImage(320, 180, Brushes.SlateBlue, Brushes.White); } /// Distinct per-scene solid-color "original background". public byte[] NewBackgroundBytes(int index) { var colors = new[] { Brushes.ForestGreen, Brushes.OrangeRed, Brushes.DodgerBlue, Brushes.DarkOrchid, Brushes.SaddleBrown }; var path = NewImage(320, 180, colors[index % colors.Length], Brushes.Black); return File.ReadAllBytes(path); } private string NewImage(int w, int h, Brush fill, Brush accent) { var path = Path.Combine(Path.GetTempPath(), $"ytLive-scenethumb-img-{Guid.NewGuid():N}.png"); var dv = new DrawingVisual(); using (var dc = dv.RenderOpen()) { dc.DrawRectangle(fill, null, new Rect(0, 0, w, h)); dc.DrawRectangle(accent, null, new Rect(24, 24, 64, 64)); } var rtb = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32); rtb.Render(dv); var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(rtb)); using var fs = File.OpenWrite(path); encoder.Save(fs); _paths.Add(path); return path; } public void Dispose() { foreach (var path in _paths) try { File.Delete(path); } catch { } } } private static string MissingPath() => Path.Combine(Path.GetTempPath(), $"ytLive-no-such-{Guid.NewGuid():N}.png"); }