using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Threading; using Microsoft.Data.Sqlite; using Xunit; using ytLive.Helpers; using ytLive.Models; using ytLive.Services; using ytLive.ViewModels; namespace ytLive.Tests; /// /// Layer-list drag-to-reorder: the code-behind reorders StagedScene.Elements /// directly (RemoveAt/Insert), bypassing SceneGraph's mutation surface, so the /// drop must be trapped as a data change and persisted. Real-App + temp-DB /// pattern (see WebcamOutputKeyTests); reuses the exact code-behind mutation and /// asserts the new order AND each layer's geometry (preview layout) are written /// to the DB — immediately at drop, no debounce window. /// [Collection("RealApp")] public sealed class LayerReorderPersistenceTests { private readonly RealAppHost _app; public LayerReorderPersistenceTests(RealAppHost app) => _app = app; [Fact] public void DragReorder_SurvivesTheDebouncedBackgroundSave() { _app.Run(Run); } /// /// The user's reproduced bug: dragging a layer BELOW the webcam and relaunching /// reverted the order. Root cause: Sources and WebcamSceneConfigs each carried /// their OWN per-type SortOrder and load appended all Sources before all /// configs — a webcam slotted between sources was structurally unrecoverable. /// Save now stamps BOTH tables from one shared z-space (the element's index /// within scene.Elements); load merges the two lists on that shared z. This /// test seeds the real Live layout (sources + HD Pro Webcam C920), physically /// reorders "Web Resource-0" down below the webcam, then fresh-loads a NEW /// LayoutStore from the same DB — the restart — and asserts the order holds. /// [Fact] public void CrossTypeReorder_BelowTheWebcam_SurvivesFreshReload() { _app.Run(RunCrossTypeReload); } /// /// Same use case as above but driven with REAL injected mouse input /// (SetCursorPos + mouse_event against the desktop) so the actual /// List_PreviewMouseLeftButtonDown/Move/Up handlers — the seam no synthetic /// event can exercise, since e.GetPosition reads the physical cursor — must /// run. Requires an interactive desktop session: if the window is covered or /// the session is locked, the pointer no-ops and the drag never lands. /// [Fact] public void RealMouseDrag_OnTheLayerList_PersistsTheReorder() { _app.Run(RunRealDrag); } private void RunRealDrag() { var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-realdrag-{Guid.NewGuid():N}.db"); MainViewModel.LayoutPathOverride = tempDb; var window = default(MainWindow); try { using (var schema = new LayoutStore(tempDb)) { } SqliteConnection.ClearAllPools(); SeedThreeLayers(tempDb); window = new MainWindow(); var vm = (MainViewModel)window.DataContext; vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live)); Assert.True(vm.StagedScene!.Elements.OfType().Count() >= 4, "3 seeded layers + the healed Background must load"); window.Show(); window.Activate(); window.Topmost = true; window.UpdateLayout(); var panel = (UserControl)window.FindName("LeftPanel")!; var list = (FocusPreservingListBox)panel.FindName("SourceList")!; list.UpdateLayout(); // Elements = [Background, ImgA, ImgB, ImgC]. Physically drag ImgC // (row 3) up onto ImgA (row 1): the handlers reorder live elements to // [Background, ImgC, ImgA, ImgB] and EndListDrag must persist that. var from = RowScreenPoint(list, 3); var to = RowScreenPoint(list, 1); SetCursorPos((int)from.X, (int)from.Y); LetInputSettle(80); mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, UIntPtr.Zero); LetInputSettle(120); SetCursorPos((int)to.X, (int)to.Y); LetInputSettle(250); mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, UIntPtr.Zero); LetInputSettle(400); var expected = vm.StagedScene.Elements.OfType().Select(s => s.Name).ToArray(); Assert.Equal("Background", expected[0]); Assert.Equal("ImgC", expected[1]); Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected), TimeSpan.FromSeconds(10)), "a real mouse drag-and-drop must be persisted by the background save"); Assert.Equal(expected, SourceNamesInDb(tempDb)); // The same drop must persist the PREVIEW LAYOUT too — each layer's // geometry (X/Y/W/H) rides in the same Source rows as the SortOrder. var dragged = vm.StagedScene.Elements.OfType().Single(s => s.Name == "ImgC"); Assert.Equal((dragged.X, dragged.Y, dragged.Width, dragged.Height), GeometryInDb(tempDb, "ImgC")); } finally { if (window != null) window.Close(); MainViewModel.LayoutPathOverride = null; SqliteConnection.ClearAllPools(); try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } } /// Screen (device-pixel) point one-third into the list row — over the /// name TextBlock, away from the per-row buttons/rename TextBox. private static Point RowScreenPoint(FocusPreservingListBox list, int index) { var item = (ListBoxItem)list.ItemContainerGenerator.ContainerFromIndex(index)!; var inList = item.TransformToVisual(list) .Transform(new Point(Math.Min(30, item.ActualWidth - 12), item.ActualHeight / 2)); return list.PointToScreen(inList); } /// Pumps the app dispatcher while sleeping in small steps, so real /// input messages posted to the thread queue are processed mid-gesture. private static void LetInputSettle(int milliseconds) { var frame = new DispatcherFrame(); var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(milliseconds) }; timer.Tick += (_, _) => { timer.Stop(); frame.Continue = false; }; timer.Start(); Dispatcher.PushFrame(frame); } private const uint MOUSEEVENTF_LEFTDOWN = 0x0002; private const uint MOUSEEVENTF_LEFTUP = 0x0004; [DllImport("user32.dll")] private static extern bool SetCursorPos(int x, int y); [DllImport("user32.dll")] private static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo); private void Run() { var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-reorder-{Guid.NewGuid():N}.db"); MainViewModel.LayoutPathOverride = tempDb; var window = default(MainWindow); try { using (var schema = new LayoutStore(tempDb)) { } SqliteConnection.ClearAllPools(); SeedThreeLayers(tempDb); window = new MainWindow(); var vm = (MainViewModel)window.DataContext; vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live)); Assert.True(vm.StagedScene!.Elements.OfType().Count() >= 4, "3 seeded layers + the healed Background must load"); // Mirrors Controls/LeftPanel.List_PreviewMouseMove: drag the last layer // to the front of the user layers (the healed Background stays pinned // at index 0, so index 1 is the first user layer). var elements = vm.StagedScene.Elements; var dragged = elements[^1]; elements.RemoveAt(elements.Count - 1); elements.Insert(1, dragged); var expected = vm.StagedScene.Elements.OfType().Select(s => s.Name).ToArray(); vm.OnSceneElementsReordered(); Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected), TimeSpan.FromSeconds(10)), "the drag reorder must be persisted at drop (immediate save)"); Assert.Equal(expected, SourceNamesInDb(tempDb)); // The drop must persist the dragged layer's PREVIEW LAYOUT too — its // X/Y/W/H ride in the same Source row, so one immediate save covers // both the new layer list and the preview geometry. var draggedSource = vm.StagedScene.Elements.OfType().Single(s => s.Name == dragged.Name); Assert.Equal((draggedSource.X, draggedSource.Y, draggedSource.Width, draggedSource.Height), GeometryInDb(tempDb, dragged.Name)); } finally { if (window != null) window.Close(); MainViewModel.LayoutPathOverride = null; SqliteConnection.ClearAllPools(); try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } } private void RunCrossTypeReload() { var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-crosstype-{Guid.NewGuid():N}.db"); MainViewModel.LayoutPathOverride = tempDb; var window = default(MainWindow); try { using (var schema = new LayoutStore(tempDb)) { } SqliteConnection.ClearAllPools(); SeedLiveSceneWithWebcam(tempDb); window = new MainWindow(); var vm = (MainViewModel)window.DataContext; vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live)); // The saved architecture (live scene = Background + YouTube Chat + // Image + Web Resource-0 sources, plus the HD Pro Webcam C920 config // row) plus the healed background. Assert.True(vm.StagedScene!.Elements.OfType().Count() >= 4, "seeded sources + healed Background must load"); // Mirror the user's repro: drag "Web Resource-0" DOWN below the webcam. var elements = vm.StagedScene.Elements; var webcam = elements.OfType().Single(); var dragged = elements.OfType().Single(s => s.Name == "Web Resource-0"); var from = elements.IndexOf(dragged); elements.RemoveAt(from); elements.Insert(elements.IndexOf(webcam) + 1, dragged); var expected = elements.Select(e => e.Name).ToArray(); vm.OnSceneElementsReordered(); // Restart: a brand-new LayoutStore reading the same DB must restore // the cross-type order exactly — webcam between Image and Web // Resource-0, not appended after all sources. Assert.True(PumpUntil(() => TryReadSceneOrder(tempDb, out var names) && names.SequenceEqual(expected), TimeSpan.FromSeconds(10)), "the webcam-between-sources order must survive a fresh reload"); } finally { if (window != null) window.Close(); MainViewModel.LayoutPathOverride = null; SqliteConnection.ClearAllPools(); try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } } /// Reads one scene's element order from a FRESH store, both tables /// merged on the shared z. False on lock/timeout (save mid-transaction). private static bool TryReadSceneOrder(string path, out string[] names) { try { using var store = new LayoutStore(path); var scene = store.Load().Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live)); names = scene.Elements.Select(e => e.Name).ToArray(); return true; } catch (SqliteException) { names = []; return false; } } /// Seeds the user's real Live layout: Background, YouTube Chat, /// Image, Web Resource-0 Source rows + a Webcam row and its config. private static void SeedLiveSceneWithWebcam(string path) { var scenes = new (string Id, string Name)[] { ("scene-starting", "Starting"), ("scene-live", "Live"), ("scene-brb", "BRB"), ("scene-chat", "Chat"), ("scene-ending", "Ending"), }; var sources = new (string Id, string Type, string Name, int Sort)[] { ("bg-live", "DisplayCapture", "Background", 0), ("chat-live", "ChatBox", "YouTube Chat", 1), ("img-live", "Image", "Image", 2), ("web-live", "WebSource", "Web Resource-0", 3), }; using var connection = new SqliteConnection($"Data Source={path}"); connection.Open(); 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(); } using (var source = connection.CreateCommand()) { source.CommandText = @"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, Width, Height, IsBackground, SortOrder) VALUES ($id, 'scene-live', $type, $name, 1, 640, 480, $bg, $sort);"; var idP = source.Parameters.Add("$id", SqliteType.Text); var typeP = source.Parameters.Add("$type", SqliteType.Text); var nameP = source.Parameters.Add("$name", SqliteType.Text); var bgP = source.Parameters.Add("$bg", SqliteType.Integer); var sortP = source.Parameters.Add("$sort", SqliteType.Integer); foreach (var (id, type, name, sort) in sources) { idP.Value = id; typeP.Value = type; nameP.Value = name; bgP.Value = name == "Background" ? 1 : 0; sortP.Value = sort; source.ExecuteNonQuery(); } } using (var webcam = connection.CreateCommand()) { webcam.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ('wid-live', 'dev-physical-9', 'HD Pro Webcam C920');"; webcam.ExecuteNonQuery(); } using (var config = connection.CreateCommand()) { config.CommandText = @"INSERT INTO WebcamSceneConfig (SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity, SortOrder) VALUES ('scene-live', 'wid-live', 1, 100, 100, 480, 270, 1, 0);"; config.ExecuteNonQuery(); } } /// Pumps the app dispatcher (so the VM's debounce timer fires) until /// or the deadline passes. True when the condition /// became true, false on deadline. private static bool PumpUntil(Func isDone, TimeSpan timeout) { var frame = new DispatcherFrame(); var poll = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) }; var deadline = DateTime.UtcNow + timeout; poll.Tick += (_, _) => { if (isDone() || DateTime.UtcNow >= deadline) { poll.Stop(); frame.Continue = false; } }; poll.Start(); Dispatcher.PushFrame(frame); return isDone(); } private static string[] SourceNamesInDb(string path) { try { using var connection = new SqliteConnection($"Data Source={path}"); connection.Open(); using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT Name FROM Source WHERE SceneId = 'scene-live' ORDER BY SortOrder;"; using var reader = cmd.ExecuteReader(); var names = new List(); while (reader.Read()) names.Add(reader.GetString(0)); return names.ToArray(); } catch (SqliteException) { // The save may hold the write lock mid-transaction — not yet. return []; } } private static (double X, double Y, double Width, double Height) GeometryInDb(string path, string name) { try { using var connection = new SqliteConnection($"Data Source={path}"); connection.Open(); using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT X, Y, Width, Height FROM Source WHERE SceneId = 'scene-live' AND Name = $name;"; cmd.Parameters.AddWithValue("$name", name); using var reader = cmd.ExecuteReader(); reader.Read(); return (reader.GetDouble(0), reader.GetDouble(1), reader.GetDouble(2), reader.GetDouble(3)); } catch (SqliteException) { // The save may hold the write lock mid-transaction — not yet. return (double.NaN, double.NaN, double.NaN, double.NaN); } } private static void SeedThreeLayers(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(); } var layers = new[] { "ImgA", "ImgB", "ImgC" }; using (var source = connection.CreateCommand()) { source.CommandText = @"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, X, Y, Width, Height, IsBackground, SortOrder) VALUES ($id, 'scene-live', 'Image', $name, 1, 0, 0, 640, 480, 0, $sort);"; var idP = source.Parameters.Add("$id", SqliteType.Text); var nameP = source.Parameters.Add("$name", SqliteType.Text); var sortP = source.Parameters.Add("$sort", SqliteType.Integer); for (var i = 0; i < layers.Length; i++) { idP.Value = $"img-{i}"; nameP.Value = layers[i]; sortP.Value = i; source.ExecuteNonQuery(); } } } }