mirror of https://github.com/icsharpcode/ILSpy.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
261 lines
11 KiB
261 lines
11 KiB
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team |
|
// |
|
// 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.Headless.NUnit; |
|
|
|
using AwesomeAssertions; |
|
|
|
using ICSharpCode.Decompiler.TypeSystem; |
|
using ICSharpCode.ILSpy.Properties; |
|
using ICSharpCode.ILSpyX.TreeView; |
|
|
|
using ICSharpCode.ILSpy; |
|
using ICSharpCode.ILSpy.Analyzers; |
|
using ICSharpCode.ILSpy.AppEnv; |
|
using ICSharpCode.ILSpy.TextView; |
|
using ICSharpCode.ILSpy.TreeNodes; |
|
using ICSharpCode.ILSpy.ViewModels; |
|
using ICSharpCode.ILSpy.Views; |
|
|
|
using NUnit.Framework; |
|
|
|
namespace ICSharpCode.ILSpy.Tests.Analyzers; |
|
|
|
[TestFixture] |
|
public class AnalyzeContextMenuTests |
|
{ |
|
[AvaloniaTest] |
|
public async Task Analyze_Entry_Is_Registered_With_The_Localised_Header() |
|
{ |
|
await TestHarness.BootAsync(); |
|
|
|
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>(); |
|
registry.Entries.Should().Contain(e => e.Metadata.Header == nameof(Resources.Analyze), |
|
"the analyze context-menu entry must reach the right-click menu through the MEF registry"); |
|
} |
|
|
|
[AvaloniaTest] |
|
public async Task Analyze_Entry_Is_Visible_For_Member_Tree_Nodes_And_Hidden_For_AssemblyTreeNode() |
|
{ |
|
// AnalyzeContextMenuEntry visibility contract: visible when every selected node is |
|
// an IMemberTreeNode (types, methods, fields, properties, events), hidden otherwise. |
|
|
|
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"); |
|
entry.IsVisible(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)typeNode } }) |
|
.Should().BeTrue("a type selection must surface the Analyze entry"); |
|
|
|
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); |
|
entry.IsVisible(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)assemblyNode } }) |
|
.Should().BeFalse("AssemblyTreeNode isn't an IMemberTreeNode — Analyze must hide"); |
|
|
|
entry.IsVisible(new TextViewContext { SelectedTreeNodes = null }) |
|
.Should().BeFalse("no selection means no entity to analyse"); |
|
entry.IsVisible(new TextViewContext { SelectedTreeNodes = System.Array.Empty<SharpTreeNode>() }) |
|
.Should().BeFalse("an empty selection must hide the entry"); |
|
} |
|
|
|
[AvaloniaTest] |
|
public async Task Analyze_Is_Visible_And_Works_For_A_Clicked_Code_Reference() |
|
{ |
|
// Right-clicking a resolved symbol (IEntity) in the decompiled code -- not a tree node -- |
|
// must also surface Analyze and push that entity into the analyzer pane. |
|
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 entity = (IEntity)typeNode.Member!; |
|
var context = new TextViewContext { Reference = new ReferenceSegment { Reference = entity } }; |
|
|
|
entry.IsVisible(context).Should().BeTrue("a clicked code reference to an entity must surface Analyze"); |
|
entry.IsEnabled(context).Should().BeTrue(); |
|
|
|
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>(); |
|
var before = analyzerVm.Root.Children.Count; |
|
entry.Execute(context); |
|
analyzerVm.Root.Children.Count.Should().Be(before + 1, |
|
"analyzing a clicked code reference must add it to the analyzer pane"); |
|
} |
|
|
|
[AvaloniaTest] |
|
public async Task Executing_Analyze_Pushes_The_Selected_Entity_Into_The_Analyzer_Pane() |
|
{ |
|
// Execute calls AnalyzerTreeViewModel.Analyze for every IMemberTreeNode in the |
|
// selection. The pane's Root.Children must hold one AnalyzedTypeTreeNode wrapping |
|
// the same type definition after the call. |
|
|
|
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 analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>(); |
|
var beforeCount = analyzerVm.Root.Children.Count; |
|
|
|
entry.Execute(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)typeNode } }); |
|
TestCapture.Step("analyze-executed"); |
|
|
|
analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1, |
|
"Execute on the analyze entry must add exactly one new row to the pane root"); |
|
(analyzerVm.Root.Children.Last() is AnalyzerEntityTreeNode).Should().BeTrue( |
|
"the new pane row must be an entity wrapper that exposes the analysed member"); |
|
} |
|
|
|
[AvaloniaTest] |
|
public async Task Pressing_Ctrl_R_On_The_Assembly_Tree_Analyses_The_Selected_Member() |
|
{ |
|
// Ctrl+R while a member is selected on the assembly tree pane must surface the |
|
// member in the analyzer pane — the same end-state as right-click + Analyze. |
|
|
|
var (window, vm) = await TestHarness.BootAsync(); |
|
|
|
var pane = await window.WaitForComponent<ICSharpCode.ILSpy.AssemblyTree.AssemblyListPane>(); |
|
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( |
|
"System.Linq", "System.Linq", "System.Linq.Enumerable"); |
|
vm.AssemblyTreeModel.SelectNode(typeNode); |
|
TestCapture.Step("type-selected"); |
|
|
|
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>(); |
|
var beforeCount = analyzerVm.Root.Children.Count; |
|
|
|
var grid = await pane.WaitForComponent<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeView>(); |
|
grid.RaiseEvent(new global::Avalonia.Input.KeyEventArgs { |
|
Key = global::Avalonia.Input.Key.R, |
|
KeyModifiers = global::Avalonia.Input.KeyModifiers.Control, |
|
RoutedEvent = global::Avalonia.Input.InputElement.KeyDownEvent, |
|
Source = grid, |
|
}); |
|
TestCapture.Step("ctrl-r-pressed"); |
|
|
|
analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1, |
|
"Ctrl+R with a type selected must analyse it"); |
|
} |
|
|
|
[AvaloniaTest] |
|
public async Task Execute_Surfaces_The_Analyzer_Pane_So_Hidden_Panes_Become_Visible() |
|
{ |
|
// Logged follow-up from earlier live-testing: analysing an entity adds it to the |
|
// pane's Root.Children, but if the pane is hidden the user doesn't see anything |
|
// happen. Execute should call DockWorkspace.ShowToolPane so the pane surfaces. |
|
|
|
var (_, vm) = await TestHarness.BootAsync(); |
|
|
|
var dockWorkspace = AppComposition.Current.GetExport<ICSharpCode.ILSpy.Docking.DockWorkspace>(); |
|
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>(); |
|
var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>() |
|
.GetEntry(nameof(Resources.Analyze)); |
|
|
|
// Pick a method that won't already be in the analyzer pane. |
|
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( |
|
"System.Linq", "System.Linq", "System.Linq.Enumerable"); |
|
typeNode.IsExpanded = true; |
|
var methodNode = typeNode.Children.OfType<MethodTreeNode>() |
|
.First(m => m.MethodDefinition.Name == "Count"); |
|
|
|
// The analyzer pane is hidden by default — not in the layout until something surfaces it. |
|
FindAnalyzerPane(dockWorkspace).Should().BeNull( |
|
"baseline: the analyzer pane is hidden until invoked"); |
|
|
|
entry.Execute(new TextViewContext { |
|
SelectedTreeNodes = new ICSharpCode.ILSpyX.TreeView.SharpTreeNode[] { methodNode } |
|
}); |
|
TestCapture.Step("analyzer-pane-surfaced"); |
|
|
|
// After Execute, the pane is materialised into the layout and active in its dock — |
|
// that's what ShowToolPane does, so the user sees the entity they just analysed. |
|
var analyzerPane = FindAnalyzerPane(dockWorkspace); |
|
analyzerPane.Should().NotBeNull("Execute must surface (materialise) the analyzer pane"); |
|
var owningDock = analyzerPane!.Owner as global::Dock.Model.Core.IDock; |
|
owningDock.Should().NotBeNull("the surfaced analyzer pane must have a dock parent"); |
|
((object?)owningDock!.ActiveDockable).Should().BeSameAs(analyzerPane, |
|
"Execute must call ShowToolPane so the analyzer pane surfaces and is active"); |
|
} |
|
|
|
[AvaloniaTest] |
|
public async Task Every_Analyzer_Tree_Row_Surfaces_A_Non_Null_Icon() |
|
{ |
|
// Logged follow-up from earlier live-testing: analyzer rows render without icons. |
|
// The Analyzed{Type,Method,Field,Property,Event,Accessor,Module}TreeNode types all |
|
// have Icon overrides returning real IImages. The "Used By" / "Uses" search-result |
|
// header rows (AnalyzerSearchTreeNode), however, didn't override Icon and inherited |
|
// null from SharpTreeNode — so the row's Image element rendered empty even though |
|
// the leaf result rows below had perfectly good icons. Every materialised node in |
|
// the analyzer tree must report a non-null Icon for the cell template to render. |
|
|
|
var (_, vm) = await TestHarness.BootAsync(); |
|
|
|
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>(); |
|
var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>() |
|
.GetEntry(nameof(Resources.Analyze)); |
|
|
|
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( |
|
"System.Linq", "System.Linq", "System.Linq.Enumerable"); |
|
typeNode.IsExpanded = true; |
|
var methodNode = typeNode.Children.OfType<MethodTreeNode>() |
|
.First(m => m.MethodDefinition.Name == "Empty"); |
|
|
|
entry.Execute(new TextViewContext { |
|
SelectedTreeNodes = new ICSharpCode.ILSpyX.TreeView.SharpTreeNode[] { methodNode } |
|
}); |
|
TestCapture.Step("analyzer-results-populated"); |
|
|
|
// Walk every materialised node under the analyzer root and assert each has Icon. |
|
var entityRow = analyzerVm.Root.Children.OfType<AnalyzerEntityTreeNode>().Last(); |
|
((object?)entityRow.Icon).Should().NotBeNull("the analysed-entity row needs an icon"); |
|
entityRow.EnsureLazyChildren(); |
|
foreach (var searchHeader in entityRow.Children.OfType<AnalyzerSearchTreeNode>()) |
|
{ |
|
((object?)searchHeader.Icon).Should().NotBeNull( |
|
$"'{searchHeader.AnalyzerHeader}' analyzer-search row needs an icon — was the regression"); |
|
} |
|
} |
|
|
|
static AnalyzerTreeViewModel? FindAnalyzerPane(ICSharpCode.ILSpy.Docking.DockWorkspace dockWorkspace) |
|
{ |
|
foreach (var dockable in WalkDockables(dockWorkspace.Layout)) |
|
{ |
|
if (dockable is AnalyzerTreeViewModel apm) |
|
return apm; |
|
} |
|
return null; |
|
} |
|
|
|
static System.Collections.Generic.IEnumerable<global::Dock.Model.Core.IDockable> WalkDockables(global::Dock.Model.Core.IDockable? root) |
|
{ |
|
if (root == null) |
|
yield break; |
|
yield return root; |
|
if (root is global::Dock.Model.Core.IDock dock && dock.VisibleDockables != null) |
|
foreach (var child in dock.VisibleDockables) |
|
foreach (var descendant in WalkDockables(child)) |
|
yield return descendant; |
|
} |
|
}
|
|
|