Backgrounds: one locked Background per screen + mini rule (TASK 25)

This commit is contained in:
2026-08-23 13:33:33 -07:00
parent b400ab59c5
commit 7dd140a4c0
13 changed files with 554 additions and 309 deletions
@@ -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;
/// <summary>
/// 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).
/// </summary>
[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<Source>().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<string, long>();
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 */ }
}
}
/// <summary>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.</summary>
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();
}
}
+3 -1
View File
@@ -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);
+49 -15
View File
@@ -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<Source>().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<Source>(), 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);
}
}
+5 -2
View File
@@ -44,14 +44,17 @@ public sealed class SourceNamingTests
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
var names = scene.Elements.OfType<Source>().Select(s => s.Name).ToList();
// The locked Background layer is always present but never numbered.
var names = scene.Elements.OfType<Source>().Where(s => !s.IsBackground)
.Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text2", "Text3" }, names);
var middle = scene.Elements.OfType<Source>().Single(s => s.Name == "Text2");
scene.Elements.Remove(middle);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
var survivors = scene.Elements.OfType<Source>().Select(s => s.Name).ToList();
var survivors = scene.Elements.OfType<Source>().Where(s => !s.IsBackground)
.Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text3", "Text2" }, survivors);
}
finally