Add additive defaults and round-trip tests for per-layer text content and formatting. Model the static text source after OBS text-source conventions: https://obsproject.com/kb/text-sources
644 lines
27 KiB
C#
644 lines
27 KiB
C#
using System;
|
|
using System.IO;
|
|
using Microsoft.Data.Sqlite;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
using ytLive.ViewModels;
|
|
|
|
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
|
|
{
|
|
[Fact]
|
|
public void Text_Layers_RoundTrip_Independently_And_Legacy_Rows_Get_Defaults()
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
|
|
try
|
|
{
|
|
var scene = new Scene { Name = "Starting" };
|
|
scene.Elements.Add(new Source
|
|
{
|
|
Name = "Headline",
|
|
Type = SourceType.TextOverlay,
|
|
TextContent = "First line\nSecond line",
|
|
TextFontFamily = "Arial",
|
|
TextFontSize = 72,
|
|
TextColor = "#12AB34",
|
|
TextBold = true,
|
|
TextAlignment = TextLayerAlignment.Center,
|
|
});
|
|
scene.Elements.Add(new Source
|
|
{
|
|
Name = "Caption",
|
|
Type = SourceType.TextOverlay,
|
|
TextContent = "Independent caption",
|
|
TextFontFamily = "Segoe UI",
|
|
TextFontSize = 24,
|
|
TextColor = "#80445566",
|
|
TextItalic = true,
|
|
TextUnderline = true,
|
|
TextAlignment = TextLayerAlignment.Right,
|
|
});
|
|
|
|
using (var store = new LayoutStore(path))
|
|
store.Save(new[] { scene }, null, null);
|
|
|
|
using (var reopened = new LayoutStore(path))
|
|
{
|
|
var sources = Assert.Single(reopened.Load()).Elements.OfType<Source>()
|
|
.Where(source => source.Type == SourceType.TextOverlay).ToArray();
|
|
Assert.Equal(2, sources.Length);
|
|
Assert.Equal("First line\nSecond line", sources[0].TextContent);
|
|
Assert.Equal("Arial", sources[0].TextFontFamily);
|
|
Assert.Equal(72, sources[0].TextFontSize);
|
|
Assert.Equal("#12AB34", sources[0].TextColor);
|
|
Assert.True(sources[0].TextBold);
|
|
Assert.False(sources[0].TextItalic);
|
|
Assert.False(sources[0].TextUnderline);
|
|
Assert.Equal(TextLayerAlignment.Center, sources[0].TextAlignment);
|
|
Assert.Equal("Independent caption", sources[1].TextContent);
|
|
Assert.Equal(24, sources[1].TextFontSize);
|
|
Assert.Equal("#80445566", sources[1].TextColor);
|
|
Assert.False(sources[1].TextBold);
|
|
Assert.True(sources[1].TextItalic);
|
|
Assert.True(sources[1].TextUnderline);
|
|
Assert.Equal(TextLayerAlignment.Right, sources[1].TextAlignment);
|
|
}
|
|
|
|
SqliteConnection.ClearAllPools();
|
|
using (var raw = new SqliteConnection($"Data Source={path}"))
|
|
{
|
|
raw.Open();
|
|
using var cmd = raw.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source DROP COLUMN TextContent; ALTER TABLE Source DROP COLUMN TextFontFamily; ALTER TABLE Source DROP COLUMN TextFontSize; ALTER TABLE Source DROP COLUMN TextColor; ALTER TABLE Source DROP COLUMN TextBold; ALTER TABLE Source DROP COLUMN TextItalic; ALTER TABLE Source DROP COLUMN TextUnderline; ALTER TABLE Source DROP COLUMN TextAlignment;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
SqliteConnection.ClearAllPools();
|
|
|
|
using (var legacy = new LayoutStore(path))
|
|
{
|
|
var sources = Assert.Single(legacy.Load()).Elements.OfType<Source>()
|
|
.Where(source => source.Type == SourceType.TextOverlay).ToArray();
|
|
Assert.Equal(2, sources.Length);
|
|
foreach (var source in sources)
|
|
{
|
|
Assert.Equal("Text", source.TextContent);
|
|
Assert.Equal("Segoe UI", source.TextFontFamily);
|
|
Assert.Equal(48, source.TextFontSize);
|
|
Assert.Equal("#FFFFFF", source.TextColor);
|
|
Assert.False(source.TextBold);
|
|
Assert.False(source.TextItalic);
|
|
Assert.False(source.TextUnderline);
|
|
Assert.Equal(TextLayerAlignment.Left, source.TextAlignment);
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(path); } catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>TASK 47: the alert announcement display method must survive a
|
|
/// restart. This one is easy to get quietly wrong — the Source row is read back
|
|
/// by POSITIONAL index, so a column inserted anywhere but the end silently
|
|
/// shifts a neighbour (e.g. AlertVideoVolume) rather than failing to load.
|
|
/// <see cref="AlertBoxPreviewVisibilityTests"/>'s neighbour settings are the
|
|
/// canary: all three alert fields are asserted together.</summary>
|
|
[Theory]
|
|
[InlineData(AlertDisplayMethod.TickerScroll)]
|
|
[InlineData(AlertDisplayMethod.Flash)]
|
|
[InlineData(AlertDisplayMethod.Solid)]
|
|
public void AlertDisplayMethod_RoundTrips(AlertDisplayMethod method)
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
|
|
try
|
|
{
|
|
using (var store = new LayoutStore(path))
|
|
store.Save(new[]
|
|
{
|
|
new Scene
|
|
{
|
|
Name = SceneCatalog.Live,
|
|
Elements =
|
|
{
|
|
new Source
|
|
{
|
|
Name = "Stream Alerts",
|
|
Type = SourceType.AlertBox,
|
|
AlertShowTicker = true,
|
|
AlertVideoVolume = 0.25,
|
|
AlertDisplayMethod = method,
|
|
},
|
|
},
|
|
},
|
|
}, null, null);
|
|
|
|
using var reopened = new LayoutStore(path);
|
|
var box = reopened.Load().SelectMany(s => s.Elements).OfType<Source>()
|
|
.Single(s => s.Type == SourceType.AlertBox);
|
|
Assert.Equal(method, box.AlertDisplayMethod);
|
|
Assert.True(box.AlertShowTicker);
|
|
Assert.Equal(0.25, box.AlertVideoVolume, 3);
|
|
}
|
|
finally
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(path); } catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
|
|
// 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 */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>TASK 51 slice 3: each scene's webcam background effect + strength survives a
|
|
/// save and a reopen; a pre-TASK-51 DB (no columns) migrates in place and reads None/0.5.</summary>
|
|
[Fact]
|
|
public void Webcam_Background_Effect_Round_Trips_Per_Scene_And_Legacy_Db_Migrates()
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
|
|
try
|
|
{
|
|
var webcam = new Webcam { DeviceId = "real-device", Name = "Logitech" };
|
|
var live = new Scene { Name = "Live" };
|
|
live.Elements.Add(new WebcamSceneConfig { WebcamId = webcam.Id, Width = 480, Height = 270, BackgroundEffect = BackgroundEffect.Remove, BackgroundStrength = 0.2 });
|
|
var chat = new Scene { Name = "Chat" };
|
|
chat.Elements.Add(new WebcamSceneConfig { WebcamId = webcam.Id, Width = 480, Height = 270, BackgroundEffect = BackgroundEffect.Blur, BackgroundStrength = 0.8 });
|
|
using (var store = new LayoutStore(path))
|
|
store.Save(new[] { live, chat }, webcam, null);
|
|
|
|
using (var reopened = new LayoutStore(path))
|
|
{
|
|
var scenes = reopened.Load();
|
|
var liveCfg = scenes.Single(s => s.Name == "Live").Elements.OfType<WebcamSceneConfig>().Single();
|
|
var chatCfg = scenes.Single(s => s.Name == "Chat").Elements.OfType<WebcamSceneConfig>().Single();
|
|
Assert.Equal(BackgroundEffect.Remove, liveCfg.BackgroundEffect);
|
|
Assert.Equal(0.2, liveCfg.BackgroundStrength, 6);
|
|
Assert.Equal(BackgroundEffect.Blur, chatCfg.BackgroundEffect);
|
|
Assert.Equal(0.8, chatCfg.BackgroundStrength, 6);
|
|
}
|
|
|
|
// Simulate a DB written before TASK 51: drop the two columns, reopen.
|
|
SqliteConnection.ClearAllPools();
|
|
using (var raw = new SqliteConnection($"Data Source={path}"))
|
|
{
|
|
raw.Open();
|
|
using var cmd = raw.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE WebcamSceneConfig DROP COLUMN BackgroundEffect; ALTER TABLE WebcamSceneConfig DROP COLUMN BackgroundStrength;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
SqliteConnection.ClearAllPools();
|
|
using (var legacy = new LayoutStore(path))
|
|
{
|
|
var cfg = legacy.Load().SelectMany(s => s.Elements).OfType<WebcamSceneConfig>().First();
|
|
Assert.Equal(BackgroundEffect.None, cfg.BackgroundEffect);
|
|
Assert.Equal(0.5, cfg.BackgroundStrength, 6);
|
|
}
|
|
}
|
|
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 */ }
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void WebSource_WebUri_Persists()
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
|
|
try
|
|
{
|
|
using var store = new LayoutStore(path);
|
|
var scene = new Scene { Name = "Main" };
|
|
var source = new Source
|
|
{
|
|
Name = "Overlay",
|
|
Type = SourceType.WebSource,
|
|
Width = 640,
|
|
Height = 480,
|
|
WebUri = "https://widgets.streamelements.com/host/661d8622ff009005bd74b8bc/d4fb46cd-4cc1-44eb-a800-3a415ca6233f/yTZ18ad1MOF3Vzii6kH2z6D0Rxuv7IUnXPl0ExeJM5EAizLk",
|
|
};
|
|
scene.Elements.Add(source);
|
|
|
|
store.Save(new[] { scene }, null, null);
|
|
|
|
var reloaded = store.Load();
|
|
var loaded = Assert.Single(reloaded[0].Elements);
|
|
Assert.IsType<Source>(loaded);
|
|
var loadedSource = (Source)loaded;
|
|
Assert.Equal(SourceType.WebSource, loadedSource.Type);
|
|
Assert.Equal(640, loadedSource.Width);
|
|
Assert.Equal(480, loadedSource.Height);
|
|
Assert.Equal("https://widgets.streamelements.com/host/661d8622ff009005bd74b8bc/d4fb46cd-4cc1-44eb-a800-3a415ca6233f/yTZ18ad1MOF3Vzii6kH2z6D0Rxuv7IUnXPl0ExeJM5EAizLk", loadedSource.WebUri);
|
|
}
|
|
finally
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(path); } catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
|
|
// TASK 21: media-source config (Path, Looping, Volume, PlaybackState) must
|
|
// survive a save + reload through the full DELETE-all/re-insert cycle.
|
|
// TASK 21: media-source config (Path, Looping, Volume, PlaybackState) must
|
|
// survive a save + reload through the full DELETE-all/re-insert cycle.
|
|
[Fact]
|
|
public void MediaSource_Config_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 source = new Source
|
|
{
|
|
Name = "Starting Loop",
|
|
Type = SourceType.MediaSource,
|
|
Width = 1920,
|
|
Height = 1080,
|
|
MediaPath = @"C:\videos\starting.mp4",
|
|
MediaIsLooping = true,
|
|
MediaVolume = 0.75,
|
|
MediaPlaybackState = MediaPlaybackState.Playing,
|
|
};
|
|
scene.Elements.Add(source);
|
|
|
|
store.Save(new[] { scene }, null, null);
|
|
|
|
var reloaded = store.Load();
|
|
var loaded = Assert.Single(reloaded[0].Elements);
|
|
var loadedSource = Assert.IsType<Source>(loaded);
|
|
|
|
Assert.Equal(SourceType.MediaSource, loadedSource.Type);
|
|
Assert.True(loadedSource.IsMediaSource);
|
|
Assert.Equal(@"C:\videos\starting.mp4", loadedSource.MediaPath);
|
|
Assert.True(loadedSource.MediaIsLooping);
|
|
Assert.Equal(0.75, loadedSource.MediaVolume);
|
|
Assert.Equal(MediaPlaybackState.Playing, loadedSource.MediaPlaybackState);
|
|
}
|
|
finally
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(path); } catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
|
|
// TASK: default record folder falls back to Downloads (0th case / no preference).
|
|
[Fact]
|
|
public void DefaultRecordFolder_Falls_Back_To_Downloads()
|
|
{
|
|
var folder = MainViewModel.DefaultRecordFolder();
|
|
var downloads = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
|
|
|
if (Directory.Exists(downloads))
|
|
Assert.Equal(downloads, folder);
|
|
else
|
|
Assert.Equal(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), folder);
|
|
}
|
|
}
|