Skip to content
22 changes: 22 additions & 0 deletions ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,28 @@ public async Task Analyze_Entry_Is_Visible_For_Member_Tree_Nodes_And_Hidden_For_
.Should().BeFalse("an empty selection must hide the entry");
}

[AvaloniaTest]
public async Task Analyze_Entry_Is_Disabled_For_A_Selection_That_Mixes_Members_And_Other_Nodes()
{
// IsEnabled must judge the same node shape as IsVisible: a selection that contains a
// non-member node is hidden, so it must not report itself as enabled either (a filter over
// the member subset would call a type-plus-assembly selection analysable).
var (_, vm) = await TestHarness.BootAsync();

var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>()
.GetEntry(nameof(Resources.Analyze));

var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
var mixed = new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode, assemblyNode } };

entry.IsVisible(mixed).Should().BeFalse("precondition: a mixed selection hides the entry");
entry.IsEnabled(mixed).Should().BeFalse("a mixed selection must be disabled, not enabled-but-hidden");
entry.IsEnabled(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { assemblyNode } })
.Should().BeFalse("a selection without any member node has nothing to analyse");
}

[AvaloniaTest]
public async Task Analyze_Is_Visible_And_Works_For_A_Clicked_Code_Reference()
{
Expand Down
5 changes: 3 additions & 2 deletions ILSpy.Tests/Analyzers/AnalyzerPaneRemoveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
using ICSharpCode.ILSpyX.TreeView;

using ICSharpCode.ILSpy;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpy.Analyzers;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.TreeNodes;
Expand All @@ -46,7 +47,7 @@ public async Task Remove_Entry_Is_Visible_Only_For_Top_Level_Analysed_Entities()

var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
var entry = registry.Entries
.Single(e => e.Metadata.Header == "Remove"
.Single(e => e.Metadata.Header == nameof(Resources.Remove)
&& e.Value is RemoveAnalyzeContextMenuEntry)
.Value;

Expand All @@ -70,7 +71,7 @@ public async Task Remove_Execute_Drops_The_Selected_Root_Children()

var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
var entry = registry.Entries
.Single(e => e.Metadata.Header == "Remove"
.Single(e => e.Metadata.Header == nameof(Resources.Remove)
&& e.Value is RemoveAnalyzeContextMenuEntry)
.Value;

Expand Down
171 changes: 171 additions & 0 deletions ILSpy.Tests/Analyzers/AnalyzerTreeContextMenuTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright (c) 2026 Christoph Wille
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.


using System.Linq;
using System.Threading.Tasks;

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

using AwesomeAssertions;

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

using ICSharpCode.ILSpy;
using ICSharpCode.ILSpy.Analyzers;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.Controls.TreeView;
using ICSharpCode.ILSpy.Docking;
using ICSharpCode.ILSpy.TreeNodes;

using NUnit.Framework;

namespace ICSharpCode.ILSpy.Tests.Analyzers;

/// <summary>
/// The Analyzer pane's context menu follows the same Thunderbird-style rules as the assembly
/// tree: a right-click on a row outside the selection targets that row alone without moving the
/// selection, the target carries the highlight class while the menu is open, and a keyboard-invoked
/// menu returns the focus to the row it was opened for.
/// </summary>
[TestFixture]
public class AnalyzerTreeContextMenuTests
{
static async Task<(ICSharpCode.ILSpy.Views.MainWindow Window, AnalyzerTreeView View, SharpTreeView Tree,
AnalyzerEntityTreeNode First, AnalyzerEntityTreeNode Second)> SetupTwoAnalysedTypesAsync()
{
var (window, vm) = await TestHarness.BootAsync(3);
var dockWorkspace = AppComposition.Current.GetExport<DockWorkspace>();
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();

var enumerable = vm.AssemblyTreeModel.FindNode<TypeTreeNode>("System.Linq", "System.Linq", "System.Linq.Enumerable");
var lookup = vm.AssemblyTreeModel.FindNode<TypeTreeNode>("System.Linq", "System.Linq", "System.Linq.Lookup`2");
var first = analyzerVm.Analyze((ITypeDefinition)enumerable.Member!);
var second = analyzerVm.Analyze((ITypeDefinition)lookup.Member!);
first.IsExpanded = false;
second.IsExpanded = false;

dockWorkspace.ShowToolPane(AnalyzerTreeViewModel.PaneContentId);
var view = await window.WaitForComponent<AnalyzerTreeView>();
var tree = await view.WaitForComponent<SharpTreeView>();
// Analyze selects the row it adds; put the selection back on the first row so the second
// one is the unselected target of the probes below.
tree.SelectedItem = first;
await Waiters.WaitForIdleAsync();
ReferenceEquals(analyzerVm.SelectedItems.SingleOrDefault(), first).Should().BeTrue(
"precondition: the pane selection must sit on the first analysed row");
return (window, view, tree, first, second);
}

static SharpTreeViewItem? RowFor(SharpTreeView tree, SharpTreeNode node)
=> tree.GetVisualDescendants().OfType<SharpTreeViewItem>().FirstOrDefault(r => ReferenceEquals(r.Node, node));

[AvaloniaTest]
public async Task Right_Clicking_An_Unselected_Analyzer_Row_Targets_It_Without_Moving_The_Selection()
{
var (window, _, tree, first, second) = await SetupTwoAnalysedTypesAsync();
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();
var menu = tree.ContextMenu!;

await window.ClickAsync(() => RowFor(tree, second), MouseButton.Right,
pointInTarget: r => new Point(System.Math.Min(r.Bounds.Width, tree.Bounds.Width) / 2, r.Bounds.Height / 2));
await Waiters.WaitForAsync(() => menu.IsOpen, description: "the right-clicked analyzer row's context menu to open");

ReferenceEquals(analyzerVm.SelectedItems.SingleOrDefault(), first).Should().BeTrue(
"right-clicking an unselected analyzer row must not move the selection");
RowFor(tree, second)!.Classes.Should().Contain("contextTarget",
"the right-clicked row must carry the context-target highlight while the menu is open");

window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, keySymbol: null);
await Waiters.WaitForAsync(() => !menu.IsOpen, description: "the context menu to close");
RowFor(tree, second)!.Classes.Should().NotContain("contextTarget",
"the transient highlight must clear once the menu closes");
}

[AvaloniaTest]
public async Task Menu_Built_For_A_Right_Clicked_Row_Outside_The_Selection_Targets_Only_That_Row()
{
var (_, view, tree, first, second) = await SetupTwoAnalysedTypesAsync();

TextViewContext? seen = null;
var export = new StubExport(new RecordingEntry(c => seen = c), new ContextMenuEntryMetadata { Header = "Probe", Order = 0 });

var built = view.BuildContextMenuForCurrentState(new IContextMenuEntryExport[] { export }, rightClickedNode: second);
built.Should().NotBeNull("the probe entry must produce a menu");
var item = built!.Items.OfType<MenuItem>().Single();
item.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(MenuItem.ClickEvent));

seen.Should().NotBeNull("the probe entry must have been executed");
ReferenceEquals(seen!.TreeGrid, tree).Should().BeTrue("the context must name the analyzer tree");
seen.SelectedTreeNodes.Should().BeEquivalentTo(new SharpTreeNode[] { second },
"a right-click outside the selection acts on the clicked row alone, not on the selection");

seen = null;
built = view.BuildContextMenuForCurrentState(new IContextMenuEntryExport[] { export }, rightClickedNode: first);
built!.Items.OfType<MenuItem>().Single().RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(MenuItem.ClickEvent));
seen!.SelectedTreeNodes.Should().BeEquivalentTo(new SharpTreeNode[] { first },
"a right-click inside the selection acts on the whole selection");
}

[AvaloniaTest]
public async Task Keyboard_Invoked_Analyzer_Menu_Returns_Focus_To_The_Row_On_Close()
{
var (window, _, tree, first, _) = await SetupTwoAnalysedTypesAsync();
var row = RowFor(tree, first);
row.Should().NotBeNull("the selected analyzer row must be realised");
row!.Focus(NavigationMethod.Tab);
Dispatcher.UIThread.RunJobs();

var focusManager = TopLevel.GetTopLevel(window)!.FocusManager!;
(focusManager.GetFocusedElement() == row).Should().BeTrue("the row must hold focus before invoking the menu");

// Keyboard invocation raises ContextRequested with no pointer position (the Shift+F10 / Apps path).
row.RaiseEvent(new ContextRequestedEventArgs());
await Waiters.WaitForIdleAsync();
tree.ContextMenu!.IsOpen.Should().BeTrue("the keyboard gesture must open the analyzer context menu");
row.Classes.Should().Contain("contextTarget",
"a keyboard-invoked menu must show the target highlight on the focused row, like the mouse path");

window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, keySymbol: null);
await Waiters.WaitForIdleAsync();

(focusManager.GetFocusedElement() == row).Should().BeTrue(
"closing a keyboard-invoked context menu must return focus to the row, not strand it");
row.Classes.Should().NotContain("contextTarget", "the transient highlight must clear once the menu closes");
}

