using System;
using System.IO;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
///
/// 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.
///
public class LayoutStorePersistenceTests
{
[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(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 backdrop (schema v5): permanent bottom layer holding live desktop/game
// capture. IsBackdrop + CaptureKey must survive a save + reload so a
// re-designated target isn't lost on restart.
[Fact]
public void Backdrop_IsBackdrop_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 backdrop = new Source
{
Name = "Backdrop",
Type = SourceType.DisplayCapture,
IsBackdrop = 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(backdrop);
store.Save(new[] { scene }, null, null);
var reloaded = store.Load();
var restored = Assert.IsType(Assert.Single(reloaded[0].Elements));
Assert.True(restored.IsBackdrop);
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 */ }
}
}
// Per-scene backdrop switch (schema v6): a scene whose backdrop was turned
// off must stay off across a save + reload.
[Fact]
public void Scene_HasBackdrop_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", HasBackdrop = true };
var off = new Scene { Name = "Ending", HasBackdrop = false };
store.Save(new[] { on, off }, null, null);
var reloaded = store.Load();
Assert.True(reloaded.First(s => s.Name == "Live").HasBackdrop);
Assert.False(reloaded.First(s => s.Name == "Ending").HasBackdrop);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// v5 → v6 one-time backfill: pre-v6 DBs already have backdrops sitting in the
// non-Live canonical scenes (Starting/BRB/Chat/Ending). Adding the HasBackdrop
// column turns those scenes off and drops their backdrop sources; Live keeps
// its backdrop.
[Fact]
public void V5_Database_Backfill_Removes_NonLive_Backdrops()
{
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,
IsBackdrop 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, IsBackdrop, CaptureKey, SortOrder)
VALUES ('b-starting', 's-starting', 'DisplayCapture', 'Backdrop', 1, 'monitor:0', 0);
INSERT INTO Source (Id, SceneId, Type, Name, IsBackdrop, CaptureKey, SortOrder)
VALUES ('b-live', 's-live', 'DisplayCapture', 'Backdrop', 1, 'monitor:0', 0);
INSERT INTO Source (Id, SceneId, Type, Name, IsBackdrop, CaptureKey, SortOrder)
VALUES ('b-chat', 's-chat', 'DisplayCapture', 'Backdrop', 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.HasBackdrop);
Assert.DoesNotContain(starting.Elements, e => e is Source { IsBackdrop: true });
var chat = Assert.Single(scenes, s => s.Name == "Chat");
Assert.False(chat.HasBackdrop);
Assert.DoesNotContain(chat.Elements, e => e is Source { IsBackdrop: true });
var live = Assert.Single(scenes, s => s.Name == "Live");
Assert.True(live.HasBackdrop);
Assert.Single(live.Elements, e => e is Source { IsBackdrop: 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(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 */ }
}
}
}