From a134c8b28d3896a8d7f1a4c5abec3fcfa6aeda14 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Sat, 15 Aug 2026 13:56:36 +0200 Subject: [PATCH] Aim the tree-gesture pointer tests at a hit-testable row Middle_Click_On_An_Assembly_Tree_Row_Opens_A_New_Decompiler_Tab timed out after its full 60s window on the macOS CI runner. The tests picked the first SharpTreeViewItem present in the visual tree and clicked its centre, but a container realised by the virtualizing panel is not necessarily arranged yet, and a row can sit outside the grid's viewport - on a loaded runner, where assemblies are still streaming into the tree, the click landed on nothing and the gesture never happened. The two negative tests shared the same click-point computation and would have passed vacuously in that state, so a missed click was only ever visible on the positive one. Hit-testing the candidate point back to its own row before clicking rules both out, and fails with a description instead of an unexplained timeout if no row is ever reachable. Assisted-by: Claude:claude-opus-5[1m]:Claude Code --- ILSpy.Tests/Input/HeadlessMmbPointerTests.cs | 89 ++++++++++---------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/ILSpy.Tests/Input/HeadlessMmbPointerTests.cs b/ILSpy.Tests/Input/HeadlessMmbPointerTests.cs index d4d1a7d4c..d2c904f08 100644 --- a/ILSpy.Tests/Input/HeadlessMmbPointerTests.cs +++ b/ILSpy.Tests/Input/HeadlessMmbPointerTests.cs @@ -60,27 +60,12 @@ public class HeadlessMmbPointerTests var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); - - // Any realised SharpTreeViewItem whose DataContext is an ILSpyTreeNode is a valid click - // target — the exact node doesn't matter, only that the gesture pipeline fires. - await Waiters.WaitForAsync(() => grid.GetVisualDescendants().OfType() - .Any(r => r.DataContext is ILSpyTreeNode)); - var row = grid.GetVisualDescendants().OfType() - .First(r => r.DataContext is ILSpyTreeNode); + var (topLevel, centreInTopLevel) = await WaitForClickableRowAsync(grid); // Snapshot the tab count BEFORE the gesture so we can assert a strict +1 after. var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; int before = documents.VisibleDockables?.Count ?? 0; - // Translate the row's centre into TopLevel coordinates so MouseDown lands inside it. - var topLevel = TopLevel.GetTopLevel(row)!; - var rowBounds = row.Bounds; - // Tree rows stretch to content width (with horizontal scroll), so clamp the click X to the - // visible grid viewport — the row centre can sit off-screen past the grid's right edge. - var centreInRow = new Point(System.Math.Min(rowBounds.Width, grid.Bounds.Width) / 2, rowBounds.Height / 2); - var centreInTopLevel = row.TranslatePoint(centreInRow, topLevel) - ?? throw new System.InvalidOperationException("Row not in TopLevel visual tree"); - // Real pointer event — exercises bubble + handledEventsToo routing through SharpTreeView. topLevel.MouseDown(centreInTopLevel, MouseButton.Middle); topLevel.MouseUp(centreInTopLevel, MouseButton.Middle); @@ -109,23 +94,11 @@ public class HeadlessMmbPointerTests var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); - - await Waiters.WaitForAsync(() => grid.GetVisualDescendants().OfType() - .Any(r => r.DataContext is ILSpyTreeNode)); - var row = grid.GetVisualDescendants().OfType() - .First(r => r.DataContext is ILSpyTreeNode); + var (topLevel, centreInTopLevel) = await WaitForClickableRowAsync(grid); var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; int before = documents.VisibleDockables?.Count ?? 0; - var topLevel = TopLevel.GetTopLevel(row)!; - var rowBounds = row.Bounds; - // Tree rows stretch to content width (with horizontal scroll), so clamp the click X to the - // visible grid viewport — the row centre can sit off-screen past the grid's right edge. - var centreInRow = new Point(System.Math.Min(rowBounds.Width, grid.Bounds.Width) / 2, rowBounds.Height / 2); - var centreInTopLevel = row.TranslatePoint(centreInRow, topLevel) - ?? throw new System.InvalidOperationException("Row not in TopLevel visual tree"); - topLevel.MouseDown(centreInTopLevel, MouseButton.Left, RawInputModifiers.Control); topLevel.MouseUp(centreInTopLevel, MouseButton.Left, RawInputModifiers.Control); @@ -154,25 +127,11 @@ public class HeadlessMmbPointerTests var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); - // Any realised SharpTreeViewItem whose data is an ILSpyTreeNode is a fine target; the - // non-leaf assembly node row (depth 0) is always present and never confused with a - // method row whose tree-toggle area is null. - await Waiters.WaitForAsync(() => grid.GetVisualDescendants().OfType() - .Any(r => r.DataContext is ILSpyTreeNode)); - var row = grid.GetVisualDescendants().OfType() - .First(r => r.DataContext is ILSpyTreeNode); + var (topLevel, centreInTopLevel) = await WaitForClickableRowAsync(grid); var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; int before = documents.VisibleDockables?.Count ?? 0; - var topLevel = TopLevel.GetTopLevel(row)!; - var rowBounds = row.Bounds; - // Tree rows stretch to content width (with horizontal scroll), so clamp the click X to the - // visible grid viewport — the row centre can sit off-screen past the grid's right edge. - var centreInRow = new Point(System.Math.Min(rowBounds.Width, grid.Bounds.Width) / 2, rowBounds.Height / 2); - var centreInTopLevel = row.TranslatePoint(centreInRow, topLevel) - ?? throw new System.InvalidOperationException("Row not in TopLevel visual tree"); - // Drive two LMB clicks at the same coordinate — Avalonia.Headless interprets the // second click within the double-click threshold as a real double-click event. topLevel.MouseDown(centreInTopLevel, MouseButton.Left); @@ -188,4 +147,46 @@ public class HeadlessMmbPointerTests (documents.VisibleDockables?.Count ?? 0).Should().Be(before, "LMB double-click must not open a new tab — only MMB does"); } + + /// + /// Waits until an assembly-tree row is actually clickable and returns the TopLevel point that + /// lands on it. "Realised in the visual tree" is not enough: under layout churn (assemblies + /// still streaming into the tree) the virtualizing panel can hand out a container whose Bounds + /// are still empty, and a row can sit outside the grid's viewport - a pointer event aimed at + /// either reaches nothing, so the gesture silently does not happen. Hit-testing the candidate + /// point back to its own row before clicking rules both out. Which row is used does not matter, + /// only that the click lands on one whose node is an . + /// + static async Task<(TopLevel TopLevel, Point Point)> WaitForClickableRowAsync( + ICSharpCode.ILSpy.Controls.TreeView.SharpTreeView grid) + { + Point point = default; + await Waiters.WaitForAsync(() => TryGetRowClickPoint(grid, out point), + description: "an assembly-tree row whose centre hit-tests back to that row"); + return (TopLevel.GetTopLevel(grid)!, point); + } + + static bool TryGetRowClickPoint(ICSharpCode.ILSpy.Controls.TreeView.SharpTreeView grid, out Point point) + { + point = default; + if (TopLevel.GetTopLevel(grid) is not { } topLevel) + return false; + foreach (var row in grid.GetVisualDescendants().OfType()) + { + if (row.DataContext is not ILSpyTreeNode || row.Bounds.Width <= 0 || row.Bounds.Height <= 0) + continue; + // Tree rows stretch to content width (with horizontal scroll), so clamp the click X to the + // visible grid viewport - the row centre can sit off-screen past the grid's right edge. + var centreInRow = new Point(System.Math.Min(row.Bounds.Width, grid.Bounds.Width) / 2, row.Bounds.Height / 2); + if (row.TranslatePoint(centreInRow, topLevel) is not { } candidate) + continue; + if (topLevel.InputHitTest(candidate) is Visual hit + && ReferenceEquals(hit.FindAncestorOfType(includeSelf: true), row)) + { + point = candidate; + return true; + } + } + return false; + } }