Files
LlamaCasty/ytLive.Tests/LayoutStorePersistenceTests.cs
T

356 lines
14 KiB
C#

using System;
using System.IO;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// The layout DB is a full rewrite on every save (DELETE all scenes/sources,
/// re-insert from memory). This guards the round trip: webcam configs the user
/// removes in the UI must not come back after a save + reload.
/// </summary>
public class LayoutStorePersistenceTests
{
// The reusable stream cache (TASK 5): the RTMP URL must survive restarts so
// the frame pump has it the moment go-live starts, without a YouTube round-trip.
[Fact]
public void ReusableStream_Cache_RoundTrips()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using (var store = new LayoutStore(path))
{
store.SaveReusableStream(new ReusableStream("S456", "rtmp://a.rtmp.youtube.com/live2", "KEY123"));
var loaded = store.LoadReusableStream();
Assert.NotNull(loaded);
Assert.Equal("S456", loaded.Id);
Assert.Equal("rtmp://a.rtmp.youtube.com/live2", loaded.IngestionAddress);
Assert.Equal("KEY123", loaded.StreamName);
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY123", loaded.RtmpUrl);
}
using (var reopened = new LayoutStore(path))
Assert.Equal("KEY123", reopened.LoadReusableStream()?.StreamName);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
[Fact]
public void Deleted_Webcam_Config_Does_Not_Return_After_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var webcam = new Webcam { DeviceId = "real-device", Name = "Logitech" };
var scene = new Scene { Name = "Starting" };
scene.Elements.Add(new WebcamSceneConfig
{
WebcamId = webcam.Id,
Name = webcam.Name,
Width = 480,
Height = 270,
});
store.Save(new[] { scene }, webcam, null);
var reloaded = store.Load();
var config = Assert.Single(reloaded[0].Elements);
Assert.IsType<WebcamSceneConfig>(config);
reloaded[0].Elements.RemoveAt(0);
store.Save(reloaded, store.Webcam, null);
var afterDelete = store.Load();
Assert.Empty(afterDelete[0].Elements);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// The background (schema v5): permanent bottom layer holding live desktop/game
// capture. IsBackground + CaptureKey must survive a save + reload so a
// re-designated target isn't lost on restart.
[Fact]
public void Background_IsBackground_And_CaptureKey_Survive_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var background = new Source
{
Name = "Game Capture",
Type = SourceType.DisplayCapture,
IsBackground = true,
IsEnabled = true,
X = 0,
Y = 0,
Width = 1920,
Height = 1080,
MonitorIndex = 1,
CaptureKey = "monitor:1",
};
var scene = new Scene { Name = "Starting" };
scene.Elements.Add(background);
store.Save(new[] { scene }, null, null);
var reloaded = store.Load();
var restored = Assert.IsType<Source>(Assert.Single(reloaded[0].Elements));
Assert.True(restored.IsBackground);
Assert.Equal("monitor:1", restored.CaptureKey);
Assert.Equal(1, restored.MonitorIndex);
Assert.Equal(SourceType.DisplayCapture, restored.Type);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// Rows saved before the Type setter derived IsBackground carry IsBackground=0
// for Type='Background', which used to load false — hiding the Background
// properties panel in BRB/Chat/Ending. Load must derive from Type while
// keeping the persisted bit for legacy DisplayCapture backdrops.
[Fact]
public void Legacy_IsBackground_Zero_Rows_Derive_From_Type_On_Load()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using (var store = new LayoutStore(path))
{
var scene = new Scene { Name = "BRB", HasBackground = true };
scene.Elements.Add(new Source { Name = "Game Capture", Type = SourceType.DisplayCapture, IsBackground = true });
scene.Elements.Add(new Source { Name = "Background", Type = SourceType.Background });
scene.Elements.Add(new Source { Name = "Logo", Type = SourceType.Image });
store.Save(new[] { scene }, null, null);
using var conn = new SqliteConnection($"Data Source={path}");
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE Source SET IsBackground = 0 WHERE Name IN ('Background', 'Logo');";
cmd.ExecuteNonQuery();
}
using var reopened = new LayoutStore(path);
var reloaded = reopened.Load();
var sources = Assert.Single(reloaded).Elements.OfType<Source>().ToList();
Assert.True(sources.Single(s => s.Type == SourceType.Background).IsBackground,
"A legacy IsBackground=0 row with Type=Background must derive the flag on load");
Assert.True(sources.Single(s => s.Type == SourceType.DisplayCapture).IsBackground,
"The persisted bit must survive for legacy DisplayCapture backdrops");
Assert.False(sources.Single(s => s.Type == SourceType.Image).IsBackground);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// Per-scene background switch (schema v6): a scene whose background was turned
// off must stay off across a save + reload.
[Fact]
public void Scene_HasBackground_Survives_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var on = new Scene { Name = "Live", HasBackground = true };
var off = new Scene { Name = "Ending", HasBackground = false };
store.Save(new[] { on, off }, null, null);
var reloaded = store.Load();
Assert.True(reloaded.First(s => s.Name == "Live").HasBackground);
Assert.False(reloaded.First(s => s.Name == "Ending").HasBackground);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// v5 → v6 one-time backfill: pre-v6 DBs already have backgrounds sitting in the
// non-Live canonical scenes (Starting/BRB/Chat/Ending). Adding the HasBackground
// column turns those scenes off and drops their background sources; Live keeps
// its background.
[Fact]
public void V5_Database_Backfill_Removes_NonLive_Backgrounds()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using (var conn = new SqliteConnection($"Data Source={path}"))
{
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
CREATE TABLE Scene (
Id TEXT PRIMARY KEY,
Name TEXT NOT NULL,
IsHidden INTEGER NOT NULL DEFAULT 0,
IsChatScene INTEGER NOT NULL DEFAULT 0,
SortOrder INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE Source (
Id TEXT PRIMARY KEY,
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
AssetId TEXT,
Type TEXT NOT NULL,
Name TEXT NOT NULL,
IsEnabled INTEGER NOT NULL DEFAULT 1,
X REAL NOT NULL DEFAULT 0,
Y REAL NOT NULL DEFAULT 0,
Width REAL NOT NULL DEFAULT 0,
Height REAL NOT NULL DEFAULT 0,
Opacity REAL NOT NULL DEFAULT 1,
MonitorIndex INTEGER,
DeviceId TEXT,
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0,
IsBackground INTEGER NOT NULL DEFAULT 0,
CaptureKey TEXT,
SortOrder INTEGER NOT NULL DEFAULT 0
);
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-starting', 'Starting', 0);
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-live', 'Live', 1);
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-chat', 'Chat', 2);
INSERT INTO Source (Id, SceneId, Type, Name, IsBackground, CaptureKey, SortOrder)
VALUES ('b-starting', 's-starting', 'DisplayCapture', 'Background', 1, 'monitor:0', 0);
INSERT INTO Source (Id, SceneId, Type, Name, IsBackground, CaptureKey, SortOrder)
VALUES ('b-live', 's-live', 'DisplayCapture', 'Background', 1, 'monitor:0', 0);
INSERT INTO Source (Id, SceneId, Type, Name, IsBackground, CaptureKey, SortOrder)
VALUES ('b-chat', 's-chat', 'DisplayCapture', 'Background', 1, 'monitor:0', 0);
PRAGMA user_version = 5;
""";
cmd.ExecuteNonQuery();
}
using var store = new LayoutStore(path);
var scenes = store.Load();
var starting = Assert.Single(scenes, s => s.Name == "Starting");
Assert.False(starting.HasBackground);
Assert.DoesNotContain(starting.Elements, e => e is Source { IsBackground: true });
var chat = Assert.Single(scenes, s => s.Name == "Chat");
Assert.False(chat.HasBackground);
Assert.DoesNotContain(chat.Elements, e => e is Source { IsBackground: true });
var live = Assert.Single(scenes, s => s.Name == "Live");
Assert.True(live.HasBackground);
Assert.Single(live.Elements, e => e is Source { IsBackground: true });
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// Round-to-rect restore is persisted (schema v4): a Round webcam resized to a
// square saves its pre-Round rect dims, and a reloaded config restores them on
// toggle-back instead of staying square.
[Fact]
public void Pre_Round_Rect_Dims_Survive_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var webcam = new Webcam { DeviceId = "real-device", Name = "Logitech" };
var scene = new Scene { Name = "Starting" };
scene.Elements.Add(new WebcamSceneConfig
{
WebcamId = webcam.Id,
Name = webcam.Name,
Width = 400,
Height = 400,
ClipShape = ClipShape.Round,
RectWidth = 480,
RectHeight = 270,
});
store.Save(new[] { scene }, webcam, null);
var reloaded = store.Load();
var config = Assert.IsType<WebcamSceneConfig>(Assert.Single(reloaded[0].Elements));
config.ToggleClipShape();
Assert.Equal(ClipShape.Traditional, config.ClipShape);
Assert.Equal(480, config.Width);
Assert.Equal(270, config.Height);
Assert.Null(config.RectWidth);
Assert.Null(config.RectHeight);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// TRAX (schema v9): the app-wide background track is one Music row; it must
// survive a save + reload so the creator's chosen track is restored.
[Fact]
public void Music_Track_Survives_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var scene = new Scene { Name = "Starting" };
var music = new Music { TrackPath = @"C:\music\my-banger.mp3", IsEnabled = true };
store.Save(new[] { scene }, null, null, music);
store.Load();
Assert.NotNull(store.Music);
Assert.Equal(@"C:\music\my-banger.mp3", store.Music!.TrackPath);
Assert.True(store.Music.IsEnabled);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
[Fact]
public void No_Music_Row_Loads_Null()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var scene = new Scene { Name = "Starting" };
store.Save(new[] { scene }, null, null);
store.Load();
Assert.Null(store.Music);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
}