Browse Source

Synchronize headless UI tests on idle and hit-tested clicks

The headless UI tests synchronized with the application by pumping a fixed
number of frames (39 loops of RunJobs/Delay across 19 files) and by pressing
at a point computed once from a control's bounds. Both encode how fast the
machine that wrote the test was: on the loaded Windows Debug CI agent the
frame count comes up short and the point goes stale, which is the recurring
timeout in the tree context-menu tests and the reason each such failure was
repaired one test at a time.

Waiters.WaitForIdleAsync replaces the frame loops. It observes the actual
precondition - no dispatcher job queued at Background priority or above, no
assembly still loading in the background sweep, a frame rendered - and
requires it on two consecutive polls so a thread-pool continuation about to
post back is caught as well.

Window.ClickAsync replaces element-targeted MouseDown/MouseUp pairs. It
re-resolves the target on every poll and presses only once the window's hit
test at the click point answers with that target, reporting the point and
what was hit instead on timeout. That diagnostic exposed one vacuous test:
User_Click_On_Visible_Row_Does_Not_Recentre_Viewport clicked the centre of a
row wider than the tree viewport, which lies under the decompiler text view,
so its assertion held without the row ever being clicked. It now clamps the
point to the viewport like the other tree-row clicks.

Clicks at text positions and press-only gutter clicks stay raw; they do not
target an element.

Assisted-by: Claude:claude-fable-5:Claude Code
pull/4083/head
Siegfried Pammer 2 weeks ago
parent
commit
1b78c76215
  1. 7
      ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs
  2. 5
      ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs
  3. 63
      ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs
  4. 12
      ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs
  5. 24
      ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs
  6. 12
      ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs
  7. 125
      ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs
  8. 19
      ILSpy.Tests/ContextMenus/KeyboardContextMenuFocusTests.cs
  9. 6
      ILSpy.Tests/ContextMenus/ReferenceScopeAndNewTabTests.cs
  10. 12
      ILSpy.Tests/Controls/OmnibarSettingTests.cs
  11. 20
      ILSpy.Tests/Docking/DocumentTabStripModeTests.cs
  12. 7
      ILSpy.Tests/Docking/MultiRowTabStripTests.cs
  13. 6
      ILSpy.Tests/Docking/RunInNewTabTests.cs
  14. 6
      ILSpy.Tests/Editor/DecompilerViewTests.cs
  15. 5
      ILSpy.Tests/Editor/DocumentationLinkTests.cs
  16. 12
      ILSpy.Tests/Editor/FoldingContextMenuTests.cs
  17. 7
      ILSpy.Tests/Metadata/MetadataFilterRowEndToEndTests.cs
  18. 3
      ILSpy.Tests/Options/OptionsTabTests.cs
  19. 12
      ILSpy.Tests/Search/SearchPaneNicetiesTests.cs
  20. 37
      ILSpy.Tests/Waiters.cs
  21. 53
      ILSpy.Tests/WindowExtensions.cs

7
ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs

@ -154,12 +154,7 @@ public class AnalyzerTreeKeyboardTests @@ -154,12 +154,7 @@ public class AnalyzerTreeKeyboardTests
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeNode);
for (int i = 0; i < 6; i++)
{
Dispatcher.UIThread.RunJobs();
tree.UpdateLayout();
await Task.Delay(20);
}
await Waiters.WaitForIdleAsync();
tree.Focus();
Dispatcher.UIThread.RunJobs();

5
ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs

