From 2ac00db0dafab807d9a25e8934d9141c5dcc2e4c Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 18 Sep 2026 09:07:49 -0700 Subject: [PATCH] test: prove layer drag-reorder persists via REAL injected mouse input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- HANDOFF.md | 15 ++- ytLive.Tests/LayerReorderPersistenceTests.cs | 112 +++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/HANDOFF.md b/HANDOFF.md index c335197..b983dad 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 `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. +**Real-input drag verification test added on top** (unpushed, see below). **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, calls the trap, pumps the dispatcher until the debounced save lands, asserts the DB `Source` 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) @@ -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 `Background` element pinned at `Elements[0]` (created by `EnsureBackground` on `StagedScene` set) — 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. ## Next step diff --git a/ytLive.Tests/LayerReorderPersistenceTests.cs b/ytLive.Tests/LayerReorderPersistenceTests.cs index 0e495c7..31fbe8e 100644 --- a/ytLive.Tests/LayerReorderPersistenceTests.cs +++ b/ytLive.Tests/LayerReorderPersistenceTests.cs @@ -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); } + /// + /// 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 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 */ } + } + } + + /// 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");