Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
// DEALINGS IN THE SOFTWARE.

using System.Linq;
using System.Reflection.Metadata;
using System.Threading.Tasks;

using Avalonia.Headless.NUnit;
Expand All @@ -25,10 +26,12 @@

using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.TreeView;

using ICSharpCode.ILSpy;
using ICSharpCode.ILSpy.Analyzers;
using ICSharpCode.ILSpy.Analyzers.TreeNodes;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.TextView;
using ICSharpCode.ILSpy.TreeNodes;
Expand Down Expand Up @@ -238,6 +241,93 @@ public async Task Every_Analyzer_Tree_Row_Surfaces_A_Non_Null_Icon()
}
}

[AvaloniaTest]
public async Task Analyze_Promotes_An_Analyzer_Result_Row_To_A_Top_Level_Entry()
{
// Right-click on a result row inside the analyzer pane (a "Used By" hit, say) must
// offer Analyze and, on Execute, add that row's entity as a new top-level entry.
var (_, vm) = await TestHarness.BootAsync();
var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>()
.GetEntry(nameof(Resources.Analyze));
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();

var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.IsExpanded = true;
var method = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty").MethodDefinition;

entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode } });
var rootRow = analyzerVm.Root.Children.OfType<AnalyzerEntityTreeNode>().Last();
rootRow.EnsureLazyChildren();
// A result row lives underneath an analyzer-search header, never directly under the root.
var resultRow = new AnalyzedMethodTreeNode(method, typeNode.Member);
rootRow.Children.OfType<AnalyzerSearchTreeNode>().First().Children.Add(resultRow);

var context = new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { resultRow } };
entry.IsVisible(context).Should().BeTrue("an analyzer result row wraps an entity, so Analyze must be offered");
entry.IsEnabled(context).Should().BeTrue();

var before = analyzerVm.Root.Children.Count;
entry.Execute(context);
TestCapture.Step("result-row-analyzed");

analyzerVm.Root.Children.Count.Should().Be(before + 1, "the result row's entity must become a top-level entry");
var promoted = analyzerVm.Root.Children.OfType<AnalyzerEntityTreeNode>().Last();
promoted.Member.Should().BeSameAs(method);
((object)analyzerVm.SelectedItems.Single()).Should().BeSameAs(promoted);
}

[AvaloniaTest]
public async Task Analyze_Is_Hidden_For_A_Top_Level_Analyzer_Row()
{
// A top-level analyzer row is already analysed; re-analysing it would be a no-op, so the
// entry stays hidden there (Remove is the entry offered for those rows).
var (_, vm) = await TestHarness.BootAsync();
var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>()
.GetEntry(nameof(Resources.Analyze));
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();

var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode } });
var rootRow = analyzerVm.Root.Children.OfType<AnalyzerEntityTreeNode>().Last();

entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { rootRow } })
.Should().BeFalse("a top-level analyzer row is already analysed");
}

[AvaloniaTest]
public async Task Analyze_Reuses_The_Row_When_The_Same_Entity_Comes_From_Another_Type_System()
{
// Analyzer result rows carry entities from the type system each analyzer run builds, so
// the same member reaches the pane as different IEntity/IModule instances depending on
// whether it was analysed from the assembly tree or from a result row. Both must land on
// the same top-level row.
var (_, vm) = await TestHarness.BootAsync();
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();

var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.IsExpanded = true;
var method = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty").MethodDefinition;
var first = analyzerVm.Analyze(method);
var count = analyzerVm.Root.Children.Count;

var file = method.ParentModule!.MetadataFile!;
var otherTypeSystem = new DecompilerTypeSystem(file, file.GetAssemblyResolver());
var other = otherTypeSystem.MainModule.GetDefinition((MethodDefinitionHandle)method.MetadataToken);
other.ParentModule.Should().NotBeSameAs(method.ParentModule, "the test must exercise the cross-type-system case");

var second = analyzerVm.Analyze(other);
TestCapture.Step("same-entity-other-type-system");

((object)second).Should().BeSameAs(first, "the existing row must be reused");
analyzerVm.Root.Children.Count.Should().Be(count);
((object)analyzerVm.SelectedItems.Single()).Should().BeSameAs(first);
}