@ -81,10 +81,7 @@ public class AssemblyTreeExpanderHitboxTests @@ -81,10 +81,7 @@ public class AssemblyTreeExpanderHitboxTests
// outside the centred glyph at ~y=3.5..12.5) collapses the node. This proves the grown
// area is genuinely hittable, not just larger in layout.
assemblyNode.IsExpanded.Should().BeTrue("precondition: node is expanded before the click");
var hitPoint = expander.TranslatePoint(new Point(expander.Bounds.Width / 2, 14), window);
hitPoint.Should().NotBeNull();
HeadlessWindowExtensions.MouseDown(window, hitPoint!.Value, MouseButton.Left);
HeadlessWindowExtensions.MouseUp(window, hitPoint.Value, MouseButton.Left);
await window.ClickAsync(() => expander, pointInTarget: e => new Point(e.Bounds.Width / 2, 14));
TestCapture.Step("clicked-enlarged-expander-area");
await Waiters.WaitForAsync(() => !assemblyNode.IsExpanded,

63
ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs

@ -634,11 +634,7 @@ public class AssemblyTreeTests @@ -634,11 +634,7 @@ public class AssemblyTreeTests
vm.AssemblyTreeModel.SelectNode(enumerable);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, enumerable));
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
grid.UpdateLayout();
var scrollViewer = await grid.WaitForComponent<ScrollViewer>();
@ -665,19 +661,13 @@ public class AssemblyTreeTests @@ -665,19 +661,13 @@ public class AssemblyTreeTests
var offsetBefore = scrollViewer.Offset.Y;
// Act — real pointer click. Setting SelectedItem programmatically would fire DataGrid's
// internal ScrollIntoView too, which a real user click does not.
var rowCentre = candidateRow!.TranslatePoint(
new Point(candidateRow.Bounds.Width / 2, candidateRow.Bounds.Height / 2),
window)!.Value;
global::Avalonia.Headless.HeadlessWindowExtensions.MouseDown(window, rowCentre, global::Avalonia.Input.MouseButton.Left);
global::Avalonia.Headless.HeadlessWindowExtensions.MouseUp(window, rowCentre, global::Avalonia.Input.MouseButton.Left);
// internal ScrollIntoView too, which a real user click does not. Tree rows stretch to
// content width, so clamp X to the visible grid viewport.
await window.ClickAsync(() => candidateRow,
pointInTarget: r => new Point(System.Math.Min(r.Bounds.Width, grid.Bounds.Width) / 2, r.Bounds.Height / 2));
TestCapture.Step("visible-row-clicked");
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
// Assert — viewport offset is unchanged (within 1px tolerance for layout jitter).
scrollViewer.Offset.Y.Should().BeApproximately(offsetBefore, 1.0,
@ -711,11 +701,7 @@ public class AssemblyTreeTests @@ -711,11 +701,7 @@ public class AssemblyTreeTests
vm.AssemblyTreeModel.SelectNode(enumerable);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, enumerable));
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
grid.UpdateLayout();
var scrollViewer = await grid.WaitForComponent<ScrollViewer>();
@ -742,11 +728,7 @@ public class AssemblyTreeTests @@ -742,11 +728,7 @@ public class AssemblyTreeTests
// Act — model-driven selection (the open-in-new-tab path), NOT a mouse click.
vm.AssemblyTreeModel.SelectNode(candidateNode);
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
scrollViewer.Offset.Y.Should().BeApproximately(offsetBefore, 1.0,
"selecting an already-visible row via the model (e.g. Decompile to new tab) must not move the viewport");
@ -780,11 +762,7 @@ public class AssemblyTreeTests @@ -780,11 +762,7 @@ public class AssemblyTreeTests
// Select + reveal the type, then let it settle on screen.
vm.AssemblyTreeModel.SelectNode(enumerable);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, enumerable));
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
grid.UpdateLayout();
(scrollViewer.Extent.Height - scrollViewer.Viewport.Height).Should().BeGreaterThan(50,
@ -795,11 +773,7 @@ public class AssemblyTreeTests @@ -795,11 +773,7 @@ public class AssemblyTreeTests
// rows above the selection and pushing it off-screen.
var coreLib = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(typeof(object).Assembly.GetName().Name!);
coreLib.IsExpanded = true;
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
grid.UpdateLayout();
// Assert: the app did not chase the selection. The expand reveals the opened node's children;
@ -1774,14 +1748,9 @@ public class AssemblyTreeTests @@ -1774,14 +1748,9 @@ public class AssemblyTreeTests
Assert.That(targetNode, Is.Not.Null, "the clicked row must wrap a tree node");
// Tree rows stretch to content width (with horizontal scroll), so a row can be wider than
// the grid viewport. Click within the visible viewport, not at the (off-screen) row centre.
var clickX = System.Math.Min(targetRow.Bounds.Width, grid.Bounds.Width) / 2;
var rowCentre = targetRow.TranslatePoint(
new Point(clickX, targetRow.Bounds.Height / 2), window)!.Value;
HeadlessWindowExtensions.MouseDown(window, rowCentre, MouseButton.Left);
HeadlessWindowExtensions.MouseUp(window, rowCentre, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
await Task.Delay(50);
Dispatcher.UIThread.RunJobs();
await window.ClickAsync(() => targetRow,
pointInTarget: r => new Point(System.Math.Min(r.Bounds.Width, grid.Bounds.Width) / 2, r.Bounds.Height / 2));
await Waiters.WaitForIdleAsync();
TestCapture.Step("plain-click-collapsed-selection");
// Assert — selection collapsed to exactly the clicked row, in both grid and model.
@ -1822,11 +1791,7 @@ public class AssemblyTreeTests @@ -1822,11 +1791,7 @@ public class AssemblyTreeTests
// Act -- run Load Dependencies on the System.Net.Http node.
await vm.AssemblyTreeModel.LoadDependenciesAsync(new SharpTreeNode[] { httpNode });
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
// Assert -- references were resolved AND survive in the list as auto-loaded entries.
var added = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies()

12
ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs

@ -54,11 +54,7 @@ public class BookmarkContextMenuTests @@ -54,11 +54,7 @@ public class BookmarkContextMenuTests
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
var bookmarkableLines = Enumerable.Range(1, view.Editor.Document.LineCount)
.Where(view.CanToggleBookmarkAtLine)
@ -94,11 +90,7 @@ public class BookmarkContextMenuTests @@ -94,11 +90,7 @@ public class BookmarkContextMenuTests
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
int line = Enumerable.Range(1, view.Editor.Document.LineCount)
.First(view.CanToggleBookmarkAtLine);

24
ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs

@ -57,11 +57,7 @@ public class BookmarkGutterTests @@ -57,11 +57,7 @@ public class BookmarkGutterTests
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
window.UpdateLayout();
var margin = view.Editor.TextArea.LeftMargins.OfType<BookmarkMargin>().Single();
@ -107,11 +103,7 @@ public class BookmarkGutterTests @@ -107,11 +103,7 @@ public class BookmarkGutterTests
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
window.UpdateLayout();
var margin = view.Editor.TextArea.LeftMargins.OfType<BookmarkMargin>().Single();
@ -150,11 +142,7 @@ public class BookmarkGutterTests @@ -150,11 +142,7 @@ public class BookmarkGutterTests
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
window.UpdateLayout();
var margin = view.Editor.TextArea.LeftMargins.OfType<BookmarkMargin>().Single();
@ -231,11 +219,7 @@ public class BookmarkGutterTests @@ -231,11 +219,7 @@ public class BookmarkGutterTests
vm.AssemblyTreeModel.SelectNode(node);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var shown = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
return shown;
}

12
ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs

@ -59,11 +59,7 @@ public class BookmarkNavigationViewTests @@ -59,11 +59,7 @@ public class BookmarkNavigationViewTests
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
int bookmarkLine = Enumerable.Range(1, view.Editor.Document.LineCount)
.Where(view.CanToggleBookmarkAtLine)
@ -232,10 +228,6 @@ public class BookmarkNavigationViewTests @@ -232,10 +228,6 @@ public class BookmarkNavigationViewTests
static async Task PumpLayoutAsync()
{
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
}
}

