Scene strip tile artwork + on-air indicator (creator ruling 2026-10-10). Top-bar tiles now carry their own art: each canonical scene's tile is a per-scene CRT-styled image stored in the internal DB (Scene.ThumbnailAssetId - additive migration, seeded at heal from the creator's five Downloads JPGs via SceneThumbnailSeedPaths), and the preview pane keeps rendering the scene's ORIGINAL background - never the tiles. One shared on-air image (Gemini 4zajyl...jpg) replaces the staged scene's tile and follows the scene swap ('the on-air screen jumps around the top bar as the user swaps scenes'): stored as an Asset BLOB tracked by the OnAirImageAssetId Setting (EnsureOnAirImage, same contract as the alert clip) with MainViewModel.OnAirSeedPath override; RefreshSnapshotsAsync shows it for the staged scene, otherwise thumbnail-or-background art, and a staged Live with no on-air art still falls back to the green placeholder (minis never render live captures). Removed the 6px #22c55e rectangle in PreviewPane's backdrop ('green outline around the preview screen' that surfaced when a scene had no background) - the outer PreviewGlow red border is the real live signal. Doubled the scene-tile labels (FontSize 10 -> 20). Fixed a real bug this surfaced: Save()'s Asset pruner only kept Source/alert-clip rows, so the first save would have DELETED the seeded tile + on-air assets - the keep-set now includes Scene.ThumbnailAssetId and the OnAirImageAssetId Setting. New Settings/Load/Save migrations + SceneThumbnailTests (4 tests: seeded tile in strip, preview keeps original bg, Live-staged tile, no-seed fallback, on-air swap + persistence). 427 pass, 0 build warnings. (Unit: strip tiles + on-air)
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Scene strip tile artwork (creator ruling 2026-10-10). Each canonical scene's
|
||||
/// top-strip tile is its own image, stored as an Asset row and seeded from the
|
||||
/// creator's CRT-styled renders. The preview pane keeps rendering each scene's
|
||||
/// ORIGINAL background — the tiles never appear as preview backgrounds.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class SceneThumbnailTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public SceneThumbnailTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void Strip_Shows_The_Seeded_Tile_And_The_Preview_Keeps_The_Original_Background()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
EnvGuard? env = null;
|
||||
try
|
||||
{
|
||||
env = ArrangedDb(out var file);
|
||||
env.SeedPaths[SceneCatalog.Starting] = file.TilePath;
|
||||
env.SeedPaths[SceneCatalog.Live] = MissingPath();
|
||||
env.Apply();
|
||||
|
||||
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_Tile_Not_The_Capture_Placeholder()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
EnvGuard? env = null;
|
||||
try
|
||||
{
|
||||
env = ArrangedDb(out var file);
|
||||
env.SeedPaths[SceneCatalog.Live] = file.TilePath;
|
||||
env.Apply();
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var i = 0;
|
||||
foreach (var scene in builder.Scenes)
|
||||
{
|
||||
var bg = scene.Elements.OfType<Source>().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<string, string> BackgroundIds { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, string> SeedPaths { get; }
|
||||
private readonly Dictionary<string, string> _originalPaths;
|
||||
private readonly string _originalOnAirPath;
|
||||
|
||||
public EnvGuard(string dbPath, Files files)
|
||||
{
|
||||
DbPath = dbPath;
|
||||
Files = files;
|
||||
_originalPaths = new Dictionary<string, string>(MainViewModel.SceneThumbnailSeedPaths, StringComparer.OrdinalIgnoreCase);
|
||||
_originalOnAirPath = MainViewModel.OnAirSeedPath;
|
||||
SeedPaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
MainViewModel.LayoutPathOverride = dbPath;
|
||||
MainViewModel.OnAirSeedPath = MissingPath();
|
||||
foreach (var path in _originalPaths)
|
||||
SeedPaths[path.Key] = MissingPath();
|
||||
}
|
||||
|
||||
/// <summary>Writes the tests' chosen SeedPaths into the live map; any
|
||||
/// canonical scene not overridden stays pointing at a missing file.</summary>
|
||||
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<string> _paths = new();
|
||||
|
||||
public Files()
|
||||
{
|
||||
TilePath = NewImage(320, 180, Brushes.Teal, Brushes.Red);
|
||||
OnAirPath = NewImage(320, 180, Brushes.SlateBlue, Brushes.White);
|
||||
}
|
||||
|
||||
/// <summary>Distinct per-scene solid-color "original background".</summary>
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user