static AnalyzerTreeViewModel? FindAnalyzerPane(ICSharpCode.ILSpy.Docking.DockWorkspace dockWorkspace)
{
foreach (var dockable in WalkDockables(dockWorkspace.Layout))
Expand Down
71 changes: 71 additions & 0 deletions ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,77 @@ await Waiters.WaitForAsync(() => analyzed.IsExpanded,
description: "Right must expand the node via SharpTreeView.OnKeyDown on the analyzer tree");
}

[AvaloniaTest]
public async Task Enter_Activates_The_Selected_Analyzer_Node()
{
// Enter on a single selected analyzer row activates it -- for an entity node that means
// navigating to the member's home in the assembly tree, like 10.x did. The key must reach
// SharpTreeView.OnKeyDown: the container is a ListBoxItem, and Avalonia's default key
// selection triggers treat Enter/Space as selection input and mark the event handled
// before it bubbles, so SharpTreeView suppresses that trigger for the activation case.
var (window, vm) = await TestHarness.BootAsync(3);
var dockWorkspace = AppComposition.Current.GetExport<DockWorkspace>();
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();

var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
var entity = (ITypeDefinition)typeNode.Member!;
var analyzed = analyzerVm.Analyze(entity);

dockWorkspace.ShowToolPane(AnalyzerTreeViewModel.PaneContentId);
var view = await window.WaitForComponent<ICSharpCode.ILSpy.Analyzers.AnalyzerTreeView>();
var tree = await view.WaitForComponent<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeView>();
tree.SelectedItem = analyzed;
Dispatcher.UIThread.RunJobs();
tree.FocusNode(analyzed);
Dispatcher.UIThread.RunJobs();

((object?)vm.AssemblyTreeModel.SelectedItem).Should().NotBeSameAs(typeNode,
"precondition: the assembly tree must not already sit on the target node");

window.KeyPress(Key.Enter, RawInputModifiers.None, PhysicalKey.Enter, null);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, typeNode),
description: "Enter must activate the analyzer node and select the type in the assembly tree");
}

[AvaloniaTest]
public async Task Delete_Removes_The_Selected_Top_Level_Analyzer_Node()
{
// Delete on a selected top-level analyzer row removes it from the pane (the keyboard
// equivalent of the "Remove" context-menu entry). Rows below the top level are not
// deletable, so Delete on one of them leaves the pane untouched.
var (window, vm) = await TestHarness.BootAsync(3);
var dockWorkspace = AppComposition.Current.GetExport<DockWorkspace>();
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();

var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
var analyzed = analyzerVm.Analyze((ITypeDefinition)typeNode.Member!);
analyzed.IsExpanded = true;
var child = analyzed.Children.First();

dockWorkspace.ShowToolPane(AnalyzerTreeViewModel.PaneContentId);
var view = await window.WaitForComponent<ICSharpCode.ILSpy.Analyzers.AnalyzerTreeView>();
var tree = await view.WaitForComponent<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeView>();

tree.SelectedItem = child;
Dispatcher.UIThread.RunJobs();
tree.FocusNode(child);
Dispatcher.UIThread.RunJobs();
window.KeyPress(Key.Delete, RawInputModifiers.None, PhysicalKey.Delete, null);
Dispatcher.UIThread.RunJobs();
analyzed.Children.Should().Contain(child, "Delete must not remove a nested analyzer row");
analyzerVm.Root.Children.Should().Contain(analyzed, "Delete on a nested row must not remove its top-level node");

tree.SelectedItem = analyzed;
Dispatcher.UIThread.RunJobs();
tree.FocusNode(analyzed);
Dispatcher.UIThread.RunJobs();
window.KeyPress(Key.Delete, RawInputModifiers.None, PhysicalKey.Delete, null);
await Waiters.WaitForAsync(() => !analyzerVm.Root.Children.Contains(analyzed),
description: "Delete must remove the selected top-level analyzer node from the pane");
}