125
ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs

@ -181,12 +181,7 @@ public class DecompileInNewViewTests @@ -181,12 +181,7 @@ public class DecompileInNewViewTests
vm.AssemblyTreeModel.SelectNode(nodeA);
// Let the top-level rows realise and layout settle.
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
grid.UpdateLayout();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
var rowB = grid.GetVisualDescendants().OfType<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem>()
.FirstOrDefault(r => RowNodeEquals(r, nodeB));
@ -200,16 +195,9 @@ public class DecompileInNewViewTests @@ -200,16 +195,9 @@ public class DecompileInNewViewTests
// Right-click the centre of B's row (clear of the far-left expander glyph). Tree rows
// stretch to content width, so clamp X to the visible grid viewport.
var clickX = System.Math.Min(rowB!.Bounds.Width, grid.Bounds.Width) / 2;
var point = rowB.TranslatePoint(new Point(clickX, rowB.Bounds.Height / 2), window);
point.Should().NotBeNull();
HeadlessWindowExtensions.MouseDown(window, point!.Value, MouseButton.Right);
HeadlessWindowExtensions.MouseUp(window, point.Value, MouseButton.Right);
for (int i = 0; i < 4; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
await window.ClickAsync(() => rowB, MouseButton.Right,
pointInTarget: r => new Point(System.Math.Min(r.Bounds.Width, grid.Bounds.Width) / 2, r.Bounds.Height / 2));
await Waiters.WaitForIdleAsync();
ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, nodeA).Should().BeTrue(
"right-clicking an unselected row must not change the selection (Thunderbird-style context target)");
@ -239,12 +227,7 @@ public class DecompileInNewViewTests @@ -239,12 +227,7 @@ public class DecompileInNewViewTests
var nodeC = assemblies[2];
vm.AssemblyTreeModel.SelectNode(nodeA);
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
grid.UpdateLayout();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem Row(SharpTreeNode node) => grid.GetVisualDescendants()
.OfType<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem>()
@ -252,43 +235,16 @@ public class DecompileInNewViewTests @@ -252,43 +235,16 @@ public class DecompileInNewViewTests
var menu = grid.ContextMenu!;
Point? ClickPoint(SharpTreeNode node)
{
var row = grid.GetVisualDescendants()
.OfType<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem>()
.FirstOrDefault(r => RowNodeEquals(r, node));
if (row == null)
return null;
var clickX = System.Math.Min(row.Bounds.Width, grid.Bounds.Width) / 2;
return row.TranslatePoint(new Point(clickX, row.Bounds.Height / 2), window);
}
async Task RightClick(SharpTreeNode node)
{
// A press only becomes a context request for the row when the hit test at the press
// point answers with that row - the same question OnTreeContextRequested asks - and it
// takes an unknown number of frames for that to hold: a closed popup's light-dismiss
// overlay keeps answering hit tests until the scene is rendered again, and assemblies
// still loading in the background reshuffle the rows, which re-realises containers and
// shifts them. So the row container and the point are resolved afresh on every poll and
// the hit is matched by node, not by container identity.
Point? pt = null;
Visual? lastHit = null;
try
{
await Waiters.WaitForAsync(
() => (pt = ClickPoint(node)) is { } p
&& (lastHit = window.InputHitTest(p) as Visual) is { } hit
&& hit.FindAncestorOfType<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem>(includeSelf: true) is { } hitRow
&& RowNodeEquals(hitRow, node),
description: "the row to answer hit tests at the point about to be right-clicked");
}
catch (System.TimeoutException ex)
{
throw new System.TimeoutException($"{ex.Message} (point: {pt?.ToString() ?? "row not realised"}, hit: {lastHit?.GetType().Name ?? "nothing"})", ex);
}
HeadlessWindowExtensions.MouseDown(window, pt!.Value, MouseButton.Right);
HeadlessWindowExtensions.MouseUp(window, pt.Value, MouseButton.Right);
// Tree rows stretch to content width, so clamp X to the visible grid viewport.
await window.ClickAsync(
() => grid.GetVisualDescendants()
.OfType<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem>()
.FirstOrDefault(r => RowNodeEquals(r, node)),
MouseButton.Right,
pointInTarget: row => new Point(System.Math.Min(row.Bounds.Width, grid.Bounds.Width) / 2, row.Bounds.Height / 2),
description: $"the row for {node}");
// 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.
@ -328,27 +284,15 @@ public class DecompileInNewViewTests @@ -328,27 +284,15 @@ public class DecompileInNewViewTests
var nodeA = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
var nodeB = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(TreeNavigation.CoreLibName);
vm.AssemblyTreeModel.SelectNode(nodeA);
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
grid.UpdateLayout();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
var rowB = grid.GetVisualDescendants().OfType<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem>()
.First(r => RowNodeEquals(r, nodeB));
int tabsBefore = vm.DockWorkspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count();
var clickX = System.Math.Min(rowB.Bounds.Width, grid.Bounds.Width) / 2;
var point = rowB.TranslatePoint(new Point(clickX, rowB.Bounds.Height / 2), window)!.Value;
HeadlessWindowExtensions.MouseDown(window, point, MouseButton.Middle);
HeadlessWindowExtensions.MouseUp(window, point, MouseButton.Middle);
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
grid.UpdateLayout();
await Task.Delay(20);
}
await window.ClickAsync(() => rowB, MouseButton.Middle,
pointInTarget: r => new Point(System.Math.Min(r.Bounds.Width, grid.Bounds.Width) / 2, r.Bounds.Height / 2));
await Waiters.WaitForIdleAsync();
vm.DockWorkspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count()
.Should().BeGreaterThan(tabsBefore, "middle-clicking a row must open it in a new document tab");
@ -376,23 +320,13 @@ public class DecompileInNewViewTests @@ -376,23 +320,13 @@ public class DecompileInNewViewTests
vm.AssemblyTreeModel.SelectNode(nodeA);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
for (int i = 0; i < 6; i++)
{
Dispatcher.UIThread.RunJobs();
grid.UpdateLayout();
await Task.Delay(20);
}
await Waiters.WaitForIdleAsync();
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
var menu = pane.BuildContextMenuForCurrentState(registry.Entries, rightClickedNode: nodeB);
menu!.ClickItem(Resources.DecompileToNewPanel);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
for (int i = 0; i < 6; i++)
{
Dispatcher.UIThread.RunJobs();
grid.UpdateLayout();
await Task.Delay(20);
}
await Waiters.WaitForIdleAsync();
// The model selection follows the new active tab...
ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, nodeB).Should().BeTrue(
@ -428,12 +362,7 @@ public class DecompileInNewViewTests @@ -428,12 +362,7 @@ public class DecompileInNewViewTests
vm.AssemblyTreeModel.SelectedItems.Add(nodeA);
vm.AssemblyTreeModel.SelectedItems.Add(nodeB);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
for (int i = 0; i < 6; i++)
{
Dispatcher.UIThread.RunJobs();
grid.UpdateLayout();
await Task.Delay(20);
}
await Waiters.WaitForIdleAsync();
vm.AssemblyTreeModel.SelectedItems.Count.Should().Be(2, "precondition: a multi-selection is held");
// Open C in a new tab -> activates it -> the tree must follow to C.
@ -441,12 +370,7 @@ public class DecompileInNewViewTests @@ -441,12 +370,7 @@ public class DecompileInNewViewTests
var menu = pane.BuildContextMenuForCurrentState(registry.Entries, rightClickedNode: nodeC);
menu!.ClickItem(Resources.DecompileToNewPanel);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
for (int i = 0; i < 6; i++)
{
Dispatcher.UIThread.RunJobs();
grid.UpdateLayout();
await Task.Delay(20);
}
await Waiters.WaitForIdleAsync();
ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, nodeC).Should().BeTrue(
"the tree model selection must follow the newly-activated single-node tab");
@ -494,12 +418,7 @@ public class DecompileInNewViewTests @@ -494,12 +418,7 @@ public class DecompileInNewViewTests
// 3) Re-activate the multi-node tab.
vm.DockWorkspace.Factory.SetActiveDockable(multiTab);
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
grid.UpdateLayout();
await Task.Delay(20);
}
await Waiters.WaitForIdleAsync();
// 4) The tree selection must contain BOTH original nodes again -- in the model...
vm.AssemblyTreeModel.SelectedItems.Should().Contain(nodeA)

