diff --git a/ILSpy.Tests/AssemblyTree/NavigateToReferenceAssemblyTests.cs b/ILSpy.Tests/AssemblyTree/NavigateToReferenceAssemblyTests.cs new file mode 100644 index 000000000..0b9fb4fcb --- /dev/null +++ b/ILSpy.Tests/AssemblyTree/NavigateToReferenceAssemblyTests.cs @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// 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.IO; +using System.Threading.Tasks; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.ILSpy.AssemblyTree; +using ICSharpCode.ILSpyX; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.AssemblyTree; + +/// +/// A navigation target that only a reference assembly declares. The VS add-in hands ILSpy the +/// assemblies a project references, which for a framework-targeting project are the targeting +/// pack's reference assemblies, so refusing to look in them leaves the target unresolved and +/// the jump silently does nothing (issue #2093). +/// +[TestFixture] +public class NavigateToReferenceAssemblyTests +{ + // The member is looked up on this fixture itself: it is public, so the reference assembly + // declares it too, and both assemblies are next to the test at run time. + public static int TargetMember(string text) => text.Length; + + const string TargetId = "M:ICSharpCode.ILSpy.Tests.AssemblyTree.NavigateToReferenceAssemblyTests.TargetMember(System.String)"; + + static string ImplementationPath => typeof(NavigateToReferenceAssemblyTests).Assembly.Location; + + static string ReferencePath => Path.Combine( + Path.GetDirectoryName(ImplementationPath)!, "ReferenceAssemblyFixture", + Path.GetFileName(ImplementationPath)); + + [Test] + public async Task The_Fixture_Really_Is_A_Reference_Assembly() + { + File.Exists(ReferencePath).Should().BeTrue( + "the build copies this project's reference assembly next to the tests"); + var list = new AssemblyList(); + var reference = list.OpenAssembly(ReferencePath); + var file = await reference.GetMetadataFileOrNullAsync(); + file.Should().NotBeNull(); + file!.IsReferenceAssembly().Should().BeTrue("otherwise the tests below prove nothing"); + } + + [Test] + public async Task A_Member_Only_A_Reference_Assembly_Declares_Still_Resolves() + { + var list = new AssemblyList(); + var reference = list.OpenAssembly(ReferencePath); + await reference.GetMetadataFileOrNullAsync(); + + var entity = AssemblyTreeModel.FindEntityInRelevantAssemblies(TargetId, new[] { reference }); + + entity.Should().NotBeNull("a reference assembly is the only place the member can be found"); + entity!.Name.Should().Be(nameof(TargetMember)); + } + + [Test] + public async Task An_Implementation_Assembly_Wins_Over_A_Reference_Assembly() + { + var list = new AssemblyList(); + var reference = list.OpenAssembly(ReferencePath); + var implementation = list.OpenAssembly(ImplementationPath); + await reference.GetMetadataFileOrNullAsync(); + await implementation.GetMetadataFileOrNullAsync(); + + // The reference assembly comes first, so a plain "first hit wins" search would answer + // with it; only a search that prefers real definitions picks the implementation. + var entity = AssemblyTreeModel.FindEntityInRelevantAssemblies( + TargetId, new[] { reference, implementation }); + + entity.Should().NotBeNull(); + entity!.ParentModule!.MetadataFile!.FileName.Should().Be(ImplementationPath, + "a definition with a body is more useful than a signature-only one"); + } +} diff --git a/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs b/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs index 336935b52..7cde3c34e 100644 --- a/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs +++ b/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs @@ -18,6 +18,8 @@ using System; using System.IO; +using System.Linq; +using System.Collections.Generic; using System.Threading.Tasks; using Avalonia.Headless.NUnit; @@ -26,6 +28,9 @@ using AwesomeAssertions; using ICSharpCode.ILSpy.AppEnv; using ICSharpCode.ILSpy.Languages; +using ICSharpCode.ILSpy.Metadata; +using ICSharpCode.ILSpy.Metadata.CorTables; +using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.TreeNodes; using ICSharpCode.ILSpy.ViewModels; using ICSharpCode.ILSpy.Views; @@ -61,6 +66,79 @@ public class CommandLineArgumentsTests languageService.CurrentLanguage.Name.Should().Be("IL"); } + [AvaloniaTest] + public async Task NavigateTo_An_Id_That_Names_Nothing_Reports_What_Was_Searched() + { + // An ID that resolves to nothing used to leave the tree untouched and say nothing, so a + // jump that silently did not happen looked like a jump to the wrong place. The target + // and the assemblies that were searched are written to the pane the jump would have + // filled (issue #2093). + + // Arrange - boot, and decompile something so the main tab really is a decompiler tab. + // Whether one is active at startup is a race, and the report lands elsewhere when it + // is not; the sibling test below covers that case. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + vm.AssemblyTreeModel.SelectNode(typeNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + vm.DockWorkspace.ActiveDecompilerTab.Should().NotBeNull("the report's preferred sink must exist"); + + var args = CommandLineArguments.Create(new[] { "--navigateto", "M:No.Such.Type.NoSuchMember" }); + + // Act - apply the args. + await vm.AssemblyTreeModel.HandleCommandLineArgumentsAsync(args); + + // Assert - the pane the jump would have filled names the target that was not found. + var tab = vm.DockWorkspace.ActiveDecompilerTab; + tab.Should().NotBeNull("the report goes to the decompiler pane"); + tab!.Text.Should().Contain("M:No.Such.Type.NoSuchMember"); + } + + [AvaloniaTest] + public async Task NavigateTo_Reports_An_Unresolved_Id_Even_Without_An_Active_Decompiler_Tab() + { + // DockWorkspace.ShowText writes to the active decompiler tab and silently does nothing + // when the active content is something else, which is a real state at startup and + // whenever a metadata table is in front. A report that can go missing is no better than + // the silence it replaces. + + // Arrange - boot and put a metadata table in front, so there is no decompiler tab. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + var typeDefNode = vm.AssemblyTreeModel.FindCoreLib() + .GetChild() + .GetChild() + .GetChild(); + vm.AssemblyTreeModel.SelectNode(typeDefNode); + await vm.DockWorkspace.WaitForMetadataTabAsync(); + vm.DockWorkspace.ActiveDecompilerTab.Should().BeNull("the metadata table must be in front"); + + var args = CommandLineArguments.Create(new[] { "--navigateto", "M:No.Such.Type.NoSuchMember" }); + + // Act - apply the args. + await vm.AssemblyTreeModel.HandleCommandLineArgumentsAsync(args); + + // Assert - the report opened a tab of its own rather than vanishing. ActiveDecompilerTab + // only ever names the main tab's content, which the metadata table still occupies, so + // the new tab is looked for among the open documents. + await Waiters.WaitForAsync(() => ReportTabs(vm).Any()); + ReportTabs(vm).Single().Text.Should().Contain("M:No.Such.Type.NoSuchMember"); + + static IEnumerable ReportTabs(MainWindowViewModel vm) + => vm.DockWorkspace.Documents?.VisibleDockables? + .OfType() + .Select(t => t.Content) + .OfType() + .Where(t => t.Title == "Navigation") + ?? []; + } + [AvaloniaTest] public async Task NavigateTo_Type_Arg_Selects_The_Matching_Type_Node() { diff --git a/ILSpy.Tests/ILSpy.Tests.csproj b/ILSpy.Tests/ILSpy.Tests.csproj index 018389097..fdd5cf9d8 100644 --- a/ILSpy.Tests/ILSpy.Tests.csproj +++ b/ILSpy.Tests/ILSpy.Tests.csproj @@ -27,6 +27,20 @@ + + + true + + + + + + +