Browse Source

Render a frame while the headless UI tests wait

Hit testing for synthesized input is answered from the rendered scene, not from
the visual tree, and Dispatcher.UIThread.RunJobs() does not render one. The
context-menu gesture helpers pumped dispatcher jobs alone, so after Escape closed
a menu the next right-click could still be routed to the light-dismiss overlay of
the frame that was on screen: no ContextRequested was raised, no row became the
context target, and the assertion two lines later reported an unhighlighted row.
The macOS CI runner lost that race roughly once in forty gestures; a probe build
repeating the gesture caught a right-click that produced neither a pointer-over
nor a menu, and the fix survived 120 gestures on the same runner.

Avalonia's own headless input helpers pump the dispatcher and the render timer
together for this reason.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
pull/4032/head
Christoph Wille 4 weeks ago
parent
commit
5689dabaa9
  1. 22
      ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs
  2. 19
      ILSpy.Tests/Waiters.cs

22
ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs

@ -250,6 +250,8 @@ public class DecompileInNewViewTests
.OfType<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem>() .OfType<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem>()
.First(r => RowNodeEquals(r, node)); .First(r => RowNodeEquals(r, node));
var menu = grid.ContextMenu!;
async Task RightClick(SharpTreeNode node) async Task RightClick(SharpTreeNode node)
{ {
var row = Row(node); var row = Row(node);
@ -257,21 +259,23 @@ public class DecompileInNewViewTests
var pt = row.TranslatePoint(new Point(clickX, row.Bounds.Height / 2), window); var pt = row.TranslatePoint(new Point(clickX, row.Bounds.Height / 2), window);
HeadlessWindowExtensions.MouseDown(window, pt!.Value, MouseButton.Right); HeadlessWindowExtensions.MouseDown(window, pt!.Value, MouseButton.Right);
HeadlessWindowExtensions.MouseUp(window, pt.Value, MouseButton.Right); HeadlessWindowExtensions.MouseUp(window, pt.Value, MouseButton.Right);
for (int i = 0; i < 4; i++) // 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
Dispatcher.UIThread.RunJobs(); // the row's classes are worth reading.
await Task.Delay(20); await Waiters.WaitForAsync(() => menu.IsOpen, description: "the right-clicked row's context menu to open");
}
} }
async Task Dismiss() async Task Dismiss()
{ {
window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, keySymbol: null); 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++) for (int i = 0; i < 4; i++)
{ Waiters.PumpUI();
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
} }
await RightClick(nodeB); await RightClick(nodeB);

19
ILSpy.Tests/Waiters.cs

@ -22,6 +22,7 @@ using System.Runtime.CompilerServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Headless;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
@ -51,13 +52,29 @@ public static class Waiters
{ {
if (predicate()) if (predicate())
return; return;
Dispatcher.UIThread.RunJobs(); PumpUI();
await Task.Delay(PollInterval); await Task.Delay(PollInterval);
} }
throw new TimeoutException( throw new TimeoutException(
$"Timed out after {(timeout ?? DefaultTimeout).TotalSeconds:0.#}s waiting for: {description}"); $"Timed out after {(timeout ?? DefaultTimeout).TotalSeconds:0.#}s waiting for: {description}");
} }
/// <summary>
/// Advances the UI by one step: runs the queued dispatcher jobs and renders a frame.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static void PumpUI()
{
Dispatcher.UIThread.RunJobs();
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
}
public static async Task WaitForAssembliesAsync( public static async Task WaitForAssembliesAsync(
this AssemblyTreeModel atm, this AssemblyTreeModel atm,
int minimumCount = 1, int minimumCount = 1,

Loading…
Cancel
Save