19
ILSpy.Tests/ContextMenus/KeyboardContextMenuFocusTests.cs

@ -60,12 +60,7 @@ public class KeyboardContextMenuFocusTests @@ -60,12 +60,7 @@ public class KeyboardContextMenuFocusTests
var node = vm.AssemblyTreeModel.Root!.Children.OfType<AssemblyTreeNode>().First();
vm.AssemblyTreeModel.SelectNode(node);
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
grid.UpdateLayout();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
var row = grid.GetVisualDescendants()
.OfType<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem>().First();
@ -77,21 +72,13 @@ public class KeyboardContextMenuFocusTests @@ -77,21 +72,13 @@ public class KeyboardContextMenuFocusTests
// Keyboard invocation raises ContextRequested with no pointer position (the Shift+F10 / Apps path).
row.RaiseEvent(new ContextRequestedEventArgs());
for (int i = 0; i < 6; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
await Waiters.WaitForIdleAsync();
grid.ContextMenu!.IsOpen.Should().BeTrue("the keyboard gesture must open the tree context menu");
row.Classes.Should().Contain("contextTarget",
"a keyboard-invoked menu must show the transient target highlight on the selected row, like the mouse path");
window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, keySymbol: null);
for (int i = 0; i < 6; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
await Waiters.WaitForIdleAsync();
(focusManager.GetFocusedElement() == row).Should().BeTrue(
"closing a keyboard-invoked context menu must return focus to the row, not strand it");

6
ILSpy.Tests/ContextMenus/ReferenceScopeAndNewTabTests.cs

@ -64,11 +64,7 @@ public class ReferenceScopeAndNewTabTests @@ -64,11 +64,7 @@ public class ReferenceScopeAndNewTabTests
int before = vm.DockWorkspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count();
entry.Execute(RefContext(entity));
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
await Waiters.WaitForIdleAsync();
vm.DockWorkspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count()
.Should().BeGreaterThan(before, "Decompile to new tab on a code reference must open a new document tab");

12
ILSpy.Tests/Controls/OmnibarSettingTests.cs

@ -51,17 +51,11 @@ public class OmnibarSettingTests @@ -51,17 +51,11 @@ public class OmnibarSettingTests
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectedItem = typeNode;
Omnibar? omnibar = null;
for (int i = 0; i < 200; i++)
{
Dispatcher.UIThread.RunJobs();
omnibar = window.GetVisualDescendants().OfType<DecompilerTextView>()
await Waiters.WaitForAsync(() => (omnibar = window.GetVisualDescendants().OfType<DecompilerTextView>()
.Where(v => v.IsEffectivelyVisible)
.SelectMany(v => v.GetVisualDescendants().OfType<Omnibar>())
.FirstOrDefault();
if (omnibar != null)
break;
await Task.Delay(20);
}
.FirstOrDefault()) != null,
description: "a visible decompiler text view hosting the omnibar");
Assert.That(omnibar, Is.Not.Null, "selecting a node realizes a decompiler text view hosting the omnibar");
Assert.That(omnibar!.IsVisible, Is.False,

20
ILSpy.Tests/Docking/DocumentTabStripModeTests.cs

@ -65,15 +65,7 @@ public class DocumentTabStripModeTests @@ -65,15 +65,7 @@ public class DocumentTabStripModeTests
return (window, strip);
}
static async Task Pump(MainWindow window)
{
for (int i = 0; i < 12; i++)
{
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
await Task.Delay(20);
}
}
static Task Pump(MainWindow window) => Waiters.WaitForIdleAsync();
static Button? Dropdown(DocumentTabStrip strip)
=> strip.GetVisualDescendants().OfType<Button>()
@ -175,10 +167,7 @@ public class DocumentTabStripModeTests @@ -175,10 +167,7 @@ public class DocumentTabStripModeTests
// Drive a real pointer click through the input pipeline (open is deferred a dispatcher
// turn, which Pump runs), so this would have caught the menu failing to open on a live click.
var centre = button.TranslatePoint(new Point(button.Bounds.Width / 2, button.Bounds.Height / 2), window)
?? new Point(8, 8);
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
await window.ClickAsync(() => button);
await Pump(window);
opened.Should().BeTrue("clicking the dropdown must open its menu");
@ -198,10 +187,7 @@ public class DocumentTabStripModeTests @@ -198,10 +187,7 @@ public class DocumentTabStripModeTests
var button = Dropdown(strip);
var menu = button!.ContextMenu!;
var centre = button.TranslatePoint(new Point(button.Bounds.Width / 2, button.Bounds.Height / 2), window)
?? new Point(8, 8);
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
await window.ClickAsync(() => button);
await Pump(window);
var target = strip.Items.OfType<ContentTabPage>().Last();

7
ILSpy.Tests/Docking/MultiRowTabStripTests.cs

@ -61,12 +61,7 @@ public class MultiRowTabStripTests @@ -61,12 +61,7 @@ public class MultiRowTabStripTests
for (int i = 0; i < 40; i++)
vm.DockWorkspace.OpenNewTab(new DecompilerTabPageModel { Title = $"Tab number {i:00}" });
for (int i = 0; i < 12; i++)
{
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
await Task.Delay(20);
}
await Waiters.WaitForIdleAsync();
var strip = window.GetVisualDescendants().OfType<DocumentTabStrip>().FirstOrDefault();
strip.Should().NotBeNull("the document tab strip must be realised");

6
ILSpy.Tests/Docking/RunInNewTabTests.cs

@ -77,11 +77,7 @@ public class RunInNewTabTests @@ -77,11 +77,7 @@ public class RunInNewTabTests
TreeNavigation.CoreLibName, "System.Runtime.Versioning");
vm.AssemblyTreeModel.SelectNode(navNode);
await dock.WaitForDecompiledTextAsync();
for (int i = 0; i < 6; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
await Waiters.WaitForIdleAsync();
capturedToken.IsCancellationRequested.Should().BeFalse(
"navigating the tree must NOT cancel a long op running in its own frozen tab");

6
ILSpy.Tests/Editor/DecompilerViewTests.cs

@ -460,11 +460,7 @@ public class DecompilerViewTests @@ -460,11 +460,7 @@ public class DecompilerViewTests
await Waiters.WaitForAsync(() => tab.SyntaxExtension == ".xml" && tab.Text.Contains("<root>"));
// Drain the layout so ApplyDocument's PropertyChanged handler has executed.
for (int i = 0; i < 5; i++)
{
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
await Waiters.WaitForIdleAsync();
host.Capture("xml-folding");
// Assert — FoldingManager is installed (private field, reflected) and produced fold

5
ILSpy.Tests/Editor/DocumentationLinkTests.cs

@ -92,10 +92,7 @@ public class DocumentationLinkTests @@ -92,10 +92,7 @@ public class DocumentationLinkTests
MessageBus<NavigateToReferenceEventArgs>.Subscribers += capture;
try
{
var centre = link.TranslatePoint(
new Point(link.Bounds.Width / 2, link.Bounds.Height / 2), window)!.Value;
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
await window.ClickAsync(() => link);
}
finally
{

12
ILSpy.Tests/Editor/FoldingContextMenuTests.cs

@ -54,11 +54,7 @@ public class FoldingContextMenuTests @@ -54,11 +54,7 @@ public class FoldingContextMenuTests
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
view.HasFoldings.Should().BeTrue("decompiling a type produces brace foldings");
@ -100,11 +96,7 @@ public class FoldingContextMenuTests @@ -100,11 +96,7 @@ public class FoldingContextMenuTests
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
await Waiters.WaitForIdleAsync();
// ILSpy collapses method-body folds by default, leaving the outer type-body fold open. Two of
// those collapsed method folds are disjoint siblings: right-click "Toggle folding" on one while

7
ILSpy.Tests/Metadata/MetadataFilterRowEndToEndTests.cs

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
// DEALINGS IN THE SOFTWARE.
using System.Linq;
using System.Threading.Tasks;
using System.Reflection;
using Avalonia;
@ -73,7 +74,7 @@ public class MetadataFilterRowEndToEndTests @@ -73,7 +74,7 @@ public class MetadataFilterRowEndToEndTests
});
[AvaloniaTest]
public void Clicking_Inside_The_Popup_Never_Sorts_The_Column_Of_A_Real_DataGrid()
public async Task Clicking_Inside_The_Popup_Never_Sorts_The_Column_Of_A_Real_DataGrid()
{
// Full assembly of the real parts: an actual DataGrid with CanUserSortColumns
// (as MetadataTablePage.axaml configures it), the builder's columns, the overlay
@ -116,9 +117,7 @@ public class MetadataFilterRowEndToEndTests @@ -116,9 +117,7 @@ public class MetadataFilterRowEndToEndTests
// "Filter Attributes" tooltip).
var funnel = FindAttributesFunnel(headerPanel);
var flyout = (Flyout)FlyoutBase.GetAttachedFlyout(funnel)!;
var funnelCenter = funnel.TranslatePoint(new Point(funnel.Bounds.Width / 2, funnel.Bounds.Height / 2), window)!.Value;
window.MouseDown(funnelCenter, MouseButton.Left);
window.MouseUp(funnelCenter, MouseButton.Left);
await window.ClickAsync(() => funnel);
flyout.IsOpen.Should().BeTrue("setup precondition — the funnel click must open the flyout");
window.UpdateLayout();
Dispatcher.UIThread.RunJobs();

3
ILSpy.Tests/Options/OptionsTabTests.cs

@ -331,8 +331,7 @@ public class OptionsTabTests @@ -331,8 +331,7 @@ public class OptionsTabTests
// Toggle a re-decompile display setting.
var display = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
display.DecodeCustomAttributeBlobs = !display.DecodeCustomAttributeBlobs;
for (int i = 0; i < 12; i++)
Dispatcher.UIThread.RunJobs();
await Waiters.WaitForIdleAsync();
documents.ActiveDockable.Should().BeSameAs(optionsTab,
"an output display setting must re-decompile in place, not switch the user off the focused tab");

12
ILSpy.Tests/Search/SearchPaneNicetiesTests.cs

@ -156,11 +156,7 @@ public class SearchPaneNicetiesTests @@ -156,11 +156,7 @@ public class SearchPaneNicetiesTests
int before = workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count();
RaiseKey(results, Key.Enter, KeyModifiers.Control);
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
await Waiters.WaitForIdleAsync();
workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count()
.Should().BeGreaterThan(before, "Ctrl+Enter on a result opens it in a new document tab instead of reusing the active one");
@ -183,11 +179,7 @@ public class SearchPaneNicetiesTests @@ -183,11 +179,7 @@ public class SearchPaneNicetiesTests
int before = workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count();
vm.Activate(hit, inNewTabPage: true);
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
await Waiters.WaitForIdleAsync();
workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count()
.Should().BeGreaterThan(before, "Activate(inNewTabPage: true) must route through OpenNodeInNewTab");

37
ILSpy.Tests/Waiters.cs

@ -26,6 +26,7 @@ using Avalonia.Headless; @@ -26,6 +26,7 @@ using Avalonia.Headless;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.Docking;
using ICSharpCode.ILSpy.TextView;
@ -75,6 +76,42 @@ public static class Waiters @@ -75,6 +76,42 @@ public static class Waiters
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
}
/// <summary>
/// Waits until the application has nothing left to do: no dispatcher job queued at Background
/// priority or above, no assembly still loading in the background sweep, layout up to date and
/// a frame rendered - and until that holds on two consecutive polls, so work that a
/// thread-pool continuation is about to post back to the UI thread is caught as well.
/// </summary>
/// <remarks>
/// This is the synchronization point to use between an action and the assertions or input
/// that follow it. Pumping a fixed number of frames instead encodes how fast the machine that
/// wrote the test was, and comes apart on a loaded CI agent; "nothing pending" is the actual
/// precondition, whatever number of frames it takes.
/// </remarks>
public static async Task WaitForIdleAsync(TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? DefaultTimeout);
int consecutiveIdlePolls = 0;
while (DateTime.UtcNow < deadline)
{
PumpUI();
consecutiveIdlePolls = IsIdle() ? consecutiveIdlePolls + 1 : 0;
if (consecutiveIdlePolls >= 2)
return;
await Task.Delay(PollInterval);
}
throw new TimeoutException(
$"Timed out after {(timeout ?? DefaultTimeout).TotalSeconds:0.#}s waiting for the UI to become idle");
static bool IsIdle()
{
if (Dispatcher.UIThread.HasJobsWithPriority(DispatcherPriority.Background))
return false;
var assemblies = AppComposition.TryGetExport<AssemblyTreeModel>()?.AssemblyList?.GetAssemblies();
return assemblies == null || Array.TrueForAll(assemblies, a => a.IsLoaded);
}
}
public static async Task WaitForAssembliesAsync(
this AssemblyTreeModel atm,
int minimumCount = 1,

53
ILSpy.Tests/WindowExtensions.cs

@ -21,16 +21,69 @@ using System.Diagnostics; @@ -21,16 +21,69 @@ using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Avalonia.VisualTree;
namespace ICSharpCode.ILSpy.Tests;
public static class WindowExtensions
{
/// <summary>
/// Presses and releases <paramref name="button"/> on the visual <paramref name="resolveTarget"/>
/// returns, once the window's hit test at the click point answers with that visual (or a
/// descendant of it). Returns the window-relative point that was clicked.
/// </summary>
/// <remarks>
/// Synthesized input is routed by hit testing the rendered scene, so a press only reaches the
/// intended control when the scene agrees with the visual tree: a closed popup's light-dismiss
/// overlay keeps answering hit tests until the next frame, a virtualized row may be re-realised
/// as a different container, and layout may still be moving. Resolving the target on every poll
/// and waiting for the hit test is that precondition, whatever number of frames it takes.
/// <paramref name="pointInTarget"/> picks the point inside the target's bounds; the centre by
/// default.
/// </remarks>
public static async Task<Point> ClickAsync(
this Window window,
Func<Visual?> resolveTarget,
MouseButton button = MouseButton.Left,
RawInputModifiers modifiers = RawInputModifiers.None,
Func<Visual, Point>? pointInTarget = null,
[CallerArgumentExpression(nameof(resolveTarget))] string? description = null)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(resolveTarget);
pointInTarget ??= static t => new Point(t.Bounds.Width / 2, t.Bounds.Height / 2);
Visual? target = null;
Point? point = null;
object? lastHit = null;
try
{
await Waiters.WaitForAsync(() => {
target = resolveTarget();
point = target == null ? null : target.TranslatePoint(pointInTarget(target), window);
if (point == null)
return false;
lastHit = window.InputHitTest(point.Value);
return lastHit is Visual hit && (ReferenceEquals(hit, target) || hit.GetVisualAncestors().Contains(target));
}, description: $"{description} to answer the hit test at its click point");
}
catch (TimeoutException ex)
{
throw new TimeoutException(
$"{ex.Message} (target: {target?.GetType().Name ?? "not resolved"}, point: {point?.ToString() ?? "n/a"}, hit: {lastHit?.GetType().Name ?? "nothing"})", ex);
}
window.MouseDown(point!.Value, button, modifiers);
window.MouseUp(point.Value, button, modifiers);
return point.Value;
}
/// <summary>Snapshots the window with Skia, writes a temp PNG, and opens it in the OS
/// image viewer. No-op when <c>UseHeadlessDrawing</c> is true (CI default).</summary>
public static void CaptureAndShow(this Window window, [CallerMemberName] string? label = null)

Loading…
Cancel
Save