sealed class RecordingEntry(System.Action<TextViewContext> onExecute) : IContextMenuEntry
{
public bool IsVisible(TextViewContext context) => true;
public bool IsEnabled(TextViewContext context) => true;
public void Execute(TextViewContext context) => onExecute(context);
}

sealed class StubExport(IContextMenuEntry entry, ContextMenuEntryMetadata metadata) : IContextMenuEntryExport
{
public IContextMenuEntry Value { get; } = entry;
public ContextMenuEntryMetadata Metadata { get; } = metadata;
}
}
53 changes: 53 additions & 0 deletions ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
using ICSharpCode.Decompiler.TypeSystem;

using ICSharpCode.ILSpy.Analyzers;
using ICSharpCode.ILSpy.Analyzers.TreeNodes;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.Docking;
using ICSharpCode.ILSpy.TreeNodes;
Expand Down Expand Up @@ -167,4 +168,56 @@ await Waiters.WaitForAsync(() => analyzerVm.Root.Children.Count > before,
.Any(n => n.Member is { } m && m.MetadataToken == ((ITypeDefinition)typeNode.Member!).MetadataToken)
.Should().BeTrue("the analyzer pane must hold a node for the type that Ctrl+R analyzed");
}

[AvaloniaTest]
public async Task Ctrl_R_On_An_Analyzer_Result_Row_Promotes_It_Instead_Of_The_Assembly_Tree_Selection()
{
// Ctrl+R with the focus inside the Analyzer pane analyzes the pane's own selection (a result
// row becomes a top-level entry), like the pane-level binding did in 10.x. The window-level
// Ctrl+R, which analyzes the assembly tree's selection, must not fire for the pane.
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");
typeNode.IsExpanded = true;
var method = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty").MethodDefinition;
var analyzed = analyzerVm.Analyze((ITypeDefinition)typeNode.Member!);
analyzed.EnsureLazyChildren();
// A result row lives underneath an analyzer-search header, never directly under the root.
var searchRow = analyzed.Children.OfType<AnalyzerSearchTreeNode>().First();
var resultRow = new AnalyzedMethodTreeNode(method, typeNode.Member);
searchRow.Children.Add(resultRow);
analyzed.IsExpanded = true;
searchRow.IsExpanded = true;

// Park the assembly tree on another analysable type: if the window binding fired instead,
// this is what would land in the pane.
var decoy = vm.AssemblyTreeModel.FindNode<TypeTreeNode>("System.Linq", "System.Linq", "System.Linq.Lookup`2");
vm.AssemblyTreeModel.SelectNode(decoy);
await Waiters.WaitForIdleAsync();

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 = resultRow;
Dispatcher.UIThread.RunJobs();
tree.FocusNode(resultRow);
Dispatcher.UIThread.RunJobs();
((object?)analyzerVm.SelectedItems.SingleOrDefault()).Should().BeSameAs(resultRow,
"precondition: the pane selection must sit on the result row");

int before = analyzerVm.Root.Children.Count;
window.KeyPress(Key.R, RawInputModifiers.Control, PhysicalKey.R, null);
await Waiters.WaitForAsync(() => analyzerVm.Root.Children.Count > before,
description: "Ctrl+R in the Analyzer pane must promote the selected result row");

var promoted = analyzerVm.Root.Children.OfType<AnalyzerEntityTreeNode>().Last();
promoted.Member.Should().BeSameAs(method, "the pane's own selection is what Ctrl+R analyzes");
analyzerVm.Root.Children.OfType<AnalyzerEntityTreeNode>()
.Any(n => n.Member is { } m && m.MetadataToken == ((ITypeDefinition)decoy.Member!).MetadataToken)
.Should().BeFalse("the assembly tree's selection must not be analyzed when the key is pressed inside the pane");
}
}
66 changes: 66 additions & 0 deletions ILSpy.Tests/Docking/SetActiveDockableTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) 2026 Christoph Wille
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.