[AvaloniaTest]
public async Task Ctrl_R_Analyzes_The_Selected_Member()
{
Expand Down
29 changes: 29 additions & 0 deletions ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1539,6 +1539,35 @@ public async Task Type_Tree_Node_Exposes_DerivedTypes_Subtree_For_Non_Sealed_Cla
"the loaded assembly list contains several Exception subclasses (e.g. SystemException, ArgumentException)");
}

[AvaloniaTest]
public async Task Derived_Type_Entries_Stay_Visible_When_The_DerivedTypes_Node_Is_Expanded()
{
// The filter cascade runs for children added under a visible parent. A derived-type
// entry must report FilterResult.Match there: the Recurse handling force-loads the
// entry's own (lazy) children and hides the entry when all of them are hidden -- a
// leaf derived type has none, so every entry under "Derived Types" ended up hidden.

var (_, vm) = await TestHarness.BootAsync(3);

var coreLibName = typeof(object).Assembly.GetName().Name!;
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
coreLibName, "System", "System.Exception");
// Expand the full ancestor chain so the type node is IsVisible -- the cascade only
// fires for children of visible parents, which is the state the real tree is in.
foreach (var ancestor in typeNode.Ancestors())
ancestor.IsExpanded = true;
typeNode.IsExpanded = true;

var derived = typeNode.Children.OfType<DerivedTypesTreeNode>().Single();
derived.IsExpanded = true;

var entries = derived.Children.OfType<DerivedTypesEntryNode>().ToList();
entries.Should().NotBeEmpty(
"the loaded assembly list contains several Exception subclasses");
entries.Should().OnlyContain(e => e.IsVisible,
"public derived-type entries must show under the expanded Derived Types node");
}

[AvaloniaTest]
public async Task Sealed_Class_Has_No_DerivedTypes_Node()
{
Expand Down
117 changes: 117 additions & 0 deletions ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,23 @@
using System.Linq;
using System.Threading.Tasks;

using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.NUnit;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;

using AwesomeAssertions;

using ICSharpCode.ILSpy.Properties;

using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.Commands;
using ICSharpCode.ILSpy.Docking;
using ICSharpCode.ILSpy.TextView;
using ICSharpCode.ILSpy.TreeNodes;
using ICSharpCode.ILSpy.ViewModels;
using ICSharpCode.ILSpy.Views;
Expand Down Expand Up @@ -121,6 +126,118 @@ public async Task BrowseBack_MenuItem_Forwards_CanExecute_And_Execute_To_DockWor
"after one back-step the forward stack should be non-empty");
}

[AvaloniaTest]
public async Task Mouse_Back_And_Forward_Buttons_Navigate_The_History()
{
// The extra mouse buttons (XButton1 = back, XButton2 = forward) drive the same history
// as Alt+Left / Alt+Right, matching browsers and the WPF version (where WPF itself
// translated the buttons into BrowseBack/BrowseForward commands). Avalonia has no such
// translation, so MainWindow routes the pointer events to the navigation commands.

// Arrange — build a two-entry history exactly like the menu-driven test above.
var (window, vm) = await TestHarness.BootAsync(3);
var (firstMethod, secondMethod) = await BuildTwoEntryHistoryAsync(vm);

// Act — click mouse-back anywhere in the window.
var point = new Point(100, 100);
window.MouseDown(point, MouseButton.XButton1);
window.MouseUp(point, MouseButton.XButton1);

// Assert — selection rewinds, then mouse-forward replays the step.
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod),
description: "XButton1 must navigate back one history entry");
await Waiters.WaitForAsync(() => vm.DockWorkspace.NavigateForwardCommand.CanExecute(null),
description: "after one back-step the forward stack should be non-empty");

window.MouseDown(point, MouseButton.XButton2);
window.MouseUp(point, MouseButton.XButton2);

await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, secondMethod),
description: "XButton2 must navigate forward one history entry");
}

