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

5
ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs

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

63
ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs

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

12
ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs

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

24
ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs

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

12
ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs

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

125
ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs

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

19
ILSpy.Tests/ContextMenus/KeyboardContextMenuFocusTests.cs

@ -60,12 +60,7 @@ public class KeyboardContextMenuFocusTests
var node = vm.AssemblyTreeModel.Root!.Children.OfType<AssemblyTreeNode>().First(); var node = vm.AssemblyTreeModel.Root!.Children.OfType<AssemblyTreeNode>().First();
vm.AssemblyTreeModel.SelectNode(node); vm.AssemblyTreeModel.SelectNode(node);
for (int i = 0; i < 8; i++) await Waiters.WaitForIdleAsync();
{
Dispatcher.UIThread.RunJobs();
grid.UpdateLayout();
await Task.Delay(25);
}
var row = grid.GetVisualDescendants() var row = grid.GetVisualDescendants()
.OfType<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem>().First(); .OfType<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem>().First();
@ -77,21 +72,13 @@ public class KeyboardContextMenuFocusTests
// Keyboard invocation raises ContextRequested with no pointer position (the Shift+F10 / Apps path). // Keyboard invocation raises ContextRequested with no pointer position (the Shift+F10 / Apps path).
row.RaiseEvent(new ContextRequestedEventArgs()); row.RaiseEvent(new ContextRequestedEventArgs());
for (int i = 0; i < 6; i++) await Waiters.WaitForIdleAsync();
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
grid.ContextMenu!.IsOpen.Should().BeTrue("the keyboard gesture must open the tree context menu"); grid.ContextMenu!.IsOpen.Should().BeTrue("the keyboard gesture must open the tree context menu");
row.Classes.Should().Contain("contextTarget", row.Classes.Should().Contain("contextTarget",
"a keyboard-invoked menu must show the transient target highlight on the selected row, like the mouse path"); "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); window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, keySymbol: null);
for (int i = 0; i < 6; i++) await Waiters.WaitForIdleAsync();
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
(focusManager.GetFocusedElement() == row).Should().BeTrue( (focusManager.GetFocusedElement() == row).Should().BeTrue(
"closing a keyboard-invoked context menu must return focus to the row, not strand it"); "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
int before = vm.DockWorkspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count(); int before = vm.DockWorkspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count();
entry.Execute(RefContext(entity)); entry.Execute(RefContext(entity));
for (int i = 0; i < 8; i++) await Waiters.WaitForIdleAsync();
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
vm.DockWorkspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count() vm.DockWorkspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count()
.Should().BeGreaterThan(before, "Decompile to new tab on a code reference must open a new document tab"); .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
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectedItem = typeNode; vm.AssemblyTreeModel.SelectedItem = typeNode;
Omnibar? omnibar = null; Omnibar? omnibar = null;
for (int i = 0; i < 200; i++) await Waiters.WaitForAsync(() => (omnibar = window.GetVisualDescendants().OfType<DecompilerTextView>()
{
Dispatcher.UIThread.RunJobs();
omnibar = window.GetVisualDescendants().OfType<DecompilerTextView>()
.Where(v => v.IsEffectivelyVisible) .Where(v => v.IsEffectivelyVisible)
.SelectMany(v => v.GetVisualDescendants().OfType<Omnibar>()) .SelectMany(v => v.GetVisualDescendants().OfType<Omnibar>())
.FirstOrDefault(); .FirstOrDefault()) != null,
if (omnibar != null) description: "a visible decompiler text view hosting the omnibar");
break;
await Task.Delay(20);
}
Assert.That(omnibar, Is.Not.Null, "selecting a node realizes a 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, Assert.That(omnibar!.IsVisible, Is.False,

20
ILSpy.Tests/Docking/DocumentTabStripModeTests.cs

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

7
ILSpy.Tests/Docking/MultiRowTabStripTests.cs

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

6
ILSpy.Tests/Docking/RunInNewTabTests.cs

@ -77,11 +77,7 @@ public class RunInNewTabTests
TreeNavigation.CoreLibName, "System.Runtime.Versioning"); TreeNavigation.CoreLibName, "System.Runtime.Versioning");
vm.AssemblyTreeModel.SelectNode(navNode); vm.AssemblyTreeModel.SelectNode(navNode);
await dock.WaitForDecompiledTextAsync(); await dock.WaitForDecompiledTextAsync();
for (int i = 0; i < 6; i++) await Waiters.WaitForIdleAsync();
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
capturedToken.IsCancellationRequested.Should().BeFalse( capturedToken.IsCancellationRequested.Should().BeFalse(
"navigating the tree must NOT cancel a long op running in its own frozen tab"); "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
await Waiters.WaitForAsync(() => tab.SyntaxExtension == ".xml" && tab.Text.Contains("<root>")); await Waiters.WaitForAsync(() => tab.SyntaxExtension == ".xml" && tab.Text.Contains("<root>"));
// Drain the layout so ApplyDocument's PropertyChanged handler has executed. // Drain the layout so ApplyDocument's PropertyChanged handler has executed.
for (int i = 0; i < 5; i++) await Waiters.WaitForIdleAsync();
{
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
host.Capture("xml-folding"); host.Capture("xml-folding");
// Assert — FoldingManager is installed (private field, reflected) and produced fold // Assert — FoldingManager is installed (private field, reflected) and produced fold

5
ILSpy.Tests/Editor/DocumentationLinkTests.cs

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

12
ILSpy.Tests/Editor/FoldingContextMenuTests.cs

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

7
ILSpy.Tests/Metadata/MetadataFilterRowEndToEndTests.cs

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

3
ILSpy.Tests/Options/OptionsTabTests.cs

@ -331,8 +331,7 @@ public class OptionsTabTests
// Toggle a re-decompile display setting. // Toggle a re-decompile display setting.
var display = AppComposition.Current.GetExport<SettingsService>().DisplaySettings; var display = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
display.DecodeCustomAttributeBlobs = !display.DecodeCustomAttributeBlobs; display.DecodeCustomAttributeBlobs = !display.DecodeCustomAttributeBlobs;
for (int i = 0; i < 12; i++) await Waiters.WaitForIdleAsync();
Dispatcher.UIThread.RunJobs();
documents.ActiveDockable.Should().BeSameAs(optionsTab, documents.ActiveDockable.Should().BeSameAs(optionsTab,
"an output display setting must re-decompile in place, not switch the user off the focused tab"); "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
int before = workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count(); int before = workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count();
RaiseKey(results, Key.Enter, KeyModifiers.Control); RaiseKey(results, Key.Enter, KeyModifiers.Control);
for (int i = 0; i < 8; i++) await Waiters.WaitForIdleAsync();
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count() 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"); .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
int before = workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count(); int before = workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count();
vm.Activate(hit, inNewTabPage: true); vm.Activate(hit, inNewTabPage: true);
for (int i = 0; i < 8; i++) await Waiters.WaitForIdleAsync();
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count() workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count()
.Should().BeGreaterThan(before, "Activate(inNewTabPage: true) must route through OpenNodeInNewTab"); .Should().BeGreaterThan(before, "Activate(inNewTabPage: true) must route through OpenNodeInNewTab");

37
ILSpy.Tests/Waiters.cs

@ -26,6 +26,7 @@ using Avalonia.Headless;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.AssemblyTree; using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.Docking; using ICSharpCode.ILSpy.Docking;
using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.TextView;
@ -75,6 +76,42 @@ public static class Waiters
AvaloniaHeadlessPlatform.ForceRenderTimerTick(); 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( public static async Task WaitForAssembliesAsync(
this AssemblyTreeModel atm, this AssemblyTreeModel atm,
int minimumCount = 1, int minimumCount = 1,

53
ILSpy.Tests/WindowExtensions.cs

@ -21,16 +21,69 @@ using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless; using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree;
namespace ICSharpCode.ILSpy.Tests; namespace ICSharpCode.ILSpy.Tests;
public static class WindowExtensions 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 /// <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> /// image viewer. No-op when <c>UseHeadlessDrawing</c> is true (CI default).</summary>
public static void CaptureAndShow(this Window window, [CallerMemberName] string? label = null) public static void CaptureAndShow(this Window window, [CallerMemberName] string? label = null)

Loading…
Cancel
Save