using System.Threading.Tasks;

using Avalonia.Headless.NUnit;

using AwesomeAssertions;

using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.TreeNodes;

using NUnit.Framework;

namespace ICSharpCode.ILSpy.Tests.Docking;

/// <summary>
/// Dock's ActiveDockable setter re-runs its activation (ending in SetFocusedDockable) even when
/// the value does not change, so a plain SetActiveDockable of the already-active document would
/// move the active-pane highlight to the documents dock. The factory guards that centrally, so
/// every caller may re-activate without checking first.
/// </summary>
[TestFixture]
public class SetActiveDockableTests
{
[AvaloniaTest]
public async Task Reactivating_The_Active_Document_Keeps_The_Focused_Dockable()
{
var (_, vm) = await TestHarness.BootAsync();
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();

var docs = vm.DockWorkspace.Documents!;
var activeDocument = docs.ActiveDockable;
activeDocument.Should().NotBeNull("decompiling a type must leave an active document tab");

vm.DockWorkspace.ShowToolPane(AssemblyTreeModel.PaneContentId);
var focusedPane = vm.DockWorkspace.Layout.FocusedDockable;
focusedPane.Should().NotBeNull("showing the assembly pane must make it the focused dockable");
focusedPane.Should().NotBeSameAs(activeDocument, "precondition: the focus must sit outside the documents dock");

vm.DockWorkspace.Factory.SetActiveDockable(activeDocument!);

docs.ActiveDockable.Should().BeSameAs(activeDocument, "the active document stays active");
vm.DockWorkspace.Layout.FocusedDockable.Should().BeSameAs(focusedPane,
"re-activating the already-active document must not move the focused dockable to it");
}
}
Loading
Loading