Browse Source

Merge pull request #4083 from icsharpcode/test-harness/idle-and-click

Synchronize headless UI tests on idle and hit-tested clicks
pull/4080/head
Siegfried Pammer 2 weeks ago committed by GitHub
parent
commit
7b75b8181f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  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