[AvaloniaTest]
public async Task Mouse_Back_Button_Press_Does_Not_Reach_The_Control_Under_The_Pointer()
{
// The X buttons are navigation gestures, not clicks (WPF never delivered them to the
// control under the pointer). The press must not activate the pane under the pointer,
// move keyboard focus, or toggle a folding marker; only the release navigates, and the
// active pane stays where it was across the navigation.

// Arrange — two-entry history, assembly pane active, pointer over the editor.
var (window, vm) = await TestHarness.BootAsync(3);
var (firstMethod, _) = await BuildTwoEntryHistoryAsync(vm);
var view = await window.WaitForComponent<DecompilerTextView>();

vm.DockWorkspace.ShowToolPane(AssemblyTreeModel.PaneContentId);
var activePane = vm.DockWorkspace.Layout.FocusedDockable;
activePane.Should().NotBeNull("showing the assembly pane must make it the focused dockable");
var focusedElement = window.FocusManager?.GetFocusedElement();

int pressedInEditor = 0;
view.AddHandler(InputElement.PointerPressedEvent, (_, _) => pressedInEditor++,
RoutingStrategies.Tunnel | RoutingStrategies.Bubble);
var point = view.TranslatePoint(new Point(view.Bounds.Width / 2, view.Bounds.Height / 2), window);
point.Should().NotBeNull("the editor centre must map into the test window");

// Act / Assert — the press is swallowed at the window ...
window.MouseDown(point!.Value, MouseButton.XButton1);
pressedInEditor.Should().Be(0, "an X-button press must not reach the control under the pointer");
vm.DockWorkspace.Layout.FocusedDockable.Should().BeSameAs(activePane,
"pressing a mouse navigation button must not activate the pane under the pointer");
ReferenceEquals(window.FocusManager?.GetFocusedElement(), focusedElement).Should().BeTrue(
"pressing a mouse navigation button must not move keyboard focus");

// ... and the release navigates without moving the active pane to the editor.
window.MouseUp(point.Value, MouseButton.XButton1);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod),
description: "XButton1 must navigate back one history entry");
vm.DockWorkspace.Layout.FocusedDockable.Should().BeSameAs(activePane,
"navigating back must not move the active pane to the editor");
}

[AvaloniaTest]
public async Task Browse_Back_Keeps_The_Active_Pane()
{
// Back/Forward re-select a tree node and restore the tab's view state; the tab being
// navigated is already the active document, so the navigation must not move the active
// pane to it (WPF kept the current view focused). Exercises the command directly, which
// is what the Alt+Left key binding and the View menu invoke.
var (_, vm) = await TestHarness.BootAsync(3);
var (firstMethod, _) = await BuildTwoEntryHistoryAsync(vm);
vm.DockWorkspace.ShowToolPane(AssemblyTreeModel.PaneContentId);
var activePane = vm.DockWorkspace.Layout.FocusedDockable;
activePane.Should().NotBeNull("showing the assembly pane must make it the focused dockable");

vm.DockWorkspace.NavigateBackCommand.Execute(null);

await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod),
description: "BrowseBack must navigate back one history entry");
await vm.DockWorkspace.WaitForDecompiledTextAsync();
vm.DockWorkspace.Layout.FocusedDockable.Should().BeSameAs(activePane,
"navigating back must not move the active pane to the editor");
}

// Selects two methods of System.Linq.Enumerable with a pause in between so the history records
// them as two separate entries; returns them in selection order.
static async Task<(MethodTreeNode First, MethodTreeNode Second)> BuildTwoEntryHistoryAsync(MainWindowViewModel vm)
{
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.IsExpanded = true;
var firstMethod = typeNode.Children.OfType<MethodTreeNode>()
.Single(m => m.MethodDefinition.Name == "AsEnumerable");
var secondMethod = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty");

vm.AssemblyTreeModel.SelectNode(firstMethod);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
await Task.Delay(600);
vm.AssemblyTreeModel.SelectNode(secondMethod);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
return (firstMethod, secondMethod);
}

[AvaloniaTest]
public void BrowseBack_MenuItem_Carries_The_Alt_Left_Gesture()
{
Expand Down
Loading
Loading