Files
LlamaCasty/ytLive.Tests/LayerReorderPersistenceTests.cs
T
gramps f91bf8715d fix(reorder): save layer list AND preview layout immediately at drop; recording save dialog +10% (276→304)
Reorder: OnSceneElementsReordered now calls SaveLayoutNow() (was debounced
ScheduleSave) so a drag-drop persists instantly — one write stores the new
layer list (Source.SortOrder) and each layer's preview geometry (X/Y/W/H) in
the same rows. Reference: WPF ListBox drag-reorder requires an explicit persist
at drop; a debounce window lets a crash lose the drop.

Recording save dialog (RenameRecordingDialog.xaml): height 276→304 so the
Cancel/Save button row is no longer obscured; Save button named
SaveRecordingButton so the sizing test can assert it sits inside the client area.

Tests: deterministic seam test now asserts the dragged layer's geometry (preview
layout) is persisted with the new order; sizing test asserts 304 + textbox and
button-row bottoms within client area. 308/308 green.
2026-09-18 10:20:25 -07:00

304 lines
12 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
[Collection("RealApp")]
public sealed class LayerReorderPersistenceTests
{
private readonly RealAppHost _app;
public LayerReorderPersistenceTests(RealAppHost app) => _app = app;
[Fact]
public void DragReorder_SurvivesTheDebouncedBackgroundSave()
{
_app.Run(Run);
}
/// <summary>
/// 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.
/// </summary>
[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<Source>().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<Source>().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<Source>().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 */ }
}
}
/// <summary>Screen (device-pixel) point one-third into the list row — over the
/// name TextBlock, away from the per-row buttons/rename TextBox.</summary>
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);
}
/// <summary>Pumps the app dispatcher while sleeping in small steps, so real
/// input messages posted to the thread queue are processed mid-gesture.</summary>
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<Source>().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<Source>().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<Source>().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 */ }
}
}
/// <summary>Pumps the app dispatcher (so the VM's debounce timer fires) until
/// <paramref name="isDone"/> or the deadline passes. True when the condition
/// became true, false on deadline.</summary>
private static bool PumpUntil(Func<bool> 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<string>();
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();
}
}
}
}