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:
+14
-1
@@ -1,9 +1,10 @@
|
|||||||
# HANDOFF — 2026-09-18 (drag-reorder persistence + recording-dialog height, both local — no push)
|
# HANDOFF — 2026-09-18 (drag-reorder persistence proven via real mouse input + recording-dialog height, both local — no push)
|
||||||
|
|
||||||
## Branch / Commit State
|
## Branch / Commit State
|
||||||
|
|
||||||
`main` HEAD = **Rename Recording dialog 20% taller + teardown unhooks the full-screen hook** (local).
|
`main` HEAD = **Rename Recording dialog 20% taller + teardown unhooks the full-screen hook** (local).
|
||||||
Below it: the layer drag-reorder persistence fix (`LayerReorderPersistenceTests`), then webcam work.
|
Below it: the layer drag-reorder persistence fix (`LayerReorderPersistenceTests`), then webcam work.
|
||||||
|
**Real-input drag verification test added on top** (unpushed, see below).
|
||||||
|
|
||||||
**No push yet.** Working tree clean.
|
**No push yet.** Working tree clean.
|
||||||
|
|
||||||
@@ -63,6 +64,15 @@ ScheduleSave()`, i.e. the same background-save path every other mutation uses. *
|
|||||||
`LayerReorderPersistenceTests` (real App + temp DB): reproduces the exact code-behind mutation,
|
`LayerReorderPersistenceTests` (real App + temp DB): reproduces the exact code-behind mutation,
|
||||||
calls the trap, pumps the dispatcher until the debounced save lands, asserts the DB `Source`
|
calls the trap, pumps the dispatcher until the debounced save lands, asserts the DB `Source`
|
||||||
SortOrder matches the in-memory `Elements` order.
|
SortOrder matches the in-memory `Elements` order.
|
||||||
|
**Creator later insisted the reorder "doesn't save" even after the build — settled 2026-09-18 with a
|
||||||
|
REAL-gesture test.** My first test short-circuited `EndListDrag` — it never exercised the mouse
|
||||||
|
handlers (impossible with synthetic events: `e.GetPosition` reads the physical cursor). Added
|
||||||
|
`RealMouseDrag_OnTheLayerList_PersistsTheReorder`: real `SetCursorPos` + `mouse_event`/`SendInput`
|
||||||
|
against the shown MainWindow physically drag row 3 (ImgC) onto row 1 (ImgA), pumping between steps,
|
||||||
|
then asserts the DB lands at `[Background, ImgC, ImgA, ImgB]` via the debounced save. **Both pass.**
|
||||||
|
The mouse-down/move/up → `_dragReordered` → `EndListDrag` → `OnSceneElementsReordered` → DB seam is
|
||||||
|
now proven with real input; a real user drag working differently would mean a stale binary (the
|
||||||
|
Debug exe from 08:42 postdates fix commit 08:18) or a different deployment.
|
||||||
|
|
||||||
## ✅ Committed — Rename Recording dialog taller + full-screen hook unhooked on shutdown (2026-09-18)
|
## ✅ Committed — Rename Recording dialog taller + full-screen hook unhooked on shutdown (2026-09-18)
|
||||||
|
|
||||||
@@ -158,6 +168,9 @@ claim — `CameraConflictProbe` reads process names only, no device handles; do
|
|||||||
- **Scene.Elements layer tests:** any seeded scene with `HasBackground=1` loads a healed
|
- **Scene.Elements layer tests:** any seeded scene with `HasBackground=1` loads a healed
|
||||||
`Background` element pinned at `Elements[0]` (created by `EnsureBackground` on `StagedScene` set) —
|
`Background` element pinned at `Elements[0]` (created by `EnsureBackground` on `StagedScene` set) —
|
||||||
compute expected layer orders from the live collection, never hardcode indices.
|
compute expected layer orders from the live collection, never hardcode indices.
|
||||||
|
- **Real-input injection test** (`RealMouseDrag_OnTheLayerList_PersistsTheReorder`) needs an
|
||||||
|
interactive desktop session: the window is shown Topmost and clicks are injected at real screen
|
||||||
|
coords — a covered/locked session makes the pointer no-op and the drag never lands. Fine locally.
|
||||||
- `C:\tmpout` is for ffmpeg evidence artifacts; keep them out of the repo.
|
- `C:\tmpout` is for ffmpeg evidence artifacts; keep them out of the repo.
|
||||||
|
|
||||||
## Next step
|
## Next step
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Media;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ytLive.Helpers;
|
||||||
using ytLive.Models;
|
using ytLive.Models;
|
||||||
using ytLive.Services;
|
using ytLive.Services;
|
||||||
using ytLive.ViewModels;
|
using ytLive.ViewModels;
|
||||||
@@ -31,6 +36,113 @@ public sealed class LayerReorderPersistenceTests
|
|||||||
_app.Run(Run);
|
_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()
|
private void Run()
|
||||||
{
|
{
|
||||||
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-reorder-{Guid.NewGuid():N}.db");
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-reorder-{Guid.NewGuid():N}.db");
|
||||||
|
|||||||
Reference in New Issue
Block a user