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;
// The creator's own picture, not the shipped art: a default-art background pointing at
// other bytes is re-seeded by StampDefaultBackgroundAsset (2026-11-06), and this fixture
// needs its planted art to stay put. Browse… always sets the path alongside the asset.
bg.UseDefaultBackground = false;
bg.CustomBackgroundPath = Path.Combine(Path.GetTempPath(), $"ytLive-missing-{i}.jpg");
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");
}