test: prove layer drag-reorder persists via REAL injected mouse input

Creator reported the layer reorder still "doesn't save" after the 08:18
fix (c117885). The existing Good Dog test short-circuited EndListDrag — it
called OnSceneElementsReordered() directly, so the actual
List_PreviewMouseLeftButtonDown/Move/Up handlers were never exercised
(synthetic RaiseEvent can't: e.GetPosition reads the physical cursor).

New RealMouseDrag_OnTheLayerList_PersistsTheReorder drives SetCursorPos +
mouse_event (Win32 input injection, the same technique UI-automation
tooling uses) so the REAL handlers run: click ImgC's row, drag it onto
ImgA's row on screen, release, then pump until the debounced save writes
the DB. Green: DB ends at [Background, ImgC, ImgA, ImgB] — the seam is
proven end-to-end. If the creator still sees a revert, the run binary was
stale (Debug exe 08:42 postdates the fix commit 08:18) or another deploy.

Reference: Win32 SendInput/mouse_event input injection for WPF e2e
(mouse_event docs / UI-automation tooling pattern).
This commit is contained in:
2026-09-18 09:07:49 -07:00
parent 0d0e55de31
commit 2ac00db0da
2 changed files with 126 additions and 1 deletions
@@ -2,9 +2,14 @@ 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;
@@ -31,6 +36,113 @@ public sealed class LayerReorderPersistenceTests
_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 debounced background save");
Assert.Equal(expected, SourceNamesInDb(tempDb));
}
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");