diff --git a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs index 0607a7527c..d1ca446d8f 100644 --- a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs +++ b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs @@ -250,6 +250,8 @@ ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem Row(SharpTreeNode node) => .OfType() .First(r => RowNodeEquals(r, node)); + var menu = grid.ContextMenu!; + async Task RightClick(SharpTreeNode node) { var row = Row(node); @@ -257,21 +259,23 @@ async Task RightClick(SharpTreeNode node) var pt = row.TranslatePoint(new Point(clickX, row.Bounds.Height / 2), window); HeadlessWindowExtensions.MouseDown(window, pt!.Value, MouseButton.Right); HeadlessWindowExtensions.MouseUp(window, pt.Value, MouseButton.Right); - for (int i = 0; i < 4; i++) - { - Dispatcher.UIThread.RunJobs(); - await Task.Delay(20); - } + // The highlight is scoped to the popup - set while the menu is being requested, dropped + // again when it closes - so the popup is the point at which the gesture is finished and + // the row's classes are worth reading. + await Waiters.WaitForAsync(() => menu.IsOpen, description: "the right-clicked row's context menu to open"); } async Task Dismiss() { window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, keySymbol: null); + await Waiters.WaitForAsync(() => !menu.IsOpen, description: "the context menu to close"); + // IsOpen flips the moment the popup is torn down, but the frame that still shows its + // light-dismiss overlay is what answers hit tests until the scene is rendered again. + // Right-clicking into that frame delivers press and release to the vanishing overlay + // instead of the row: no ContextRequested is raised at all, so nothing becomes the + // context target and the next assertion sees a row with no classes on it. for (int i = 0; i < 4; i++) - { - Dispatcher.UIThread.RunJobs(); - await Task.Delay(20); - } + Waiters.PumpUI(); } await RightClick(nodeB); diff --git a/ILSpy.Tests/Waiters.cs b/ILSpy.Tests/Waiters.cs index aaa63b1b5f..7b4393b000 100644 --- a/ILSpy.Tests/Waiters.cs +++ b/ILSpy.Tests/Waiters.cs @@ -22,6 +22,7 @@ using System.Threading.Tasks; using Avalonia; +using Avalonia.Headless; using Avalonia.Threading; using Avalonia.VisualTree; @@ -51,13 +52,29 @@ public static async Task WaitForAsync( { if (predicate()) return; - Dispatcher.UIThread.RunJobs(); + PumpUI(); await Task.Delay(PollInterval); } throw new TimeoutException( $"Timed out after {(timeout ?? DefaultTimeout).TotalSeconds:0.#}s waiting for: {description}"); } + /// + /// Advances the UI by one step: runs the queued dispatcher jobs and renders a frame. + /// + /// + /// Both halves matter. Hit testing for synthesized input is answered from the rendered scene, + /// not from the visual tree, so a loop that only runs dispatcher jobs leaves input routing a + /// frame behind: a click can still be delivered to a control the last frame shows but the tree + /// no longer has - a closed popup's light-dismiss overlay, for one - and never reach what is + /// underneath. Avalonia's own headless input helpers pump both for exactly this reason. + /// + public static void PumpUI() + { + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + } + public static async Task WaitForAssembliesAsync( this AssemblyTreeModel atm, int minimumCount = 1,