From 2e8b55a3283fff4be4a43bd5639f3f4e780e0f9d Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Mon, 19 Oct 2026 21:16:00 +0200 Subject: [PATCH 1/6] Decompile a small sample type instead of framework types in UI tests Most of the slow UI tests only needed "a document", "an active tab" or "a type with a few members", yet got it by decompiling System.String, System.Object or System.Linq.Enumerable, or by landing on the CoreLib assembly row. Each such decompile rebuilds a framework type system and costs one to ten seconds; the sample type in the test assembly and the emitted fixture assemblies give the same shape for a fraction of that. Assertions on the sample's decompiled text use fragments that survive the Release build CI tests, where the compiler folds locals away. Tests whose assertion depends on the framework type (XML docs, type forwarders, derived-type scans, a long document, resources, references) keep it. Sharing one AnalyzerContext across FieldAccessAnalyzerTests' calls was tried as well and measured slower (4.9 s -> 8.5 s per fixture in four runs), so that fixture is unchanged. #4262 Single-process wall clock on the same machine: 369 s -> 271 s. Assisted-by: Claude:claude-fable-5-1:Claude Code --- .../Analyzers/AnalyzerResultHighlightTests.cs | 44 ++--- .../AssemblyList/AssemblyListResetTests.cs | 16 +- ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs | 18 +- .../SwitchListAndDeleteAllTests.cs | 7 +- .../Bookmarks/BookmarkAnchoringTests.cs | 9 +- .../Bookmarks/BookmarkContextMenuTests.cs | 18 +- ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs | 45 ++--- .../Bookmarks/BookmarkNavigationViewTests.cs | 28 ++-- .../Commands/CommandLineArgumentsTests.cs | 27 ++- ILSpy.Tests/Commands/HelpCommandTests.cs | 9 +- .../ContextMenus/DecompileInNewViewTests.cs | 55 +++--- ILSpy.Tests/DecompileSample.cs | 124 ++++++++++++++ ILSpy.Tests/Docking/FrozenTabRefreshTests.cs | 13 +- .../Docking/PreviewTabPromotionTests.cs | 68 +++----- ILSpy.Tests/Docking/RunInNewTabTests.cs | 11 +- ILSpy.Tests/Docking/SetActiveDockableTests.cs | 6 +- ILSpy.Tests/Editor/DecompilerViewTests.cs | 156 ++++++++---------- ILSpy.Tests/Editor/DocumentationLinkTests.cs | 5 +- ILSpy.Tests/Editor/FoldingContextMenuTests.cs | 15 +- ILSpy.Tests/Editor/ILLanguageTooltipTests.cs | 57 +++---- ILSpy.Tests/Editor/MarginCursorTests.cs | 11 +- ILSpy.Tests/Editor/MarginPointerTests.cs | 6 +- .../Editor/MemberReferenceHighlightTests.cs | 21 +-- ILSpy.Tests/Editor/ReferenceClickTests.cs | 6 +- ILSpy.Tests/Editor/TabFocusNavigationTests.cs | 5 +- .../BrowseBackForwardCommandTests.cs | 16 +- ILSpy.Tests/Navigation/NavigationTests.cs | 50 +++--- .../Options/DisplaySettingsReactionTests.cs | 11 +- ILSpy.Tests/Options/OptionsTabTests.cs | 24 +-- 29 files changed, 430 insertions(+), 451 deletions(-) create mode 100644 ILSpy.Tests/DecompileSample.cs diff --git a/ILSpy.Tests/Analyzers/AnalyzerResultHighlightTests.cs b/ILSpy.Tests/Analyzers/AnalyzerResultHighlightTests.cs index d0fcc0f83e3..d63ba4b3f0d 100644 --- a/ILSpy.Tests/Analyzers/AnalyzerResultHighlightTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzerResultHighlightTests.cs @@ -59,29 +59,24 @@ public async Task ActivateItem_On_Analyzer_Result_Sets_HighlightedReference_To_S var (_, vm) = await TestHarness.BootAsync(); - var coreLibName = typeof(object).Assembly.GetName().Name!; - var stringNode = vm.AssemblyTreeModel.FindNode( - coreLibName, "System", "System.String"); - stringNode.IsExpanded = true; - var toStringNode = stringNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "ToString"); - var concatNode = stringNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Concat"); - // Materialise the decompiler tab by driving a real navigation first; without this // DockWorkspace.ActiveDecompilerTab is null and the highlight path has no sink. - vm.AssemblyTreeModel.SelectNode(stringNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); - TestCapture.Step("string-decompiled"); + var (sampleNode, _) = await vm.ShowSampleAsync(typeof(DecompileSample)); + sampleNode.IsExpanded = true; + var describeNode = sampleNode.Children.OfType() + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe)); + var twiceNode = sampleNode.Children.OfType() + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)); + TestCapture.Step("sample-decompiled"); var decompTab = vm.DockWorkspace.ActiveDecompilerTab; Assert.That(decompTab, Is.Not.Null, "selecting a type must materialise the decompiler tab"); decompTab!.HighlightedReference = null; - var resultNode = new AnalyzedMethodTreeNode(toStringNode.MethodDefinition, source: concatNode.MethodDefinition); + var resultNode = new AnalyzedMethodTreeNode(describeNode.MethodDefinition, source: twiceNode.MethodDefinition); resultNode.ActivateItem(new StubArgs()); TestCapture.Step("highlighted-source-member"); - ((object?)decompTab.HighlightedReference).Should().BeSameAs(concatNode.MethodDefinition, + ((object?)decompTab.HighlightedReference).Should().BeSameAs(twiceNode.MethodDefinition, "NavigateToReferenceEventArgs.Source (= SourceMember) must land in HighlightedReference so the editor view can paint local-reference marks"); } @@ -95,27 +90,22 @@ public async Task ActivateItem_Without_SourceMember_Leaves_HighlightedReference_ var (_, vm) = await TestHarness.BootAsync(); - var coreLibName = typeof(object).Assembly.GetName().Name!; - var stringNode = vm.AssemblyTreeModel.FindNode( - coreLibName, "System", "System.String"); - stringNode.IsExpanded = true; - var lengthProp = stringNode.Children.OfType() - .First(p => p.PropertyDefinition.Name == "Length"); - // Same materialisation as the first test. - vm.AssemblyTreeModel.SelectNode(stringNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); - TestCapture.Step("string-decompiled"); + var (sampleNode, _) = await vm.ShowSampleAsync(typeof(DecompileSample)); + sampleNode.IsExpanded = true; + var nameProp = sampleNode.Children.OfType() + .First(p => p.PropertyDefinition.Name == nameof(DecompileSample.Name)); + TestCapture.Step("sample-decompiled"); var decompTab = vm.DockWorkspace.ActiveDecompilerTab!; - decompTab.HighlightedReference = lengthProp.PropertyDefinition; + decompTab.HighlightedReference = nameProp.PropertyDefinition; var topLevel = new AnalyzedMethodTreeNode( - stringNode.Children.OfType().First().MethodDefinition, + sampleNode.Children.OfType().First().MethodDefinition, source: null); topLevel.ActivateItem(new StubArgs()); TestCapture.Step("activated-top-level-entity"); - ((object?)decompTab.HighlightedReference).Should().BeSameAs(lengthProp.PropertyDefinition, + ((object?)decompTab.HighlightedReference).Should().BeSameAs(nameProp.PropertyDefinition, "top-level analysed-entity rows have no SourceMember and must not overwrite the active highlight"); } diff --git a/ILSpy.Tests/AssemblyList/AssemblyListResetTests.cs b/ILSpy.Tests/AssemblyList/AssemblyListResetTests.cs index 42cd7ce19da..f986f724306 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyListResetTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyListResetTests.cs @@ -40,14 +40,13 @@ public class AssemblyListResetTests { static async Task DecompileAMethodAsync(ViewModels.MainWindowViewModel vm) { - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); typeNode.IsExpanded = true; var method = typeNode.Children.OfType() - .Single(m => m.MethodDefinition.Name == "AsEnumerable"); + .Single(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe)); vm.AssemblyTreeModel.SelectNode(method); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - tab.Text.Should().Contain("AsEnumerable", "the pane must hold something before it can go stale"); + tab.Text.Should().Contain(nameof(DecompileSample.Describe), "the pane must hold something before it can go stale"); return method; } @@ -70,7 +69,7 @@ public async Task Clearing_The_Assembly_List_Empties_The_Decompiled_View() vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Should().BeEmpty(); var tab = vm.DockWorkspace.ActiveDecompilerTab; if (tab != null) - tab.Text.Should().NotContain("AsEnumerable", "the assembly it came from is gone"); + tab.Text.Should().NotContain(nameof(DecompileSample.Describe), "the assembly it came from is gone"); } [AvaloniaTest] @@ -82,13 +81,12 @@ public async Task Sorting_The_Assembly_List_Keeps_The_Navigation_History() // Arrange - boot, decompile two members so there is a back entry to lose. var (_, vm) = await TestHarness.BootAsync(3); - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); typeNode.IsExpanded = true; var first = typeNode.Children.OfType() - .Single(m => m.MethodDefinition.Name == "AsEnumerable"); + .Single(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe)); var second = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Empty"); + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)); vm.AssemblyTreeModel.SelectNode(first); await vm.DockWorkspace.WaitForDecompiledTextAsync(); // NavigationHistory collapses selections inside 0.5s into one entry. diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs index 3d3a2027c2d..13f302f465d 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs @@ -873,8 +873,11 @@ public async Task Delete_Key_On_Selected_Assembly_Unloads_It_From_The_List() // expect to survive, select the sacrificial one, focus the grid. var (window, vm) = await TestHarness.BootAsync(3); - var sacrificialName = typeof(System.Linq.Enumerable).Assembly.GetName().Name!; - var survivorName = typeof(object).Assembly.GetName().Name!; + // Three fixtures, the middle one to be deleted, so that whichever neighbour the tree + // re-selects afterwards is another small fixture. + var survivorName = (await vm.OpenFixtureAsync("Alpha")).ShortName; + var sacrificialName = (await vm.OpenFixtureAsync("Bravo")).ShortName; + await vm.OpenFixtureAsync("Charlie"); var sacrificialNode = vm.AssemblyTreeModel.FindNode(sacrificialName); vm.AssemblyTreeModel.SelectNode(sacrificialNode); await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, sacrificialNode)); @@ -925,7 +928,11 @@ public async Task Delete_Reselects_The_Next_Node_So_Repeated_Delete_Keeps_Workin var (window, vm) = await TestHarness.BootAsync(3); var model = vm.AssemblyTreeModel; - var firstAssembly = model.FindNode("System.Linq"); + // Three fixtures, the middle one to be deleted first, so that whichever neighbour the + // tree re-selects after each Delete is another small fixture. + await vm.OpenFixtureAsync("Alpha"); + var firstAssembly = model.FindNode((await vm.OpenFixtureAsync("Bravo")).ShortName); + await vm.OpenFixtureAsync("Charlie"); model.SelectNode(firstAssembly); await Waiters.WaitForAsync(() => ReferenceEquals(model.SelectedItem, firstAssembly)); @@ -1100,10 +1107,11 @@ public async Task Type_Ahead_Jumps_To_The_Node_Matching_The_Typed_Text() grid.Focus(); Dispatcher.UIThread.RunJobs(); - var target = model.FindNode("System.Linq"); + // Two fixtures without a common prefix, so typing the target's name never lands on the other. + var target = model.FindNode((await vm.OpenFixtureAsync("Bravo")).ShortName); var targetText = target.Text?.ToString()!; // Start the selection on a different assembly so the jump is observable. - var other = model.FindNode(typeof(object).Assembly.GetName().Name!); + var other = model.FindNode((await vm.OpenFixtureAsync("Alpha")).ShortName); model.SelectNode(other); await Waiters.WaitForAsync(() => ReferenceEquals(model.SelectedItem, other)); diff --git a/ILSpy.Tests/AssemblyList/SwitchListAndDeleteAllTests.cs b/ILSpy.Tests/AssemblyList/SwitchListAndDeleteAllTests.cs index 03583ae9788..caf6f2236ff 100644 --- a/ILSpy.Tests/AssemblyList/SwitchListAndDeleteAllTests.cs +++ b/ILSpy.Tests/AssemblyList/SwitchListAndDeleteAllTests.cs @@ -46,15 +46,14 @@ public class SwitchListAndDeleteAllTests { static async Task GiveTheHistorySomethingToHoldAsync(ViewModels.MainWindowViewModel vm) { - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); typeNode.IsExpanded = true; var methods = typeNode.Children.OfType().ToList(); - vm.AssemblyTreeModel.SelectNode(methods.First(m => m.MethodDefinition.Name == "AsEnumerable")); + vm.AssemblyTreeModel.SelectNode(methods.First(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe))); await vm.DockWorkspace.WaitForDecompiledTextAsync(); // NavigationHistory collapses selections inside 0.5s into one entry. await Task.Delay(600); - vm.AssemblyTreeModel.SelectNode(methods.First(m => m.MethodDefinition.Name == "Empty")); + vm.AssemblyTreeModel.SelectNode(methods.First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice))); await vm.DockWorkspace.WaitForDecompiledTextAsync(); vm.DockWorkspace.BackHistory.Should().NotBeEmpty("the test needs history to lose"); } diff --git a/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs b/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs index 4e1a7a5aadc..c0bfc2e70b5 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs @@ -30,7 +30,6 @@ using ICSharpCode.ILSpy.Bookmarks; using ICSharpCode.ILSpy.Languages; using ICSharpCode.ILSpy.TextView; -using ICSharpCode.ILSpy.TreeNodes; using NUnit.Framework; @@ -44,12 +43,12 @@ public class BookmarkAnchoringTests [AvaloniaTest] public async Task Decompiled_type_offers_both_body_and_token_anchors() { - var (_, vm) = await TestHarness.BootAsync(3); + var (_, vm) = await TestHarness.BootAsync(); var csharp = AppComposition.Current.GetExport().Languages.First(l => l.Name == "C#"); - var asm = vm.AssemblyTreeModel.FindNode("System.Linq"); - var typeSystem = asm!.LoadedAssembly.GetTypeSystemOrNull(); - var type = typeSystem!.MainModule.TypeDefinitions.First(t => t.FullName == "System.Linq.Enumerable"); + var asm = await vm.OpenAssemblyAsync(typeof(DecompileSample).Assembly.Location); + var typeSystem = asm.GetTypeSystemOrNull(); + var type = typeSystem!.MainModule.TypeDefinitions.First(t => t.FullName == typeof(DecompileSample).FullName); var output = new AvaloniaEditTextOutput(); csharp.DecompileType(type, output, new DecompilationOptions(new DecompilerSettings())); diff --git a/ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs b/ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs index 23429a3fbd2..a63c2d248f6 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs @@ -48,10 +48,7 @@ public async Task Toggle_Bookmark_Entry_Acts_On_The_Right_Clicked_Line_Not_The_C var manager = AppComposition.Current.GetExport(); manager.Clear(); - var coreLibName = typeof(object).Assembly.GetName().Name!; - var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); - vm.AssemblyTreeModel.SelectNode(typeNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(typeof(DecompileSample)); var view = await window.WaitForComponent(); await Waiters.WaitForIdleAsync(); @@ -74,7 +71,7 @@ public async Task Toggle_Bookmark_Entry_Acts_On_The_Right_Clicked_Line_Not_The_C manager.Bookmarks.Should().ContainSingle(); view.GetLineForBookmark(manager.Bookmarks[0]).Should().Be(clickedLine); - manager.Bookmarks[0].LocationNodeName.Should().Be("System.Object"); + manager.Bookmarks[0].LocationNodeName.Should().Be(typeof(DecompileSample).FullName); } [AvaloniaTest] @@ -84,10 +81,7 @@ public async Task Navigation_Does_Not_Fall_Back_To_Entity_When_Saved_Tree_Path_I var manager = AppComposition.Current.GetExport(); manager.Clear(); - var coreLibName = typeof(object).Assembly.GetName().Name!; - var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); - vm.AssemblyTreeModel.SelectNode(objectNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(typeof(DecompileSample)); var view = await window.WaitForComponent(); await Waiters.WaitForIdleAsync(); @@ -105,12 +99,12 @@ public async Task Navigation_Does_Not_Fall_Back_To_Entity_When_Saved_Tree_Path_I bookmark.ViewState.Should().NotBeNull(); bookmark.ViewState = bookmark.ViewState! with { SelectedTreeNodePath = new[] { "Missing assembly", "Missing type" } }; - var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); - vm.AssemblyTreeModel.SelectNode(stringNode); + var otherNode = await vm.OpenSampleAsync(typeof(DecompileSampleOther)); + vm.AssemblyTreeModel.SelectNode(otherNode); await AppComposition.Current.GetExport().NavigateToAsync(bookmark); - ((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeSameAs(stringNode); + ((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeSameAs(otherNode); } [AvaloniaTest] diff --git a/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs index 64f2dedb678..b4d7edce76c 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs @@ -51,10 +51,7 @@ public async Task Clicking_Bookmark_Gutter_Toggles_The_Visible_Line() var manager = AppComposition.Current.GetExport(); manager.Clear(); - var coreLibName = typeof(object).Assembly.GetName().Name!; - var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); - vm.AssemblyTreeModel.SelectNode(typeNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(typeof(DecompileSample)); var view = await window.WaitForComponent(); await Waiters.WaitForIdleAsync(); @@ -97,10 +94,7 @@ public async Task RightClicking_Bookmark_Gutter_Does_Not_Toggle() var manager = AppComposition.Current.GetExport(); manager.Clear(); - var coreLibName = typeof(object).Assembly.GetName().Name!; - var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); - vm.AssemblyTreeModel.SelectNode(typeNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(typeof(DecompileSample)); var view = await window.WaitForComponent(); await Waiters.WaitForIdleAsync(); @@ -136,10 +130,7 @@ public async Task Removing_A_Bookmark_From_The_Gutter_Hides_Its_Hover_Preview_Un var manager = AppComposition.Current.GetExport(); manager.Clear(); - var coreLibName = typeof(object).Assembly.GetName().Name!; - var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); - vm.AssemblyTreeModel.SelectNode(typeNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(typeof(DecompileSample)); var view = await window.WaitForComponent(); await Waiters.WaitForIdleAsync(); @@ -211,24 +202,20 @@ public async Task Gutter_Glyph_Map_Rebuilds_When_The_Displayed_Document_Changes( var (window, vm) = await TestHarness.BootAsync(3); var manager = AppComposition.Current.GetExport(); manager.Clear(); - var coreLibName = typeof(object).Assembly.GetName().Name!; - - async Task Show(string typeName) + async Task Show(System.Type sample) { - var node = vm.AssemblyTreeModel.FindNode(coreLibName, "System", typeName); - vm.AssemblyTreeModel.SelectNode(node); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(sample); var shown = await window.WaitForComponent(); await Waiters.WaitForIdleAsync(); return shown; } - var view = await Show("System.Object"); + var view = await Show(typeof(DecompileSample)); var margin = view.Editor.TextArea.LeftMargins.OfType().Single(); var toggle = AppComposition.Current.GetExport().GetEntry(nameof(Resources.BookmarkToggle)); - // A body (IL-offset) anchor, so it only resolves inside System.Object -- a token/line anchor on - // the type would also resolve in System.String, which merely references System.Object. + // A body (IL-offset) anchor, so it only resolves inside the first sample type -- a token/line + // anchor on the type would also resolve in a document that merely references it. Bookmark? bookmark = null; foreach (int candidate in Enumerable.Range(1, view.Editor.Document.LineCount).Where(view.CanToggleBookmarkAtLine)) { @@ -244,18 +231,18 @@ async Task Show(string typeName) } manager.Clear(); } - bookmark.Should().NotBeNull("System.Object has a method-body line to anchor to"); - int objectLine = view.GetLineForBookmark(bookmark!)!.Value; - margin.GlyphLinesForTest().Keys.Should().Contain(objectLine, "the gutter shows the bookmark on its own document"); + bookmark.Should().NotBeNull("the sample type has a method-body line to anchor to"); + int sampleLine = view.GetLineForBookmark(bookmark!)!.Value; + margin.GlyphLinesForTest().Keys.Should().Contain(sampleLine, "the gutter shows the bookmark on its own document"); // Navigate to another type. The preview tab reuses the same view (and gutter), only swapping the - // document text, so the gutter must drop the stale glyph: System.Object's bookmark is not here. - var stringView = await Show("System.String"); - stringView.Should().BeSameAs(view, "the preview tab reuses the view, so this exercises the stale cache"); - margin.GlyphLinesForTest().Should().BeEmpty("the gutter must rebuild for System.String, where the bookmark does not resolve"); + // document text, so the gutter must drop the stale glyph: the first type's bookmark is not here. + var otherView = await Show(typeof(DecompileSampleOther)); + otherView.Should().BeSameAs(view, "the preview tab reuses the view, so this exercises the stale cache"); + margin.GlyphLinesForTest().Should().BeEmpty("the gutter must rebuild for the other type, where the bookmark does not resolve"); // Returning to the bookmarked document must surface its glyph again. - await Show("System.Object"); + await Show(typeof(DecompileSample)); margin.GlyphLinesForTest().Keys.Should().Contain(view.GetLineForBookmark(bookmark!)!.Value, "returning to the bookmarked document must show its glyph again"); } diff --git a/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs b/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs index 74dc304adee..4fa947afb3e 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs @@ -52,11 +52,8 @@ public async Task Navigating_From_NonDecompiler_Content_Scrolls_To_And_Highlight var manager = AppComposition.Current.GetExport(); manager.Clear(); - // Decompile System.Object and bookmark a line below the top so a scroll is observable. - var coreLibName = typeof(object).Assembly.GetName().Name!; - var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); - vm.AssemblyTreeModel.SelectNode(objectNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + // Decompile the sample type and bookmark a line below the top so a scroll is observable. + await vm.ShowSampleAsync(typeof(DecompileSample)); var view = await window.WaitForComponent(); await Waiters.WaitForIdleAsync(); @@ -78,8 +75,9 @@ public async Task Navigating_From_NonDecompiler_Content_Scrolls_To_And_Highlight var bookmark = manager.Bookmarks[0]; // Switch the active content to a metadata table: now there is no active decompiler tab. - var typeDefNode = vm.AssemblyTreeModel.FindCoreLib() - .GetChild() + // The test assembly ships a PDB, so its "Debug Metadata" sibling has to be skipped. + var typeDefNode = vm.AssemblyTreeModel.FindNode(typeof(DecompileSample).Assembly.GetName().Name!) + .GetChild(n => (string)n.Text == Resources.Metadata) .GetChild() .GetChild(); vm.AssemblyTreeModel.SelectNode(typeDefNode); @@ -119,12 +117,8 @@ public async Task Navigating_To_Bookmark_With_A_Frozen_Active_Tab_Opens_And_Posi var (window, vm) = await TestHarness.BootAsync(3); var manager = AppComposition.Current.GetExport(); manager.Clear(); - var coreLibName = typeof(object).Assembly.GetName().Name!; - - // Bookmark a line in System.String. - var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); - vm.AssemblyTreeModel.SelectNode(stringNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + // Bookmark a line in the sample type. + await vm.ShowSampleAsync(typeof(DecompileSample)); var view = await window.WaitForComponent(); await PumpLayoutAsync(); @@ -142,12 +136,10 @@ public async Task Navigating_To_Bookmark_With_A_Frozen_Active_Tab_Opens_And_Posi var bookmark = manager.Bookmarks.Should().ContainSingle().Subject; // Show a different node, then freeze that tab so the next navigation must spawn a fresh preview. - var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); - vm.AssemblyTreeModel.SelectNode(objectNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(typeof(DecompileSampleOther)); vm.DockWorkspace.FreezeCurrentTab(); - // Activate the bookmark: a fresh preview tab must decompile System.String and land on the line. + // Activate the bookmark: a fresh preview tab must decompile the sample type and land on the line. await AppComposition.Current.GetExport().NavigateToAsync(bookmark); await vm.DockWorkspace.WaitForDecompiledTextAsync(); @@ -164,7 +156,7 @@ public async Task Navigating_To_Bookmark_With_A_Frozen_Active_Tab_Opens_And_Posi var activeView = ActiveView()!; int targetLine = activeView.GetLineForBookmark(bookmark) ?? -1; - targetLine.Should().BeGreaterThan(1, "the fresh preview shows System.String with the bookmarked line below the top"); + targetLine.Should().BeGreaterThan(1, "the fresh preview shows the sample type with the bookmarked line below the top"); activeView.Editor.TextArea.Caret.Line.Should().Be(targetLine, "opening a fresh preview for a frozen-tab navigation must still scroll to the bookmark"); activeView.LastHighlightPlayedLine.Should().Be(targetLine, diff --git a/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs b/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs index 7cde3c34ed6..88260e39e39 100644 --- a/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs +++ b/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs @@ -81,10 +81,7 @@ public async Task NavigateTo_An_Id_That_Names_Nothing_Reports_What_Was_Searched( 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(); + await vm.ShowSampleAsync(typeof(DecompileSample)); vm.DockWorkspace.ActiveDecompilerTab.Should().NotBeNull("the report's preferred sink must exist"); var args = CommandLineArguments.Create(new[] { "--navigateto", "M:No.Such.Type.NoSuchMember" }); @@ -143,26 +140,27 @@ static IEnumerable ReportTabs(MainWindowViewModel vm) public async Task NavigateTo_Type_Arg_Selects_The_Matching_Type_Node() { // `-n|--navigateto T:` navigates to a type-tree-node at startup. The arg's - // content is an XML-doc-style ID string ("T:System.Linq.Enumerable") which the - // consumer resolves through the loaded assemblies and selects the matching tree node - // (which then triggers a decompile). + // content is an XML-doc-style ID string ("T:Some.Type") which the consumer resolves + // through the loaded assemblies and selects the matching tree node (which then + // triggers a decompile). - // Arrange — boot. + // Arrange — boot, and open an assembly with a small type to navigate to. var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); - var args = CommandLineArguments.Create(new[] { "--navigateto", "T:System.Linq.Enumerable" }); + string path = typeof(DecompileSample).Assembly.Location; + var args = CommandLineArguments.Create(new[] { path, "--navigateto", "T:" + typeof(DecompileSample).FullName }); // Act — apply the args. await vm.AssemblyTreeModel.HandleCommandLineArgumentsAsync(args); - // Assert — selected item is the System.Linq.Enumerable TypeTreeNode (ToString returns - // the ReflectionName; that's a stable identifier independent of the active language). + // Assert — selected item is the sample's TypeTreeNode (ToString returns the + // ReflectionName; that's a stable identifier independent of the active language). ((object?)vm.AssemblyTreeModel.SelectedItem).Should().NotBeNull(); vm.AssemblyTreeModel.SelectedItem!.GetType().Should().Be(typeof(TypeTreeNode)); - vm.AssemblyTreeModel.SelectedItem!.ToString().Should().Be("System.Linq.Enumerable"); + vm.AssemblyTreeModel.SelectedItem!.ToString().Should().Be(typeof(DecompileSample).FullName); } [AvaloniaTest] @@ -288,14 +286,15 @@ public async Task NavigateTo_Skips_A_Missing_Session_Assembly_Instead_Of_Crashin Path.GetTempPath(), "ILSpyMissing_" + Guid.NewGuid().ToString("N"), "gone.dll"); vm.AssemblyTreeModel.AssemblyList!.OpenAssembly(missingPath); - var args = CommandLineArguments.Create(new[] { "--navigateto", "T:System.Linq.Enumerable" }); + string path = typeof(DecompileSample).Assembly.Location; + var args = CommandLineArguments.Create(new[] { path, "--navigateto", "T:" + typeof(DecompileSample).FullName }); // Act — applying the args must complete without throwing despite the missing entry. await vm.AssemblyTreeModel.HandleCommandLineArgumentsAsync(args); // Assert — the missing assembly was skipped and the present target still resolved. ((object?)vm.AssemblyTreeModel.SelectedItem).Should().NotBeNull(); - vm.AssemblyTreeModel.SelectedItem!.ToString().Should().Be("System.Linq.Enumerable"); + vm.AssemblyTreeModel.SelectedItem!.ToString().Should().Be(typeof(DecompileSample).FullName); } } diff --git a/ILSpy.Tests/Commands/HelpCommandTests.cs b/ILSpy.Tests/Commands/HelpCommandTests.cs index 69766e193cd..2741d04090d 100644 --- a/ILSpy.Tests/Commands/HelpCommandTests.cs +++ b/ILSpy.Tests/Commands/HelpCommandTests.cs @@ -167,9 +167,7 @@ public async Task Decompiler_Editor_Activates_Hyperlinks_Without_Ctrl_Modifier() window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); - var node = vm.AssemblyTreeModel.FindNode("System.Linq"); - vm.AssemblyTreeModel.SelectNode(node); - var content = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + var (_, content) = await vm.ShowSampleAsync(typeof(DecompileSample)); var view = new DecompilerTextView { DataContext = content }; // Force layout so the templated TextEditor child is realised — the view's @@ -198,11 +196,10 @@ public async Task About_Page_Lands_On_Back_History_And_Round_Trips_Through_Navig var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); typeNode.IsExpanded = true; var method = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "AsEnumerable"); + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe)); vm.AssemblyTreeModel.SelectNode(method); await vm.DockWorkspace.WaitForDecompiledTextAsync(); var decompilerTab = vm.DockWorkspace.ActiveDecompilerTab!; diff --git a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs index dd2cbaa50ce..51e95ca8be7 100644 --- a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs +++ b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs @@ -50,6 +50,22 @@ namespace ICSharpCode.ILSpy.Tests; [TestFixture] public class DecompileInNewViewTests { + /// + /// Opens three small fixture assemblies and returns their top-level tree nodes, so the row + /// tests below select and open rows whose decompilation is a handful of types rather than a + /// framework assembly's. + /// + static async Task OpenFixtureRowsAsync(MainWindowViewModel vm) + { + var nodes = new AssemblyTreeNode[3]; + foreach (var (name, i) in new[] { "Alpha", "Bravo", "Charlie" }.Select((n, i) => (n, i))) + { + var loaded = await vm.OpenFixtureAsync(name); + nodes[i] = vm.AssemblyTreeModel.FindNode(loaded.ShortName); + } + return nodes; + } + [AvaloniaTest] public async Task DecompileInNewView_Entry_Is_Registered() { @@ -117,18 +133,17 @@ public async Task Clicking_DecompileInNewView_Opens_The_Right_Clicked_Method_Whi 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"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); typeNode.IsExpanded = true; var firstMethod = typeNode.Children.OfType() - .Single(m => m.MethodDefinition.Name == "AsEnumerable"); + .Single(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe)); var secondMethod = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Empty"); + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)); // A is the active document: select firstMethod and decompile it into the preview tab. vm.AssemblyTreeModel.SelectNode(firstMethod); var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - firstTab.Text.Should().Contain("AsEnumerable"); + firstTab.Text.Should().Contain(nameof(DecompileSample.Describe)); window.Capture("first-method-active"); var pane = await window.WaitForComponent(); @@ -149,10 +164,10 @@ await Waiters.WaitForAsync( // The new tab shows B... ReferenceEquals(newTab, firstTab).Should().BeFalse( "a fresh decompiler tab must be created instead of reusing the existing one"); - newTab.Text.Should().Contain("Empty"); + newTab.Text.Should().Contain(nameof(DecompileSample.Twice)); // ...the original tab still shows A (it was NOT yanked to B by the right-click — this is // the whole point: you keep A and gain B, instead of ending up with B twice)... - firstTab.Text.Should().Contain("AsEnumerable"); + firstTab.Text.Should().Contain(nameof(DecompileSample.Describe)); // ...and exactly one tab was added (A + B), not two copies of B. documents.VisibleDockables!.Count.Should().Be(initialCount + 1); window.Capture("a-kept-b-opened"); @@ -174,8 +189,9 @@ public async Task Right_Clicking_An_Unselected_Row_Does_Not_Move_The_Selection() var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); - var nodeA = vm.AssemblyTreeModel.FindNode("System.Linq"); - var nodeB = vm.AssemblyTreeModel.FindNode(TreeNavigation.CoreLibName); + var rows = await OpenFixtureRowsAsync(vm); + var nodeA = rows[0]; + var nodeB = rows[1]; // Make A the active document/selection. vm.AssemblyTreeModel.SelectNode(nodeA); @@ -185,7 +201,7 @@ public async Task Right_Clicking_An_Unselected_Row_Does_Not_Move_The_Selection() var rowB = grid.GetVisualDescendants().OfType() .FirstOrDefault(r => RowNodeEquals(r, nodeB)); - rowB.Should().NotBeNull("the top-level CoreLib row must be realised"); + rowB.Should().NotBeNull("the top-level row of the second fixture must be realised"); // Suppressing the right-press must not also suppress the context menu — it still opens // from ContextRequested (raised on release). Watch for it. @@ -220,8 +236,7 @@ public async Task Right_Clicking_A_Second_Row_Moves_The_Context_Highlight_To_It( var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); - var assemblies = vm.AssemblyTreeModel.Root!.Children.OfType().Take(3).ToArray(); - assemblies.Length.Should().BeGreaterThanOrEqualTo(3, "need three top-level rows to click"); + var assemblies = await OpenFixtureRowsAsync(vm); var nodeA = assemblies[0]; var nodeB = assemblies[1]; var nodeC = assemblies[2]; @@ -281,8 +296,9 @@ public async Task Middle_Clicking_A_Row_Opens_It_In_A_New_Tab() var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); - var nodeA = vm.AssemblyTreeModel.FindNode("System.Linq"); - var nodeB = vm.AssemblyTreeModel.FindNode(TreeNavigation.CoreLibName); + var rows = await OpenFixtureRowsAsync(vm); + var nodeA = rows[0]; + var nodeB = rows[1]; vm.AssemblyTreeModel.SelectNode(nodeA); await Waiters.WaitForIdleAsync(); @@ -315,8 +331,9 @@ public async Task Opening_A_Node_In_A_New_Tab_Syncs_The_Tree_Selection_To_It() var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); - var nodeA = vm.AssemblyTreeModel.FindNode("System.Linq"); - var nodeB = vm.AssemblyTreeModel.FindNode(TreeNavigation.CoreLibName); + var rows = await OpenFixtureRowsAsync(vm); + var nodeA = rows[0]; + var nodeB = rows[1]; vm.AssemblyTreeModel.SelectNode(nodeA); await vm.DockWorkspace.WaitForDecompiledTextAsync(); @@ -351,8 +368,7 @@ public async Task Activating_A_Single_Node_Tab_Syncs_The_Tree_After_A_Multi_Node var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); - var assemblies = vm.AssemblyTreeModel.Root!.Children.OfType().Take(3).ToArray(); - assemblies.Length.Should().BeGreaterThanOrEqualTo(3, "need three top-level rows"); + var assemblies = await OpenFixtureRowsAsync(vm); var nodeA = assemblies[0]; var nodeB = assemblies[1]; var nodeC = assemblies[2]; @@ -395,8 +411,7 @@ public async Task Activating_A_Multi_Node_Tab_Restores_Its_Multi_Selection_In_Th var grid = await pane.WaitForComponent(); var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; - var assemblies = vm.AssemblyTreeModel.Root!.Children.OfType().Take(3).ToArray(); - assemblies.Length.Should().BeGreaterThanOrEqualTo(3, "need three top-level rows"); + var assemblies = await OpenFixtureRowsAsync(vm); var nodeA = assemblies[0]; var nodeB = assemblies[1]; var nodeC = assemblies[2]; diff --git a/ILSpy.Tests/DecompileSample.cs b/ILSpy.Tests/DecompileSample.cs new file mode 100644 index 00000000000..14a122e6372 --- /dev/null +++ b/ILSpy.Tests/DecompileSample.cs @@ -0,0 +1,124 @@ +// 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; +using System.Threading.Tasks; + +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; +using ICSharpCode.ILSpy.ViewModels; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// Base of . Being the first entity the decompiled sample refers +/// to, it is where "follow the first link in the document" tests land, which keeps that +/// navigation inside this assembly instead of decompiling a framework type. +/// +public class DecompileSampleBase +{ +} + +/// +/// The type UI tests decompile when they need a real document with real members but nothing +/// specific to a framework type: a constant, a field, a property, an event, and two methods with +/// several statements each, one calling the other (so analyzers find a use). Decompiling it is +/// an order of magnitude cheaper than System.String or System.Linq.Enumerable, which is what the +/// tests used before and what made them the slowest in the suite. +/// Open it with . +/// +public class DecompileSample : DecompileSampleBase +{ + public const int Limit = 3; + + public int Count; + + public string Name { get; set; } = ""; + + public event EventHandler? Changed; + + public string Describe() + { + Count++; + if (Count > Limit) + Count = 0; + Changed?.Invoke(this, EventArgs.Empty); + return Name + Count; + } + + public int Twice(int value, int extra) + { + string text = Describe(); + int result = value * 2; + result += extra; + return result + text.Length; + } + + public void Reset() + { + Count = 0; + Name = ""; + } +} + +/// +/// A second, unrelated sample type for tests that need "another document" from the same +/// assembly, e.g. to check that a bookmark anchored in does not +/// resolve here. +/// +public class DecompileSampleOther +{ + public int Value; + + public int Next() + { + Value++; + return Value; + } +} + +public static class DecompileSampleExtensions +{ + /// + /// Adds the test assembly to the assembly list (a no-op when it is already there) and returns + /// the tree node of , one of the sample types above. Unlike the Open + /// command, this leaves the tree selection alone, so a test can arrange its own selection + /// state around the call. + /// + public static async Task OpenSampleAsync(this MainWindowViewModel vm, Type sample) + { + ArgumentNullException.ThrowIfNull(vm); + ArgumentNullException.ThrowIfNull(sample); + var loaded = vm.AssemblyTreeModel.AssemblyList!.OpenAssembly(sample.Assembly.Location); + await loaded.GetLoadResultAsync(); + return vm.AssemblyTreeModel.FindNode( + sample.Assembly.GetName().Name!, sample.Namespace!, sample.FullName!); + } + + /// + /// Opens the test assembly, selects in the tree and waits for its + /// decompiled text to land in the active decompiler tab. + /// + public static async Task<(TypeTreeNode Node, DecompilerTabPageModel Tab)> ShowSampleAsync(this MainWindowViewModel vm, Type sample) + { + var node = await vm.OpenSampleAsync(sample); + vm.AssemblyTreeModel.SelectNode(node); + var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + return (node, tab); + } +} diff --git a/ILSpy.Tests/Docking/FrozenTabRefreshTests.cs b/ILSpy.Tests/Docking/FrozenTabRefreshTests.cs index 41c9b975e6c..c6ae4db77c5 100644 --- a/ILSpy.Tests/Docking/FrozenTabRefreshTests.cs +++ b/ILSpy.Tests/Docking/FrozenTabRefreshTests.cs @@ -46,20 +46,19 @@ namespace ICSharpCode.ILSpy.Tests; public class FrozenTabRefreshTests { /// - /// Boots the app, decompiles Enumerable.Select into the preview tab, freezes it, then - /// routes Enumerable.Where into a fresh preview tab. Returns both tab models settled. + /// Boots the app, decompiles one sample method into the preview tab, freezes it, then + /// routes another sample method into a fresh preview tab. Returns both tab models settled. /// static async Task<(DecompilerTabPageModel Frozen, DecompilerTabPageModel Preview, MainWindowViewModel Vm)> BootWithFrozenAndPreviewTabsAsync() { - var (_, vm) = await TestHarness.BootAsync(3); + var (_, vm) = await TestHarness.BootAsync(); - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); typeNode.IsExpanded = true; var selectNode = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Select" && m.MethodDefinition.Parameters.Count == 2); + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe)); var whereNode = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Where" && m.MethodDefinition.Parameters.Count == 2); + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)); vm.AssemblyTreeModel.SelectNode(selectNode); vm.DockWorkspace.SettleSelection(); diff --git a/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs b/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs index b81052f826a..1645c7e05e6 100644 --- a/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs +++ b/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs @@ -62,8 +62,7 @@ public async Task Carve_Out_Tabs_Are_Born_Frozen() // by subsequent tree-node selections, so they're born frozen (IsPreview=false). var (_, vm) = await TestHarness.BootAsync(3); - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.DockWorkspace.OpenNodeInNewTab(typeNode); TestCapture.Step("carve-out-tab-opened"); @@ -88,11 +87,10 @@ public async Task FreezeCurrentTab_Flips_IsPreview_Without_Spawning_A_New_Tab() previousMainTab.IsPreview.Should().BeTrue("baseline"); // Load content into MainTab so the freeze has something meaningful to keep. - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.AssemblyTreeModel.SelectNode(typeNode); vm.DockWorkspace.SettleSelection(); - TestCapture.Step("enumerable-selected"); + TestCapture.Step("sample-selected"); ReferenceEquals(previousMainTab.SourceNode, typeNode).Should().BeTrue( "baseline: tree selection populated MainTab with the chosen node"); @@ -128,8 +126,7 @@ public async Task Selecting_A_Different_Node_After_Freeze_Opens_A_New_Preview_Ta var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; // Phase 1: load type A into MainTab. - var typeA = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeA = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.AssemblyTreeModel.SelectNode(typeA); vm.DockWorkspace.SettleSelection(); TestCapture.Step("type-a-selected"); @@ -145,8 +142,7 @@ public async Task Selecting_A_Different_Node_After_Freeze_Opens_A_New_Preview_Ta // Phase 3: select a different type — NOW a new preview tab should spawn AND // receive the new content. - var typeB = vm.AssemblyTreeModel.FindNode( - "System.Private.Uri", "System", "System.Uri"); + var typeB = await vm.OpenSampleAsync(typeof(DecompileSampleOther)); vm.AssemblyTreeModel.SelectNode(typeB); vm.DockWorkspace.SettleSelection(); TestCapture.Step("type-b-opens-new-preview"); @@ -222,8 +218,7 @@ public async Task Freeze_Button_Fits_Inside_The_Tab_When_Close_Button_Is_Visible var (window, vm) = await TestHarness.BootAsync(3); // Open a carve-out so close buttons become visible on both tabs. - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.DockWorkspace.OpenNodeInNewTab(typeNode); TestCapture.Step("carve-out-tab-opened"); @@ -294,8 +289,7 @@ public async Task Freeze_Button_Fits_Inside_The_Tab_Even_With_A_Long_Title() var (window, vm) = await TestHarness.BootAsync(3); // Open carve-out so close button is visible. - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.DockWorkspace.OpenNodeInNewTab(typeNode); TestCapture.Step("carve-out-tab-opened"); @@ -401,8 +395,7 @@ public async Task Freeze_Entry_Appears_In_Tab_Context_Menu_Only_When_Preview() // The right-click context-menu Freeze entry must be visible on the preview MainTab // and hidden (its IsVisible flips) on frozen carve-out tabs. var (window, vm) = await TestHarness.BootAsync(3); - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.DockWorkspace.OpenNodeInNewTab(typeNode); TestCapture.Step("carve-out-tab-opened"); @@ -436,8 +429,7 @@ public async Task Frozen_Carve_Out_Tab_Header_Renders_Upright() var (window, vm) = await TestHarness.BootAsync(3); // Open a carve-out tab via the production code path. - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.DockWorkspace.OpenNodeInNewTab(typeNode); TestCapture.Step("carve-out-tab-opened"); @@ -513,8 +505,7 @@ public async Task Freeze_Button_Inherits_The_Close_Button_Theme() // Open a carve-out so the close button is visible alongside the freeze (single-tab // scenario hides the close button). - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.DockWorkspace.OpenNodeInNewTab(typeNode); TestCapture.Step("carve-out-tab-opened"); @@ -548,8 +539,7 @@ public async Task Close_Button_Has_A_Ctrl_W_Tooltip() var (window, vm) = await TestHarness.BootAsync(3); // Open a carve-out so the close button is realised (single-tab scenario hides it). - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.DockWorkspace.OpenNodeInNewTab(typeNode); TestCapture.Step("carve-out-tab-opened"); @@ -580,16 +570,14 @@ public async Task Ctrl_W_Closes_The_Active_Document_And_Reforges_The_One() var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; var docs = vm.DockWorkspace.Documents!; - var typeA = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeA = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.AssemblyTreeModel.SelectNode(typeA); vm.DockWorkspace.SettleSelection(); var theOne = factory.MainTab!; theOne.IsPreview.Should().BeTrue("precondition: the One starts as the preview"); // A frozen carve-out keeps the dock non-empty so the One isn't the last tab when closed. - var typeC = vm.AssemblyTreeModel.FindNode( - "System.Private.Uri", "System", "System.Uri"); + var typeC = await vm.OpenSampleAsync(typeof(DecompileSampleOther)); vm.DockWorkspace.OpenNodeInNewTab(typeC); var frozen = docs.VisibleDockables!.OfType().Last(t => t.SourceNode == typeC); @@ -600,8 +588,7 @@ public async Task Ctrl_W_Closes_The_Active_Document_And_Reforges_The_One() docs.VisibleDockables!.Should().Contain(frozen, "the frozen carve-out must survive closing the One"); // The next selection forges a brand-new One (the closed one is not resurrected). - var typeD = vm.AssemblyTreeModel.FindNode( - "System.Private.Uri", "System", "System.UriBuilder"); + var typeD = await vm.OpenSampleAsync(typeof(DecompileSampleBase)); vm.AssemblyTreeModel.SelectNode(typeD); vm.DockWorkspace.SettleSelection(); var freshOne = factory.MainTab; @@ -632,8 +619,7 @@ public async Task Tree_Selection_While_Frozen_Tab_Active_Reuses_The_One() theOne.IsPreview.Should().BeTrue("baseline: the One is a preview tab"); // Open a carve-out (born frozen) and make it the active dockable. - var typeA = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeA = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.DockWorkspace.OpenNodeInNewTab(typeA); var carveOut = vm.DockWorkspace.Documents!.VisibleDockables! .OfType() @@ -647,8 +633,7 @@ public async Task Tree_Selection_While_Frozen_Tab_Active_Reuses_The_One() var tabCountBefore = vm.DockWorkspace.Documents!.VisibleDockables!.Count; // Select a different tree node while the frozen carve-out is active. - var typeB = vm.AssemblyTreeModel.FindNode( - "System.Private.Uri", "System", "System.Uri"); + var typeB = await vm.OpenSampleAsync(typeof(DecompileSampleOther)); vm.AssemblyTreeModel.SelectNode(typeB); vm.DockWorkspace.SettleSelection(); TestCapture.Step("type-b-reuses-the-one"); @@ -676,8 +661,7 @@ public async Task The_Preview_Is_Always_At_Index_0() var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; var docs = vm.DockWorkspace.Documents!; - var typeA = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeA = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.AssemblyTreeModel.SelectNode(typeA); vm.DockWorkspace.SettleSelection(); docs.VisibleDockables!.IndexOf(factory.MainTab!).Should().Be(0, @@ -686,8 +670,7 @@ public async Task The_Preview_Is_Always_At_Index_0() // Freeze it, then select a different node -> a fresh One must appear at index 0. vm.DockWorkspace.FreezeCurrentTab(); var frozen = factory.MainTab!; - var typeB = vm.AssemblyTreeModel.FindNode( - "System.Private.Uri", "System", "System.Uri"); + var typeB = await vm.OpenSampleAsync(typeof(DecompileSampleOther)); vm.AssemblyTreeModel.SelectNode(typeB); vm.DockWorkspace.SettleSelection(); @@ -709,14 +692,12 @@ public async Task Dragging_The_Preview_Freezes_It() var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; var docs = vm.DockWorkspace.Documents!; - var typeA = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeA = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.AssemblyTreeModel.SelectNode(typeA); vm.DockWorkspace.SettleSelection(); var theOne = factory.MainTab!; theOne.IsPreview.Should().BeTrue("precondition: the One starts as the preview"); - var typeC = vm.AssemblyTreeModel.FindNode( - "System.Private.Uri", "System", "System.Uri"); + var typeC = await vm.OpenSampleAsync(typeof(DecompileSampleOther)); vm.DockWorkspace.OpenNodeInNewTab(typeC); var frozen = docs.VisibleDockables!.OfType().Last(t => t.SourceNode == typeC); docs.VisibleDockables!.IndexOf(theOne).Should().Be(0, "precondition: the One is at index 0"); @@ -739,8 +720,7 @@ public async Task Floating_The_Preview_Out_Freezes_It() var (_, vm) = await TestHarness.BootAsync(3); var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; - var typeA = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeA = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.AssemblyTreeModel.SelectNode(typeA); vm.DockWorkspace.SettleSelection(); var theOne = factory.MainTab!; @@ -761,13 +741,11 @@ public async Task Frozen_Tab_Cannot_Be_Reordered_Before_The_Preview() var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; var docs = vm.DockWorkspace.Documents!; - var typeA = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeA = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.AssemblyTreeModel.SelectNode(typeA); vm.DockWorkspace.SettleSelection(); var theOne = factory.MainTab!; - var typeC = vm.AssemblyTreeModel.FindNode( - "System.Private.Uri", "System", "System.Uri"); + var typeC = await vm.OpenSampleAsync(typeof(DecompileSampleOther)); vm.DockWorkspace.OpenNodeInNewTab(typeC); var frozen = docs.VisibleDockables!.OfType().Last(t => t.SourceNode == typeC); diff --git a/ILSpy.Tests/Docking/RunInNewTabTests.cs b/ILSpy.Tests/Docking/RunInNewTabTests.cs index e1cae574bf0..0cbae7105b7 100644 --- a/ILSpy.Tests/Docking/RunInNewTabTests.cs +++ b/ILSpy.Tests/Docking/RunInNewTabTests.cs @@ -27,7 +27,6 @@ using AwesomeAssertions; using ICSharpCode.ILSpy.TextView; -using ICSharpCode.ILSpy.TreeNodes; using ICSharpCode.ILSpy.ViewModels; using NUnit.Framework; @@ -69,14 +68,8 @@ public async Task A_Long_Op_In_A_New_Tab_Is_Not_Cancelled_By_Tree_Navigation() dock.Documents!.VisibleDockables!.OfType().Count() .Should().BeGreaterThan(tabsBefore, "the long op opens its own tab"); - // Navigate while the op is running: select a node -> the preview tab decompiles. A C# - // namespace node decompiles to just its "// Some.Name.Space" comment line, which keeps - // this inside the headless decompile-wait budget on slow CI runners (a full type decompile - // would not). - var navNode = vm.AssemblyTreeModel.FindNode( - TreeNavigation.CoreLibName, "System.Runtime.Versioning"); - vm.AssemblyTreeModel.SelectNode(navNode); - await dock.WaitForDecompiledTextAsync(); + // Navigate while the op is running: select a node -> the preview tab decompiles. + await vm.ShowSampleAsync(typeof(DecompileSample)); await Waiters.WaitForIdleAsync(); capturedToken.IsCancellationRequested.Should().BeFalse( diff --git a/ILSpy.Tests/Docking/SetActiveDockableTests.cs b/ILSpy.Tests/Docking/SetActiveDockableTests.cs index ff3740567b5..7f631216d97 100644 --- a/ILSpy.Tests/Docking/SetActiveDockableTests.cs +++ b/ILSpy.Tests/Docking/SetActiveDockableTests.cs @@ -24,7 +24,6 @@ using AwesomeAssertions; using ICSharpCode.ILSpy.AssemblyTree; -using ICSharpCode.ILSpy.TreeNodes; using NUnit.Framework; @@ -43,10 +42,7 @@ public class SetActiveDockableTests public async Task Reactivating_The_Active_Document_Keeps_The_Focused_Dockable() { var (_, vm) = await TestHarness.BootAsync(); - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); - vm.AssemblyTreeModel.SelectNode(typeNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(typeof(DecompileSample)); var docs = vm.DockWorkspace.Documents!; var activeDocument = docs.ActiveDockable; diff --git a/ILSpy.Tests/Editor/DecompilerViewTests.cs b/ILSpy.Tests/Editor/DecompilerViewTests.cs index 7301eb62da3..0e73aee6a25 100644 --- a/ILSpy.Tests/Editor/DecompilerViewTests.cs +++ b/ILSpy.Tests/Editor/DecompilerViewTests.cs @@ -44,30 +44,27 @@ namespace ICSharpCode.ILSpy.Tests; public class DecompilerViewTests { [AvaloniaTest] - public async Task Selecting_AsEnumerable_Method_Decompiles_Into_Document_View() + public async Task Selecting_A_Method_Node_Decompiles_Into_Document_View() { // Selecting a MethodTreeNode in the assembly tree must trigger a decompile and the // resulting C# (signature + body) must land in the document tab. Also verifies the // row scrolls into view (CenteredInView assertion) so the user sees the selection. - // Arrange — boot, expand Enumerable, locate AsEnumerable. - var (_, vm) = await TestHarness.BootAsync(3); + // Arrange — boot, expand the sample type, locate a method. + var (_, vm) = await TestHarness.BootAsync(); - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable").Expand(); - var methodNode = typeNode.GetChild(m => m.MethodDefinition.Name == "AsEnumerable"); - methodNode.MethodDefinition.IsExtensionMethod.Should().BeTrue(); + var typeNode = (await vm.OpenSampleAsync(typeof(DecompileSample))).Expand(); + var methodNode = typeNode.GetChild(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)); // Act — select the method node and wait for the decompile. vm.AssemblyTreeModel.SelectNode(methodNode); - TestCapture.Step("selected-asenumerable"); + TestCapture.Step("selected-method"); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - TestCapture.Step("asenumerable-decompiled"); + TestCapture.Step("method-decompiled"); // Assert — signature + body fragments are present and the row centred in the tree. - tab.Text.Should().Contain("AsEnumerable"); - tab.Text.Should().Contain("IEnumerable"); - tab.Text.Should().Contain("return source"); + tab.Text.Should().Contain("public int Twice(int value, int extra)"); + tab.Text.Should().Contain("text.Length"); methodNode.Should().Be().CenteredInView(); } @@ -104,25 +101,20 @@ public async Task Selecting_References_Folder_Decompiles_References_Listing() [AvaloniaTest] public async Task Selecting_Type_Node_Decompiles_Type_Definition() { - // Selecting a TypeTreeNode (System.Version) must decompile the full class declaration - // including representative members. - - // Arrange — boot, find System.Version. - var (_, vm) = await TestHarness.BootAsync(3); + // Selecting a TypeTreeNode must decompile the full class declaration including + // representative members. - var typeNode = vm.AssemblyTreeModel.FindNode( - TreeNavigation.CoreLibName, "System", "System.Version"); + // Arrange — boot. + var (_, vm) = await TestHarness.BootAsync(); // Act — select the type node, wait for the decompile. - vm.AssemblyTreeModel.SelectNode(typeNode); - TestCapture.Step("selected-version"); - var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - TestCapture.Step("version-class"); + var (_, tab) = await vm.ShowSampleAsync(typeof(DecompileSample)); + TestCapture.Step("sample-class"); - // Assert — class declaration + representative property names land in the output. - tab.Text.Should().Contain("class Version"); - tab.Text.Should().Contain("Major"); - tab.Text.Should().Contain("Minor"); + // Assert — class declaration + representative member names land in the output. + tab.Text.Should().Contain("class " + nameof(DecompileSample)); + tab.Text.Should().Contain(nameof(DecompileSample.Name)); + tab.Text.Should().Contain(nameof(DecompileSample.Count)); } [AvaloniaTest] @@ -131,68 +123,65 @@ public async Task Selecting_Property_Node_Decompiles_Property() // PropertyTreeNode → just the single property's declaration (with type prefix), not // the full enclosing class. - // Arrange — boot, expand System.Version, grab Major. - var (_, vm) = await TestHarness.BootAsync(3); + // Arrange — boot, expand the sample type, grab its property. + var (_, vm) = await TestHarness.BootAsync(); - var typeNode = vm.AssemblyTreeModel.FindNode( - TreeNavigation.CoreLibName, "System", "System.Version").Expand(); - var propertyNode = typeNode.GetChild(p => p.PropertyDefinition.Name == "Major"); + var typeNode = (await vm.OpenSampleAsync(typeof(DecompileSample))).Expand(); + var propertyNode = typeNode.GetChild(p => p.PropertyDefinition.Name == nameof(DecompileSample.Name)); // Act — select the property, wait for the decompile. vm.AssemblyTreeModel.SelectNode(propertyNode); - TestCapture.Step("selected-major"); + TestCapture.Step("selected-property"); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - TestCapture.Step("major-property"); + TestCapture.Step("property"); // Assert — property declaration lands in the output. - tab.Text.Should().Contain("public int Major"); + tab.Text.Should().Contain("public string Name"); } [AvaloniaTest] public async Task Selecting_Field_Node_Decompiles_Field() { - // FieldTreeNode (System.Math.PI) → decompiled field declaration with its constant + // FieldTreeNode (a constant) → decompiled field declaration with its constant // initializer. - // Arrange — boot, expand System.Math, grab PI. - var (_, vm) = await TestHarness.BootAsync(3); + // Arrange — boot, expand the sample type, grab its constant. + var (_, vm) = await TestHarness.BootAsync(); - var typeNode = vm.AssemblyTreeModel.FindNode( - TreeNavigation.CoreLibName, "System", "System.Math").Expand(); - var fieldNode = typeNode.GetChild(f => f.FieldDefinition.Name == "PI"); + var typeNode = (await vm.OpenSampleAsync(typeof(DecompileSample))).Expand(); + var fieldNode = typeNode.GetChild(f => f.FieldDefinition.Name == nameof(DecompileSample.Limit)); // Act — select the field, wait for the decompile. vm.AssemblyTreeModel.SelectNode(fieldNode); - TestCapture.Step("selected-pi"); + TestCapture.Step("selected-constant"); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - TestCapture.Step("pi-field"); + TestCapture.Step("constant-field"); // Assert — declaration and constant value both land. - tab.Text.Should().Contain("public const double PI"); - tab.Text.Should().Contain("3.14159"); + tab.Text.Should().Contain("public const int Limit"); + tab.Text.Should().Contain("= 3"); } [AvaloniaTest] public async Task Selecting_Event_Node_Decompiles_Event() { - // EventTreeNode (AppDomain.ProcessExit) → decompiled event declaration. + // EventTreeNode → decompiled event declaration. - // Arrange — boot, expand AppDomain, grab ProcessExit. - var (_, vm) = await TestHarness.BootAsync(3); + // Arrange — boot, expand the sample type, grab its event. + var (_, vm) = await TestHarness.BootAsync(); - var typeNode = vm.AssemblyTreeModel.FindNode( - TreeNavigation.CoreLibName, "System", "System.AppDomain").Expand(); - var eventNode = typeNode.GetChild(e => e.EventDefinition.Name == "ProcessExit"); + var typeNode = (await vm.OpenSampleAsync(typeof(DecompileSample))).Expand(); + var eventNode = typeNode.GetChild(e => e.EventDefinition.Name == nameof(DecompileSample.Changed)); // Act — select the event, wait for the decompile. vm.AssemblyTreeModel.SelectNode(eventNode); - TestCapture.Step("selected-processexit"); + TestCapture.Step("selected-event"); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - TestCapture.Step("processexit-event"); + TestCapture.Step("event"); // Assert — event keyword + name land in the output. tab.Text.Should().Contain("event EventHandler"); - tab.Text.Should().Contain("ProcessExit"); + tab.Text.Should().Contain(nameof(DecompileSample.Changed)); } [AvaloniaTest] @@ -315,10 +304,7 @@ public async Task Decompile_Tab_Title_Cycles_Round_Spinner_While_Decompiling() // Act — kick off a second decompile and observe Title/Text changes throughout. No // breakpoint inside the observation window: a capture's RunJobs could perturb the // in-flight title/text transitions this test is asserting on. - var second = vm.AssemblyTreeModel.FindNode( - TreeNavigation.CoreLibName, "System", "System.Version"); - vm.AssemblyTreeModel.SelectNode(second); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(typeof(DecompileSample)); tab.PropertyChanged -= OnTabPropertyChanged; TestCapture.Step("after-second-decompile"); @@ -348,27 +334,26 @@ public async Task Multi_Selection_Tab_Title_Joins_All_Selected_Names_With_Comma( // Selecting several nodes via Ctrl+Click feeds them all into one decompile. The tab // title must list every selected node's display text comma-joined. - // Arrange — boot, expand Enumerable, grab two methods. + // Arrange — boot, expand the sample type, grab two methods. var (_, vm) = await TestHarness.BootAsync(3); - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable").Expand(); - var asEnumerable = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "AsEnumerable"); - var empty = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Empty"); + var typeNode = (await vm.OpenSampleAsync(typeof(DecompileSample))).Expand(); + var describe = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe)); + var twice = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)); // Act — replace the selection with both nodes. vm.AssemblyTreeModel.SelectedItems.Clear(); - vm.AssemblyTreeModel.SelectedItems.Add(asEnumerable); - vm.AssemblyTreeModel.SelectedItems.Add(empty); + vm.AssemblyTreeModel.SelectedItems.Add(describe); + vm.AssemblyTreeModel.SelectedItems.Add(twice); TestCapture.Step("multi-selected"); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); TestCapture.Step("multi-selection-title"); // Assert — title is the two display texts joined with ", ". - tab.Title.Should().Be($"{asEnumerable.Text}, {empty.Text}"); + tab.Title.Should().Be($"{describe.Text}, {twice.Text}"); } [AvaloniaTest] @@ -377,20 +362,19 @@ public async Task Multi_Selecting_Methods_Decompiles_All_Of_Them_Into_One_View() // Multi-selection isn't just a title concern — both methods' bodies must actually be // decompiled into one combined document. - // Arrange — boot, expand Enumerable, grab two methods. + // Arrange — boot, expand the sample type, grab two methods. var (_, vm) = await TestHarness.BootAsync(3); - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable").Expand(); - var asEnumerable = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "AsEnumerable"); - var empty = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Empty"); + var typeNode = (await vm.OpenSampleAsync(typeof(DecompileSample))).Expand(); + var describe = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe)); + var twice = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)); // Act — multi-select the two methods. vm.AssemblyTreeModel.SelectedItems.Clear(); - vm.AssemblyTreeModel.SelectedItems.Add(asEnumerable); - vm.AssemblyTreeModel.SelectedItems.Add(empty); + vm.AssemblyTreeModel.SelectedItems.Add(describe); + vm.AssemblyTreeModel.SelectedItems.Add(twice); TestCapture.Step("multi-selected"); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); @@ -398,9 +382,9 @@ public async Task Multi_Selecting_Methods_Decompiles_All_Of_Them_Into_One_View() // Assert — both names AND a body-fragment from one of them confirm both bodies // actually rendered (not just signatures). - tab.Text.Should().Contain("AsEnumerable"); - tab.Text.Should().Contain("Empty"); - tab.Text.Should().Contain("return source"); + tab.Text.Should().Contain(nameof(DecompileSample.Describe)); + tab.Text.Should().Contain(nameof(DecompileSample.Twice)); + tab.Text.Should().Contain("text.Length"); } [AvaloniaTest] @@ -487,10 +471,7 @@ public async Task Expand_Display_Settings_Reach_The_Decompiler_Foldings() settings.DisplaySettings.ExpandMemberDefinitions = true; settings.DisplaySettings.ExpandUsingDeclarations = true; - var typeNode = vm.AssemblyTreeModel.FindNode( - TreeNavigation.CoreLibName, "System", "System.Version"); - vm.AssemblyTreeModel.SelectNode(typeNode); - var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + var (_, tab) = await vm.ShowSampleAsync(typeof(DecompileSample)); tab.Foldings.Should().NotBeNull(); var foldings = tab.Foldings!; @@ -530,11 +511,10 @@ public async Task Switching_Language_With_Selected_Method_Does_Not_Throw_Folding System.Threading.Tasks.TaskScheduler.UnobservedTaskException += handler; try { - var (_, vm) = await TestHarness.BootAsync(3); + var (_, vm) = await TestHarness.BootAsync(); - var method = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable") - .Expand().GetChild(m => m.MethodDefinition.Name == "AsEnumerable"); + var method = (await vm.OpenSampleAsync(typeof(DecompileSample))) + .Expand().GetChild(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe)); vm.AssemblyTreeModel.SelectNode(method); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); tab.Foldings.Should().NotBeNull( diff --git a/ILSpy.Tests/Editor/DocumentationLinkTests.cs b/ILSpy.Tests/Editor/DocumentationLinkTests.cs index 09f279f3b26..318bca8e760 100644 --- a/ILSpy.Tests/Editor/DocumentationLinkTests.cs +++ b/ILSpy.Tests/Editor/DocumentationLinkTests.cs @@ -109,10 +109,7 @@ public async Task SeeCref_Renders_As_A_Link_That_Navigates_On_Click() public async Task Pressing_Inside_The_Popup_To_Select_Does_Not_Close_It() { var (window, vm) = await TestHarness.BootAsync(); - var coreLibName = typeof(object).Assembly.GetName().Name!; - var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); - vm.AssemblyTreeModel.SelectNode(stringNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(typeof(DecompileSample)); window.UpdateLayout(); var view = window.GetVisualDescendants().OfType().First(); diff --git a/ILSpy.Tests/Editor/FoldingContextMenuTests.cs b/ILSpy.Tests/Editor/FoldingContextMenuTests.cs index e17d524484e..17d11a198e4 100644 --- a/ILSpy.Tests/Editor/FoldingContextMenuTests.cs +++ b/ILSpy.Tests/Editor/FoldingContextMenuTests.cs @@ -29,7 +29,6 @@ using ICSharpCode.ILSpy; using ICSharpCode.ILSpy.AppEnv; using ICSharpCode.ILSpy.TextView; -using ICSharpCode.ILSpy.TreeNodes; using NUnit.Framework; @@ -45,13 +44,8 @@ public async Task Toggle_All_Folding_Entry_Collapses_Then_Expands_The_Document() // them all, then expand them all on a second invocation -- via DecompilerTextView.ToggleAllFoldings. var (window, vm) = await TestHarness.BootAsync(3); - // A small CoreLib type still yields brace foldings (one per method body) but decompiles fast - // enough for the headless 15s wait on slow CI runners; a full System.Linq.Enumerable decompile - // does not. - var coreLibName = typeof(object).Assembly.GetName().Name!; - var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); - vm.AssemblyTreeModel.SelectNode(typeNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + // The sample type yields brace foldings (one per method body) and decompiles quickly. + await vm.ShowSampleAsync(typeof(DecompileSample)); var view = await window.WaitForComponent(); await Waiters.WaitForIdleAsync(); @@ -90,10 +84,7 @@ public async Task Toggle_Folding_Entry_Acts_On_The_Right_Clicked_Line_Not_The_Ca // offset via TextViewContext.TextLocation; reading the caret instead would toggle the wrong block. var (window, vm) = await TestHarness.BootAsync(3); - var coreLibName = typeof(object).Assembly.GetName().Name!; - var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); - vm.AssemblyTreeModel.SelectNode(typeNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(typeof(DecompileSample)); var view = await window.WaitForComponent(); await Waiters.WaitForIdleAsync(); diff --git a/ILSpy.Tests/Editor/ILLanguageTooltipTests.cs b/ILSpy.Tests/Editor/ILLanguageTooltipTests.cs index 45114a10614..5b15ff38908 100644 --- a/ILSpy.Tests/Editor/ILLanguageTooltipTests.cs +++ b/ILSpy.Tests/Editor/ILLanguageTooltipTests.cs @@ -54,17 +54,12 @@ namespace ICSharpCode.ILSpy.Tests.TextView; [TestFixture] public class ILLanguageTooltipTests { - static async Task LoadCoreLibStringNodeAsync() + static async Task LoadSampleNodeAsync() { - var window = AppComposition.Current.GetExport(); - window.Show(); - var vm = (MainWindowViewModel)window.DataContext!; - await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); - - var coreLibName = typeof(object).Assembly.GetName().Name!; - var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); - stringNode.IsExpanded = true; - return stringNode; + var (_, vm) = await TestHarness.BootAsync(); + var sampleNode = await vm.OpenSampleAsync(typeof(DecompileSample)); + sampleNode.IsExpanded = true; + return sampleNode; } static ILLanguage GetILLanguage() @@ -73,17 +68,17 @@ static ILLanguage GetILLanguage() [AvaloniaTest] public async Task Method_Tooltip_Is_The_Disassembled_IL_Header() { - var stringNode = await LoadCoreLibStringNodeAsync(); - var concat = stringNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Concat" && m.MethodDefinition.Parameters.Count == 2) + var sampleNode = await LoadSampleNodeAsync(); + var twice = sampleNode.Children.OfType() + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)) .MethodDefinition; - var tooltip = GetILLanguage().GetRichText(concat, default); + var tooltip = GetILLanguage().GetRichText(twice, default); tooltip.Should().NotBeNull(); tooltip!.Text.Should().StartWith(".method", "the IL hover tooltip must show the disassembled method header, not an ambience one-liner"); - tooltip.Text.Should().Contain("Concat"); + tooltip.Text.Should().Contain(nameof(DecompileSample.Twice)); tooltip.Text.Should().NotContain("\n", "the header must be collapsed to a single line for the tooltip"); } @@ -91,39 +86,39 @@ public async Task Method_Tooltip_Is_The_Disassembled_IL_Header() [AvaloniaTest] public async Task Type_Tooltip_Is_The_Disassembled_IL_Header() { - var stringNode = await LoadCoreLibStringNodeAsync(); - var stringType = (ITypeDefinition)stringNode.Member!; + var sampleNode = await LoadSampleNodeAsync(); + var sampleType = (ITypeDefinition)sampleNode.Member!; - var tooltip = GetILLanguage().GetRichText(stringType, default); + var tooltip = GetILLanguage().GetRichText(sampleType, default); tooltip.Should().NotBeNull(); tooltip!.Text.Should().StartWith(".class"); - tooltip.Text.Should().Contain("String"); + tooltip.Text.Should().Contain(nameof(DecompileSample)); tooltip.Text.Should().NotContain("\n"); } [AvaloniaTest] public async Task Field_Tooltip_Is_The_Disassembled_IL_Header() { - var stringNode = await LoadCoreLibStringNodeAsync(); - var empty = stringNode.Children.OfType() - .First(f => f.FieldDefinition.Name == "Empty") + var sampleNode = await LoadSampleNodeAsync(); + var count = sampleNode.Children.OfType() + .First(f => f.FieldDefinition.Name == nameof(DecompileSample.Count)) .FieldDefinition; - var tooltip = GetILLanguage().GetRichText(empty, default); + var tooltip = GetILLanguage().GetRichText(count, default); tooltip.Should().NotBeNull(); tooltip!.Text.Should().StartWith(".field"); - tooltip.Text.Should().Contain("Empty"); + tooltip.Text.Should().Contain(nameof(DecompileSample.Count)); tooltip.Text.Should().NotContain("\n"); } [AvaloniaTest] public async Task OpCode_Hover_Builds_Rich_Content_With_Documentation() { - var stringNode = await LoadCoreLibStringNodeAsync(); + var sampleNode = await LoadSampleNodeAsync(); var vm = (MainWindowViewModel)AppComposition.Current.GetExport().DataContext!; - vm.AssemblyTreeModel.SelectNode(stringNode); + vm.AssemblyTreeModel.SelectNode(sampleNode); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var view = new DecompilerTextView { DataContext = tab }; @@ -151,17 +146,17 @@ public async Task EntityReference_Hover_Resolves_To_A_Rich_Tooltip() // IL-view member references arrive as unresolved EntityReferences, not IEntity — // this covers the resolve half of the hover chain (BuildHoverContent -> // ResolveEntity -> EntityReference.Resolve via the tab's current node). - var stringNode = await LoadCoreLibStringNodeAsync(); + var sampleNode = await LoadSampleNodeAsync(); var vm = (MainWindowViewModel)AppComposition.Current.GetExport().DataContext!; - vm.AssemblyTreeModel.SelectNode(stringNode); + vm.AssemblyTreeModel.SelectNode(sampleNode); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - var concat = stringNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Concat" && m.MethodDefinition.Parameters.Count == 2) + var twice = sampleNode.Children.OfType() + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)) .MethodDefinition; var view = new DecompilerTextView { DataContext = tab }; var segment = new ReferenceSegment { - Reference = new EntityReference(concat.ParentModule!.MetadataFile!, concat.MetadataToken), + Reference = new EntityReference(twice.ParentModule!.MetadataFile!, twice.MetadataToken), }; var content = view.BuildHoverContent(tab, segment); diff --git a/ILSpy.Tests/Editor/MarginCursorTests.cs b/ILSpy.Tests/Editor/MarginCursorTests.cs index 6ecec59abf5..ef13de8afd8 100644 --- a/ILSpy.Tests/Editor/MarginCursorTests.cs +++ b/ILSpy.Tests/Editor/MarginCursorTests.cs @@ -33,7 +33,6 @@ using ICSharpCode.ILSpy.Bookmarks; using ICSharpCode.ILSpy.Options; using ICSharpCode.ILSpy.TextView; -using ICSharpCode.ILSpy.TreeNodes; using NUnit.Framework; @@ -55,10 +54,7 @@ public async Task Left_Gutter_Margins_Use_The_Arrow_Cursor_Not_The_Editors_I_Bea // A type (not a single method) so the decompiled body carries foldings and the folding margin // is installed. - var coreLibName = typeof(object).Assembly.GetName().Name!; - var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); - vm.AssemblyTreeModel.SelectNode(objectNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(typeof(DecompileSample)); var view = await window.WaitForComponent(); var margins = view.Editor.TextArea.LeftMargins; @@ -85,10 +81,7 @@ public async Task Toggling_Line_Numbers_On_Live_Keeps_Gutter_Order_And_Positive_ var settings = AppComposition.Current.GetExport().DisplaySettings; settings.ShowLineNumbers = false; - var coreLibName = typeof(object).Assembly.GetName().Name!; - var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); - vm.AssemblyTreeModel.SelectNode(objectNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(typeof(DecompileSample)); var view = await window.WaitForComponent(); settings.ShowLineNumbers = true; diff --git a/ILSpy.Tests/Editor/MarginPointerTests.cs b/ILSpy.Tests/Editor/MarginPointerTests.cs index d19394476ba..999f9be5572 100644 --- a/ILSpy.Tests/Editor/MarginPointerTests.cs +++ b/ILSpy.Tests/Editor/MarginPointerTests.cs @@ -34,7 +34,6 @@ using ICSharpCode.ILSpy.AppEnv; using ICSharpCode.ILSpy.Options; using ICSharpCode.ILSpy.TextView; -using ICSharpCode.ILSpy.TreeNodes; using NUnit.Framework; @@ -56,10 +55,7 @@ public class MarginPointerTests // A type (not a single method) so the decompiled body carries foldings and the folding margin // is installed. - var coreLibName = typeof(object).Assembly.GetName().Name!; - var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); - vm.AssemblyTreeModel.SelectNode(objectNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(typeof(DecompileSample)); var view = await window.WaitForComponent(); await Waiters.WaitForAsync(() => view.Editor.TextArea.LeftMargins.OfType().Any(), description: "the folding margin to be installed for the decompiled type"); diff --git a/ILSpy.Tests/Editor/MemberReferenceHighlightTests.cs b/ILSpy.Tests/Editor/MemberReferenceHighlightTests.cs index 2a06cc4cdd5..9c6c130bd1e 100644 --- a/ILSpy.Tests/Editor/MemberReferenceHighlightTests.cs +++ b/ILSpy.Tests/Editor/MemberReferenceHighlightTests.cs @@ -143,16 +143,9 @@ public async Task Ctrl_Click_Navigates_When_Ctrl_Click_Is_Required() [AvaloniaTest] public async Task Pointer_Click_Respects_Ctrl_Click_Requirement() { - // Mirrors ReferenceClickTests.SetupAsync/FindVisibleReference: the System.String - // view and its first visible link are the proven coordinate path for pointer - // gestures. - var (window, vm) = await TestHarness.BootAsync(); - AppComposition.Current.GetExport().DisplaySettings.NavigateOnCtrlClick = true; - var coreLibName = typeof(object).Assembly.GetName().Name!; - var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); - vm.AssemblyTreeModel.SelectNode(stringNode); - var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - var view = window.GetVisualDescendants().OfType().First(); + // Mirrors ReferenceClickTests.FindVisibleReference: the first visible link of the + // sample view is the proven coordinate path for pointer gestures. + var (window, view, tab) = await SetupAsync(navigateOnCtrlClick: true); AvaloniaHeadlessPlatform.ForceRenderTimerTick(); Avalonia.Threading.Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); @@ -186,13 +179,7 @@ public async Task Pointer_Click_Respects_Ctrl_Click_Requirement() [AvaloniaTest] public async Task Cursor_Shows_Hand_Only_When_Click_Would_Navigate() { - var (window, vm) = await TestHarness.BootAsync(); - AppComposition.Current.GetExport().DisplaySettings.NavigateOnCtrlClick = true; - var coreLibName = typeof(object).Assembly.GetName().Name!; - var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); - vm.AssemblyTreeModel.SelectNode(stringNode); - var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - var view = window.GetVisualDescendants().OfType().First(); + var (window, view, tab) = await SetupAsync(navigateOnCtrlClick: true); AvaloniaHeadlessPlatform.ForceRenderTimerTick(); Avalonia.Threading.Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); diff --git a/ILSpy.Tests/Editor/ReferenceClickTests.cs b/ILSpy.Tests/Editor/ReferenceClickTests.cs index 00e8ef9b168..215fde7262b 100644 --- a/ILSpy.Tests/Editor/ReferenceClickTests.cs +++ b/ILSpy.Tests/Editor/ReferenceClickTests.cs @@ -32,7 +32,6 @@ using AwesomeAssertions; using ICSharpCode.ILSpy.TextView; -using ICSharpCode.ILSpy.TreeNodes; using ICSharpCode.ILSpy.ViewModels; using ICSharpCode.ILSpy.Views; @@ -51,10 +50,7 @@ public class ReferenceClickTests static async Task<(MainWindow Window, DecompilerTextView View, DecompilerTabPageModel Tab)> SetupAsync() { var (window, vm) = await TestHarness.BootAsync(); - var coreLibName = typeof(object).Assembly.GetName().Name!; - var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); - vm.AssemblyTreeModel.SelectNode(stringNode); - var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + var (_, tab) = await vm.ShowSampleAsync(typeof(DecompileSample)); var view = window.GetVisualDescendants().OfType().First(); Waiters.PumpUI(window); diff --git a/ILSpy.Tests/Editor/TabFocusNavigationTests.cs b/ILSpy.Tests/Editor/TabFocusNavigationTests.cs index 4e2b96d76bc..74fc722bd31 100644 --- a/ILSpy.Tests/Editor/TabFocusNavigationTests.cs +++ b/ILSpy.Tests/Editor/TabFocusNavigationTests.cs @@ -31,7 +31,6 @@ using AwesomeAssertions; using ICSharpCode.ILSpy.TextView; -using ICSharpCode.ILSpy.TreeNodes; using NUnit.Framework; @@ -48,9 +47,7 @@ public class TabFocusNavigationTests static async Task<(ICSharpCode.ILSpy.Views.MainWindow Window, TextArea TextArea)> FocusEditorAsync() { var (window, vm) = await TestHarness.BootAsync(); - var coreLibName = typeof(object).Assembly.GetName().Name!; - vm.AssemblyTreeModel.SelectNode(vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String")); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await vm.ShowSampleAsync(typeof(DecompileSample)); var textArea = window.GetVisualDescendants().OfType().First().Editor.TextArea; textArea.Focus(); Avalonia.Threading.Dispatcher.UIThread.RunJobs(); diff --git a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs index 497f40645a8..e3cefaee2c4 100644 --- a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs +++ b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs @@ -86,13 +86,12 @@ public async Task BrowseBack_MenuItem_Forwards_CanExecute_And_Execute_To_DockWor // Arrange — boot the window, load assemblies, expand Enumerable, capture two methods. var (window, vm) = await TestHarness.BootAsync(3); - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); typeNode.IsExpanded = true; var firstMethod = typeNode.Children.OfType() - .Single(m => m.MethodDefinition.Name == "AsEnumerable"); + .Single(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe)); var secondMethod = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Empty"); + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)); // Locate the View > Back NativeMenuItem. var nativeMenu = NativeMenu.GetMenu(window) @@ -265,17 +264,16 @@ await Waiters.WaitForAsync( description: "the focus must follow the selection to the newly selected row"); } - // Selects two methods of System.Linq.Enumerable with a pause in between so the history records + // Selects two methods of the sample type 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( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); typeNode.IsExpanded = true; var firstMethod = typeNode.Children.OfType() - .Single(m => m.MethodDefinition.Name == "AsEnumerable"); + .Single(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe)); var secondMethod = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Empty"); + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)); vm.AssemblyTreeModel.SelectNode(firstMethod); await vm.DockWorkspace.WaitForDecompiledTextAsync(); diff --git a/ILSpy.Tests/Navigation/NavigationTests.cs b/ILSpy.Tests/Navigation/NavigationTests.cs index 62312b72383..6462a055cb1 100644 --- a/ILSpy.Tests/Navigation/NavigationTests.cs +++ b/ILSpy.Tests/Navigation/NavigationTests.cs @@ -41,23 +41,22 @@ public async Task Back_Navigation_Restores_Previously_Selected_Node() // the Back command must restore the previously-selected node, re-decompile it, and // scroll the tree so that node is back in view. - // Arrange — boot the window, wait for assemblies, expand Enumerable, capture two methods + // Arrange — boot the window, wait for assemblies, expand the sample type, capture two methods // that we'll bounce between. var (_, vm) = await TestHarness.BootAsync(3); - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); typeNode.IsExpanded = true; var firstMethod = typeNode.Children.OfType() - .Single(m => m.MethodDefinition.Name == "AsEnumerable"); + .Single(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe)); var secondMethod = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Empty"); + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)); - // Act 1 — select AsEnumerable, wait for it to decompile. + // Act 1 — select the first method, wait for it to decompile. vm.AssemblyTreeModel.SelectNode(firstMethod); var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - firstTab.Text.Should().Contain("AsEnumerable"); - TestCapture.Step("as-enumerable-decompiled"); + firstTab.Text.Should().Contain(nameof(DecompileSample.Describe)); + TestCapture.Step("first-method-decompiled"); // NavigationHistory collapses selections that happen within 0.5s into one entry. Wait // past that window so the second selection records a real back-history entry. @@ -67,7 +66,7 @@ public async Task Back_Navigation_Restores_Previously_Selected_Node() vm.AssemblyTreeModel.SelectNode(secondMethod); await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, secondMethod)); var secondTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - secondTab.Text.Should().Contain("Empty"); + secondTab.Text.Should().Contain(nameof(DecompileSample.Twice)); TestCapture.Step("empty-decompiled"); // Act 3 — fire NavigateBack. @@ -76,12 +75,12 @@ public async Task Back_Navigation_Restores_Previously_Selected_Node() vm.DockWorkspace.NavigateBackCommand.Execute(null); TestCapture.Step("navigated-back"); - // Assert — selection restores to AsEnumerable, the document re-decompiles to its body, + // Assert — selection restores to the first method, the document re-decompiles to its body, // and the row is centred back into view. await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod)); var restoredTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - restoredTab.Text.Should().Contain("AsEnumerable"); - restoredTab.Text.Should().Contain("return source"); + restoredTab.Text.Should().Contain(nameof(DecompileSample.Describe)); + restoredTab.Text.Should().Contain("return Name + Count"); firstMethod.Should().Be().CenteredInView(); } @@ -94,18 +93,17 @@ public async Task Back_SplitButton_Dropdown_Lists_History_And_Jumps_To_Selected_ // and that picking a non-immediate entry jumps multiple steps in one click (with the // displaced entries pushed onto the forward stack). - // Arrange — boot, wait for assemblies, expand Enumerable, capture three methods. + // Arrange — boot, wait for assemblies, expand the sample type, capture three methods. var (window, vm) = await TestHarness.BootAsync(3); - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); typeNode.IsExpanded = true; var methodA = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "AsEnumerable"); + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe)); var methodB = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Empty"); + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)); var methodC = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Range"); + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Reset)); // Act 1 — three distinct selections with >0.6s gaps so each lands as its own entry on // the back stack (NavigationHistory collapses sub-0.5s rapid succession into one entry). @@ -117,7 +115,7 @@ public async Task Back_SplitButton_Dropdown_Lists_History_And_Jumps_To_Selected_ await Task.Delay(600); vm.AssemblyTreeModel.SelectNode(methodC); await vm.DockWorkspace.WaitForDecompiledTextAsync(); - TestCapture.Step("range-decompiled"); + TestCapture.Step("third-method-decompiled"); // Act 2 — open the Back SplitButton's flyout. The Opening handler populates the menu // from the current back history. @@ -137,7 +135,7 @@ public async Task Back_SplitButton_Dropdown_Lists_History_And_Jumps_To_Selected_ var items = flyout.Items.OfType().ToList(); ((string)items[0].Header!).Should().Be((string)methodB.NavigationText); ((string)items[1].Header!).Should().Be((string)methodA.NavigationText); - ((string)items[0].Header!).Should().Contain("Enumerable", + ((string)items[0].Header!).Should().Contain(nameof(DecompileSample), "NavigationText must include the declaring type"); items[1].CommandParameter.Should().BeOfType(); var entry = (ICSharpCode.ILSpy.Navigation.TreeNodeEntry)items[1].CommandParameter!; @@ -162,13 +160,11 @@ public async Task Back_History_Dropdown_Uses_NavigationText_For_Generic_Tree_Nod // form ("Base Types (System.Exception)"), and TreeNodeEntry.DisplayText must use it // when the node implements ILSpyTreeNode. - // Arrange — boot, wait for assemblies, expand System.Exception so we can select its + // Arrange — boot, expand a type that has a base type so we can select its // BaseTypesTreeNode child. - var (window, vm) = await TestHarness.BootAsync(3); + var (window, vm) = await TestHarness.BootAsync(); - var coreLibName = typeof(object).Assembly.GetName().Name!; - var typeNode = vm.AssemblyTreeModel.FindNode( - coreLibName, "System", "System.Exception"); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); typeNode.IsExpanded = true; var baseTypes = typeNode.Children.OfType().Single(); @@ -179,7 +175,7 @@ public async Task Back_History_Dropdown_Uses_NavigationText_For_Generic_Tree_Nod await Task.Delay(600); vm.AssemblyTreeModel.SelectNode(typeNode); await vm.DockWorkspace.WaitForDecompiledTextAsync(); - TestCapture.Step("exception-type-decompiled"); + TestCapture.Step("sample-type-decompiled"); // Open the back-history flyout and read the entry header. var backSplit = window.GetVisualDescendants().OfType() @@ -194,7 +190,7 @@ public async Task Back_History_Dropdown_Uses_NavigationText_For_Generic_Tree_Nod var items = flyout.Items.OfType().ToList(); var baseTypesEntry = items.First(i => ((string)i.Header!).StartsWith((string)baseTypes.Text)); - ((string)baseTypesEntry.Header!).Should().Contain("Exception", + ((string)baseTypesEntry.Header!).Should().Contain(nameof(DecompileSample), "the dropdown header must disambiguate generic grouping nodes via NavigationText"); ((string)baseTypesEntry.Header!).Should().NotBe((string)baseTypes.Text, "falling back to bare Text would leave the grouping nodes indistinguishable"); diff --git a/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs b/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs index a0a787a7063..76119136fe8 100644 --- a/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs +++ b/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs @@ -60,15 +60,12 @@ static async Task Pump(int ticks = 12) public async Task Toggling_A_Decompiler_Output_Setting_Re_Decompiles_The_Active_Tab() { var (_, vm) = await TestHarness.BootAsync(); - // Decompile a single small method rather than the whole Enumerable type: the full type - // takes >15 s in headless and times out WaitForDecompiledTextAsync under CI load. The - // re-decompile reaction under test fires on whatever the active tab shows, so one method - // exercises it just as well. - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + // The re-decompile reaction under test fires on whatever the active tab shows, so one + // small method exercises it just as well as a whole type. + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); typeNode.IsExpanded = true; var method = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Empty"); + .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)); vm.AssemblyTreeModel.SelectNode(method); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); diff --git a/ILSpy.Tests/Options/OptionsTabTests.cs b/ILSpy.Tests/Options/OptionsTabTests.cs index 6b96edea9c1..50e21bf0bb3 100644 --- a/ILSpy.Tests/Options/OptionsTabTests.cs +++ b/ILSpy.Tests/Options/OptionsTabTests.cs @@ -36,7 +36,6 @@ using ICSharpCode.ILSpy.Docking; using ICSharpCode.ILSpy.Options; using ICSharpCode.ILSpy.TextView; -using ICSharpCode.ILSpy.TreeNodes; using ICSharpCode.ILSpy.ViewModels; using ICSharpCode.ILSpy.Views; @@ -96,7 +95,8 @@ public async Task Tree_Selection_Activates_MainTab_Even_When_Options_Is_The_Focu // document is still Options — so the user sees no visible change. The fix: // ShowSelectedNode pulls focus back to MainTab whenever a tree-selection writes // new content there. - var (_, vm) = await TestHarness.BootAsync(3); + var (_, vm) = await TestHarness.BootAsync(); + var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); // Open Options and confirm it's the active document. AppComposition.Current.GetExport() @@ -110,8 +110,6 @@ public async Task Tree_Selection_Activates_MainTab_Even_When_Options_Is_The_Focu "baseline: opening Options must make it the active document"); // Click a tree node — fires the same path as a real user mouse-click. - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); vm.AssemblyTreeModel.SelectNode(typeNode); TestCapture.Step("tree-node-selected"); @@ -285,12 +283,10 @@ public async Task Display_Setting_Change_Live_Updates_Decompiler_Editor() TestCapture.Step("booted"); // Materialise a DecompilerTextView by selecting a node. - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); - vm.AssemblyTreeModel.SelectNode(typeNode); + await vm.ShowSampleAsync(typeof(DecompileSample)); var view = await window.WaitForComponent(); var editor = await view.WaitForComponent(); - TestCapture.Step("enumerable-decompiled"); + TestCapture.Step("sample-decompiled"); var originalSize = settings.DisplaySettings.SelectedFontSize; var newSize = originalSize + 5; @@ -313,16 +309,8 @@ public async Task Toggling_A_Display_Setting_Does_Not_Switch_Away_From_The_Focus // pull focus back to MainTab and yank the user off the Options page they are editing. var (_, vm) = await TestHarness.BootAsync(3); - // Show some decompiled content first, so there is a decompiler tab to refresh. Use a single - // small method, not the whole Enumerable type: the full type takes >15 s in headless and - // times out WaitForDecompiledTextAsync under CI load. - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); - typeNode.IsExpanded = true; - var method = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Empty"); - vm.AssemblyTreeModel.SelectNode(method); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); + // Show some decompiled content first, so there is a decompiler tab to refresh. + await vm.ShowSampleAsync(typeof(DecompileSample)); // Open Options and confirm it is the active document. AppComposition.Current.GetExport() From a3ebc39ae280d6eb0775d34e165953c1edfeaa03 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Mon, 19 Oct 2026 21:24:25 +0200 Subject: [PATCH 2/6] Drop the fixed 600 ms waits between navigation-history entries Nine tests slept past a "NavigationHistory collapses selections within 0.5 s" window before selecting a second node. The history only collapses a rapid re-selection of the same entry (NavigationHistory.Record), and every one of these tests selects a different node, so the waits bought nothing. The affected fixtures pass three consecutive runs without them. #4262 Single-process wall clock on the same machine: 271 s -> 262 s. Assisted-by: Claude:claude-fable-5-1:Claude Code --- ILSpy.Tests/AssemblyList/AssemblyListResetTests.cs | 2 -- .../AssemblyList/SwitchListAndDeleteAllTests.cs | 2 -- ILSpy.Tests/Commands/HelpCommandTests.cs | 3 --- .../Navigation/BrowseBackForwardCommandTests.cs | 6 ++---- ILSpy.Tests/Navigation/NavigationTests.cs | 14 +++----------- 5 files changed, 5 insertions(+), 22 deletions(-) diff --git a/ILSpy.Tests/AssemblyList/AssemblyListResetTests.cs b/ILSpy.Tests/AssemblyList/AssemblyListResetTests.cs index f986f724306..25ebc87d2ee 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyListResetTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyListResetTests.cs @@ -89,8 +89,6 @@ public async Task Sorting_The_Assembly_List_Keeps_The_Navigation_History() .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)); vm.AssemblyTreeModel.SelectNode(first); await vm.DockWorkspace.WaitForDecompiledTextAsync(); - // NavigationHistory collapses selections inside 0.5s into one entry. - await Task.Delay(600); vm.AssemblyTreeModel.SelectNode(second); await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, second)); await vm.DockWorkspace.WaitForDecompiledTextAsync(); diff --git a/ILSpy.Tests/AssemblyList/SwitchListAndDeleteAllTests.cs b/ILSpy.Tests/AssemblyList/SwitchListAndDeleteAllTests.cs index caf6f2236ff..b167fde0503 100644 --- a/ILSpy.Tests/AssemblyList/SwitchListAndDeleteAllTests.cs +++ b/ILSpy.Tests/AssemblyList/SwitchListAndDeleteAllTests.cs @@ -51,8 +51,6 @@ static async Task GiveTheHistorySomethingToHoldAsync(ViewModels.MainWindowViewMo var methods = typeNode.Children.OfType().ToList(); vm.AssemblyTreeModel.SelectNode(methods.First(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe))); await vm.DockWorkspace.WaitForDecompiledTextAsync(); - // NavigationHistory collapses selections inside 0.5s into one entry. - await Task.Delay(600); vm.AssemblyTreeModel.SelectNode(methods.First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice))); await vm.DockWorkspace.WaitForDecompiledTextAsync(); vm.DockWorkspace.BackHistory.Should().NotBeEmpty("the test needs history to lose"); diff --git a/ILSpy.Tests/Commands/HelpCommandTests.cs b/ILSpy.Tests/Commands/HelpCommandTests.cs index 2741d04090d..a2273a9ba38 100644 --- a/ILSpy.Tests/Commands/HelpCommandTests.cs +++ b/ILSpy.Tests/Commands/HelpCommandTests.cs @@ -205,9 +205,6 @@ public async Task About_Page_Lands_On_Back_History_And_Round_Trips_Through_Navig var decompilerTab = vm.DockWorkspace.ActiveDecompilerTab!; var methodText = decompilerTab.Text; - // NavigationHistory collapses entries within 0.5s — wait past the window so opening - // About records its own entry instead of replacing the tree-node entry. - await Task.Delay(600); // Act 1 — open the About page. var registry = AppComposition.Current.GetExport(); diff --git a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs index e3cefaee2c4..06022530d34 100644 --- a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs +++ b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs @@ -109,7 +109,6 @@ public async Task BrowseBack_MenuItem_Forwards_CanExecute_And_Execute_To_DockWor vm.AssemblyTreeModel.SelectNode(firstMethod); await vm.DockWorkspace.WaitForDecompiledTextAsync(); TestCapture.Step("first-method-decompiled"); - await Task.Delay(600); vm.AssemblyTreeModel.SelectNode(secondMethod); await vm.DockWorkspace.WaitForDecompiledTextAsync(); TestCapture.Step("second-method-decompiled"); @@ -264,8 +263,8 @@ await Waiters.WaitForAsync( description: "the focus must follow the selection to the newly selected row"); } - // Selects two methods of the sample type with a pause in between so the history records - // them as two separate entries; returns them in selection order. + // Selects two methods of the sample type 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 = await vm.OpenSampleAsync(typeof(DecompileSample)); @@ -277,7 +276,6 @@ await Waiters.WaitForAsync( vm.AssemblyTreeModel.SelectNode(firstMethod); await vm.DockWorkspace.WaitForDecompiledTextAsync(); - await Task.Delay(600); vm.AssemblyTreeModel.SelectNode(secondMethod); await vm.DockWorkspace.WaitForDecompiledTextAsync(); return (firstMethod, secondMethod); diff --git a/ILSpy.Tests/Navigation/NavigationTests.cs b/ILSpy.Tests/Navigation/NavigationTests.cs index 6462a055cb1..985994234c0 100644 --- a/ILSpy.Tests/Navigation/NavigationTests.cs +++ b/ILSpy.Tests/Navigation/NavigationTests.cs @@ -58,16 +58,12 @@ public async Task Back_Navigation_Restores_Previously_Selected_Node() firstTab.Text.Should().Contain(nameof(DecompileSample.Describe)); TestCapture.Step("first-method-decompiled"); - // NavigationHistory collapses selections that happen within 0.5s into one entry. Wait - // past that window so the second selection records a real back-history entry. - await Task.Delay(600); - - // Act 2 — select Empty, wait for its decompile. + // Act 2 — select the second method, wait for its decompile. vm.AssemblyTreeModel.SelectNode(secondMethod); await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, secondMethod)); var secondTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); secondTab.Text.Should().Contain(nameof(DecompileSample.Twice)); - TestCapture.Step("empty-decompiled"); + TestCapture.Step("second-method-decompiled"); // Act 3 — fire NavigateBack. vm.DockWorkspace.NavigateBackCommand.CanExecute(null).Should().BeTrue(); @@ -105,14 +101,11 @@ public async Task Back_SplitButton_Dropdown_Lists_History_And_Jumps_To_Selected_ var methodC = typeNode.Children.OfType() .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Reset)); - // Act 1 — three distinct selections with >0.6s gaps so each lands as its own entry on - // the back stack (NavigationHistory collapses sub-0.5s rapid succession into one entry). + // Act 1 — three distinct selections, each landing as its own entry on the back stack. vm.AssemblyTreeModel.SelectNode(methodA); await vm.DockWorkspace.WaitForDecompiledTextAsync(); - await Task.Delay(600); vm.AssemblyTreeModel.SelectNode(methodB); await vm.DockWorkspace.WaitForDecompiledTextAsync(); - await Task.Delay(600); vm.AssemblyTreeModel.SelectNode(methodC); await vm.DockWorkspace.WaitForDecompiledTextAsync(); TestCapture.Step("third-method-decompiled"); @@ -172,7 +165,6 @@ public async Task Back_History_Dropdown_Uses_NavigationText_For_Generic_Tree_Nod // grouping lands on the back stack. vm.AssemblyTreeModel.SelectNode(baseTypes); await vm.DockWorkspace.WaitForDecompiledTextAsync(); - await Task.Delay(600); vm.AssemblyTreeModel.SelectNode(typeNode); await vm.DockWorkspace.WaitForDecompiledTextAsync(); TestCapture.Step("sample-type-decompiled"); From 87740f756e7cd93c504caca0f569642260646f4d Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Mon, 19 Oct 2026 21:31:57 +0200 Subject: [PATCH 3/6] Remove UI tests whose assertions another test already makes Each deleted test drove the same production path as a surviving test in the same file and asserted a subset of what that test asserts, so a regression it could catch already fails the survivor. Where the deleted test held one assertion of its own (the TypeTreeNode type check, the MenuCategory metadata, the freeze flipping IsPreview without a new tab, the Shift+Down range precondition), that assertion moved into the survivor. The Ctrl+R test on the assembly tree pane duplicated AnalyzerTreeKeyboardTests, which reaches the same window key binding. #4262 Single-process wall clock on the same machine: 262 s -> 251 s, 16 tests fewer. Assisted-by: Claude:claude-fable-5-1:Claude Code --- .../Analyzers/AnalyzeContextMenuTests.cs | 65 +------- .../Analyzers/AnalyzerNavigationTests.cs | 20 --- ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs | 150 +----------------- .../Commands/CommandLineArgumentsTests.cs | 32 +--- .../ContextMenus/DecompileInNewViewTests.cs | 16 -- .../Docking/PreviewTabPromotionTests.cs | 82 +--------- .../BrowseBackForwardCommandTests.cs | 37 +---- .../Options/ApiVisibilityFilterTests.cs | 19 --- 8 files changed, 22 insertions(+), 399 deletions(-) diff --git a/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs index 53c883c4b22..a047330b3a8 100644 --- a/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs @@ -47,16 +47,6 @@ 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(); - 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() { @@ -129,33 +119,6 @@ public async Task Analyze_Is_Visible_And_Works_For_A_Clicked_Code_Reference() "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() - .GetEntry(nameof(Resources.Analyze)); - - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); - - var analyzerVm = AppComposition.Current.GetExport(); - 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 Analyze_Command_CanExecute_Tracks_The_Assembly_Tree_Selection() { @@ -174,32 +137,6 @@ public async Task Analyze_Command_CanExecute_Tracks_The_Assembly_Tree_Selection( command.CanExecute(null).Should().BeFalse("assembly nodes are not analyzable members"); } - [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 typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); - vm.AssemblyTreeModel.SelectNode(typeNode); - TestCapture.Step("type-selected"); - - var analyzerVm = AppComposition.Current.GetExport(); - var beforeCount = analyzerVm.Root.Children.Count; - - window.KeyPress(Key.R, - RawInputModifiers.Control, - PhysicalKey.R, - keySymbol: null); - 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() { @@ -296,6 +233,8 @@ public async Task Analyze_Promotes_An_Analyzer_Result_Row_To_A_Top_Level_Entry() .First(m => m.MethodDefinition.Name == "Empty").MethodDefinition; entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode } }); + analyzerVm.Root.Children.Should().ContainSingle( + "Execute on a tree node must add exactly one entity row to the pane root"); var rootRow = analyzerVm.Root.Children.OfType().Last(); rootRow.EnsureLazyChildren(); // A result row lives underneath an analyzer-search header, never directly under the root. diff --git a/ILSpy.Tests/Analyzers/AnalyzerNavigationTests.cs b/ILSpy.Tests/Analyzers/AnalyzerNavigationTests.cs index 210a62403f4..1b42ab04f36 100644 --- a/ILSpy.Tests/Analyzers/AnalyzerNavigationTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzerNavigationTests.cs @@ -44,26 +44,6 @@ sealed class StubRoutedEventArgs : IPlatformRoutedEventArgs public bool Handled { get; set; } } - [AvaloniaTest] - public async Task ActivateItem_On_An_AnalyzedTypeTreeNode_Selects_That_TypeTreeNode_In_The_Assembly_Tree() - { - // Double-click on an analyser result must navigate back to the entity's home in - // the assembly tree — same UX as clicking a hyperlink in the decompiler view. - - var (_, vm) = await TestHarness.BootAsync(); - - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); - var entity = (ICSharpCode.Decompiler.TypeSystem.ITypeDefinition)typeNode.Member!; - - var analyzed = new AnalyzedTypeTreeNode(entity, source: null); - analyzed.ActivateItem(new StubRoutedEventArgs()); - TestCapture.Step("navigated-to-type-node"); - - ((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeSameAs(typeNode, - "ActivateItem must move the assembly-tree selection to the entity's tree node"); - } - [AvaloniaTest] public async Task ActivateItem_On_An_AnalyzedMethodTreeNode_Selects_The_MethodTreeNode_Sibling() { diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs index 13f302f465d..55010e58c75 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs @@ -1025,37 +1025,6 @@ public async Task Numpad_Add_Subtract_Multiply_Expand_And_Collapse_The_Node() await Waiters.WaitForAsync(() => assembly.IsExpanded, description: "Numpad * expands"); } - [AvaloniaTest] - public async Task Shift_Up_After_Shift_Down_Shrinks_The_Selection_Toward_The_Anchor() - { - // Anchor at A, Shift+Down twice -> A,B,C. Shift+Up must SHRINK back to A,B (not keep C). - var (window, vm) = await TestHarness.BootAsync(3); - var model = vm.AssemblyTreeModel; - var pane = await window.WaitForComponent(); - var grid = await pane.WaitForComponent(); - grid.Focus(); - Dispatcher.UIThread.RunJobs(); - - var flat = (System.Collections.IList)grid.ItemsSource!; - var a = (SharpTreeNode)flat[0]!; - var b = (SharpTreeNode)flat[1]!; - var c = (SharpTreeNode)flat[2]!; - - model.SelectNode(a); - await Waiters.WaitForAsync(() => ReferenceEquals(model.SelectedItem, a)); - - window.KeyPress(Key.Down, RawInputModifiers.Shift, PhysicalKey.ArrowDown, null); - Dispatcher.UIThread.RunJobs(); - window.KeyPress(Key.Down, RawInputModifiers.Shift, PhysicalKey.ArrowDown, null); - Dispatcher.UIThread.RunJobs(); - model.SelectedItems.Should().BeEquivalentTo(new[] { a, b, c }, "precondition: A,B,C selected"); - - window.KeyPress(Key.Up, RawInputModifiers.Shift, PhysicalKey.ArrowUp, null); - Dispatcher.UIThread.RunJobs(); - model.SelectedItems.Should().BeEquivalentTo(new[] { a, b }, - "Shift+Up must shrink the range back toward the anchor (A,B), dropping C"); - } - [AvaloniaTest] public async Task Shift_Down_Then_Shift_Up_In_The_Middle_Shrinks_Without_Drifting_The_Anchor() { @@ -1086,6 +1055,8 @@ public async Task Shift_Down_Then_Shift_Up_In_The_Middle_Shrinks_Without_Driftin window.KeyPress(Key.Down, RawInputModifiers.Shift, PhysicalKey.ArrowDown, null); Dispatcher.UIThread.RunJobs(); } + model.SelectedItems.Should().BeEquivalentTo(new[] { start, below, flat[5], flat[6] }, + "precondition: three Shift+Down presses extend the range from the anchor downwards"); for (int u = 0; u < 2; u++) { window.KeyPress(Key.Up, RawInputModifiers.Shift, PhysicalKey.ArrowUp, null); @@ -1344,32 +1315,6 @@ await Waiters.WaitForAsync( "a fresh metadata tab must be created — the previous one keeps its TypeDef state"); } - [AvaloniaTest] - public async Task Property_Tree_Node_Has_Getter_And_Setter_Method_Children() - { - // Properties expose their accessors as MethodTreeNode children, mirroring WPF - // ILSpy. Without this, tree-based navigation can't reach get_X / set_X — and - // MMB on a property-accessor row in the metadata grid silently no-ops because - // FindTreeNode can't resolve the IMethod to a tree node. - var (_, vm) = await TestHarness.BootAsync(); - - var coreLibName = typeof(object).Assembly.GetName().Name!; - var stringTypeNode = vm.AssemblyTreeModel.FindNode( - coreLibName, "System", "System.String"); - stringTypeNode.IsExpanded = true; - - // String.Length is read-only — a property with a getter but no setter. - var lengthProp = stringTypeNode.Children.OfType() - .First(p => p.PropertyDefinition.Name == "Length"); - lengthProp.IsExpanded = true; - - var accessors = lengthProp.Children.OfType().ToList(); - accessors.Should().NotBeEmpty( - "a property tree node must surface its accessors as MethodTreeNode children"); - accessors.Should().Contain(m => m.MethodDefinition.Name == "get_Length", - "String.Length must expose its getter"); - } - [AvaloniaTest] public async Task Event_Tree_Node_Has_Add_And_Remove_Method_Children() { @@ -1448,30 +1393,6 @@ public async Task FindTreeNode_Resolves_Property_Accessor_To_Its_MethodTreeNode_ .Should().Be(getter.MetadataToken); } - [AvaloniaTest] - public async Task Type_Tree_Node_Exposes_BaseTypes_Subtree_Listing_Object_For_System_Exception() - { - // Each type node should surface a "Base Types" sub-tree listing its base classes / - // interfaces, mirroring the WPF tree. System.Exception extends only System.Object so we - // expect a single BaseTypesEntryNode whose label ends in "Object". - - // Arrange — boot, wait for assemblies, drill into System.Exception. - var (_, vm) = await TestHarness.BootAsync(3); - - var coreLibName = typeof(object).Assembly.GetName().Name!; - var typeNode = vm.AssemblyTreeModel.FindNode( - coreLibName, "System", "System.Exception"); - typeNode.IsExpanded = true; - - // Assert — BaseTypes child exists, expanding it yields entries, Object is among them. - var baseTypes = typeNode.Children.OfType().Single(); - baseTypes.IsExpanded = true; - baseTypes.Children.OfType().Should().NotBeEmpty(); - baseTypes.Children.OfType() - .Should().Contain(e => e.Text.ToString()!.EndsWith("Object"), - "System.Exception's base-type chain must include Object"); - } - [AvaloniaTest] public async Task BaseTypesEntryNode_Activate_Navigates_To_The_Base_Type() { @@ -1501,26 +1422,6 @@ public async Task BaseTypesEntryNode_Activate_Navigates_To_The_Base_Type() .Should().Be("Object"); } - [AvaloniaTest] - public async Task Type_Tree_Node_Exposes_DerivedTypes_Subtree_For_Non_Sealed_Class() - { - // Non-sealed types get a "Derived Types" sub-tree listing classes that extend them. We - // scan the assembly list synchronously — for the small fixture loaded in tests this - // yields a few hits per common base (e.g. SystemException is derived from Exception). - - var (_, vm) = await TestHarness.BootAsync(3); - - var coreLibName = typeof(object).Assembly.GetName().Name!; - var typeNode = vm.AssemblyTreeModel.FindNode( - coreLibName, "System", "System.Exception"); - typeNode.IsExpanded = true; - - var derived = typeNode.Children.OfType().Single(); - derived.IsExpanded = true; - derived.Children.OfType().Should().NotBeEmpty( - "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() { @@ -1678,53 +1579,6 @@ public async Task Global_Namespace_Types_Appear_Under_Dash_Node() " (and any other global types) belong under the '-' node"); } - [AvaloniaTest] - public async Task CtrlA_Selects_Every_Top_Level_Row_On_The_First_Press() - { - // Ctrl+A in the assembly tree must select all rows on the FIRST press, not just move - // the current cell to the last row (the user reported having to press it twice). - - // Arrange — boot, wait for assemblies, realise the grid. - var (window, vm) = await TestHarness.BootAsync(3); - - var pane = await window.WaitForComponent(); - var grid = await pane.WaitForComponent(); - grid.UpdateLayout(); - - var topLevelCount = vm.AssemblyTreeModel.Root!.Children.Count; - topLevelCount.Should().BeGreaterThan(1, "the test needs several top-level rows to be meaningful"); - - // Give the grid keyboard focus WITHOUT clicking a cell first — this is the state the - // tree is in right after the user tabs/clicks into the pane but before establishing a - // current cell. The DataGrid only handles Ctrl+A when a focused element lives inside it. - grid.Focus(); - Dispatcher.UIThread.RunJobs(); - - // First press. - window.KeyPress(Key.A, RawInputModifiers.Control, PhysicalKey.A, keySymbol: "a"); - Dispatcher.UIThread.RunJobs(); - await Task.Delay(50); - Dispatcher.UIThread.RunJobs(); - // What the USER sees is the grid's selection. A feedback loop through the model's - // singular SelectedItem can collapse the grid back to one row even while the model's - // SelectedItems still holds all of them — so assert on the grid, not the model. - int gridAfterFirst = grid.SelectedItems!.Count; - int modelAfterFirst = vm.AssemblyTreeModel.SelectedItems.Count; - TestCapture.Step("after-first-ctrl-a"); - - // Second press. - window.KeyPress(Key.A, RawInputModifiers.Control, PhysicalKey.A, keySymbol: "a"); - Dispatcher.UIThread.RunJobs(); - await Task.Delay(50); - Dispatcher.UIThread.RunJobs(); - int gridAfterSecond = grid.SelectedItems!.Count; - - // Assert — every top-level row is selected in the grid after the FIRST press. - gridAfterFirst.Should().Be(topLevelCount, - $"Ctrl+A must select all {topLevelCount} assembly rows on the first press " - + $"(grid after 1st={gridAfterFirst}, model after 1st={modelAfterFirst}, grid after 2nd={gridAfterSecond})"); - } - [AvaloniaTest] public async Task Plain_Click_On_A_Row_Reduces_A_Multi_Selection_To_That_Row() { diff --git a/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs b/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs index 88260e39e39..d179f5db3a8 100644 --- a/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs +++ b/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs @@ -136,33 +136,6 @@ static IEnumerable ReportTabs(MainWindowViewModel vm) ?? []; } - [AvaloniaTest] - public async Task NavigateTo_Type_Arg_Selects_The_Matching_Type_Node() - { - // `-n|--navigateto T:` navigates to a type-tree-node at startup. The arg's - // content is an XML-doc-style ID string ("T:Some.Type") which the consumer resolves - // through the loaded assemblies and selects the matching tree node (which then - // triggers a decompile). - - // Arrange — boot, and open an assembly with a small type to navigate to. - var window = AppComposition.Current.GetExport(); - window.Show(); - var vm = (MainWindowViewModel)window.DataContext!; - await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); - - string path = typeof(DecompileSample).Assembly.Location; - var args = CommandLineArguments.Create(new[] { path, "--navigateto", "T:" + typeof(DecompileSample).FullName }); - - // Act — apply the args. - await vm.AssemblyTreeModel.HandleCommandLineArgumentsAsync(args); - - // Assert — selected item is the sample's TypeTreeNode (ToString returns the - // ReflectionName; that's a stable identifier independent of the active language). - ((object?)vm.AssemblyTreeModel.SelectedItem).Should().NotBeNull(); - vm.AssemblyTreeModel.SelectedItem!.GetType().Should().Be(typeof(TypeTreeNode)); - vm.AssemblyTreeModel.SelectedItem!.ToString().Should().Be(typeof(DecompileSample).FullName); - } - [AvaloniaTest] public async Task NavigateTo_None_Arg_Leaves_Selection_Empty() { @@ -292,8 +265,11 @@ public async Task NavigateTo_Skips_A_Missing_Session_Assembly_Instead_Of_Crashin // Act — applying the args must complete without throwing despite the missing entry. await vm.AssemblyTreeModel.HandleCommandLineArgumentsAsync(args); - // Assert — the missing assembly was skipped and the present target still resolved. + // Assert — the missing assembly was skipped and the present target still resolved to + // its TypeTreeNode (ToString returns the ReflectionName; that's a stable identifier + // independent of the active language). ((object?)vm.AssemblyTreeModel.SelectedItem).Should().NotBeNull(); + vm.AssemblyTreeModel.SelectedItem!.GetType().Should().Be(typeof(TypeTreeNode)); vm.AssemblyTreeModel.SelectedItem!.ToString().Should().Be(typeof(DecompileSample).FullName); } } diff --git a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs index 51e95ca8be7..588d930e3db 100644 --- a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs +++ b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs @@ -66,22 +66,6 @@ static async Task OpenFixtureRowsAsync(MainWindowViewModel v return nodes; } - [AvaloniaTest] - public async Task DecompileInNewView_Entry_Is_Registered() - { - // "Decompile in new tab" is a context-menu entry sitting alongside Remove on every - // node that decompiles. Verifies the [ExportContextMenuEntry] is discovered. - - // Arrange — boot. - var window = AppComposition.Current.GetExport(); - window.Show(); - var registry = AppComposition.Current.GetExport(); - - // Assert — registry contains an entry whose header is the DecompileToNewPanel resource. - registry.Entries.Should().Contain( - e => e.Metadata.Header == nameof(Resources.DecompileToNewPanel)); - } - // audit (2026-05-12): converted 📦 → 🧪. Was calling `entry.IsVisible(synthetic ctx)` // directly with hand-built TextViewContexts; now drives the live `BuildContextMenuForCurrentState` // path with real selection states. The entry's presence/absence in the built menu IS the diff --git a/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs b/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs index 1645c7e05e6..51b53b9cd58 100644 --- a/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs +++ b/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs @@ -41,80 +41,6 @@ namespace ICSharpCode.ILSpy.Tests; public class PreviewTabPromotionTests { - [AvaloniaTest] - public void MainTab_Starts_In_Preview_State() - { - // The persistent MainTab is the preview slot; tree-node clicks should replace its - // Content in place until the user explicitly freezes it. - var window = AppComposition.Current.GetExport(); - window.Show(); - var vm = (MainWindowViewModel)window.DataContext!; - TestCapture.Step("booted"); - var mainTab = ((ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!; - mainTab.IsPreview.Should().BeTrue( - "the freshly-created MainTab is the preview slot until the user freezes it"); - } - - [AvaloniaTest] - public async Task Carve_Out_Tabs_Are_Born_Frozen() - { - // Open-in-new-tab tabs are explicit user intent — they should never be replaced - // by subsequent tree-node selections, so they're born frozen (IsPreview=false). - var (_, vm) = await TestHarness.BootAsync(3); - - var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); - vm.DockWorkspace.OpenNodeInNewTab(typeNode); - TestCapture.Step("carve-out-tab-opened"); - - var carveOut = vm.DockWorkspace.Documents!.VisibleDockables! - .OfType() - .Last(t => t.SourceNode == typeNode); - carveOut.IsPreview.Should().BeFalse( - "carve-out tabs are explicit user intent and must survive tree-selection changes"); - } - - [AvaloniaTest] - public async Task FreezeCurrentTab_Flips_IsPreview_Without_Spawning_A_New_Tab() - { - // New semantics: Freeze only flips IsPreview=false on the current MainTab. No new - // preview tab spawns at freeze time. A fresh preview tab opens lazily later, when a - // tree-selection change finds the active tab frozen — see - // Selecting_A_Different_Node_After_Freeze_Opens_A_New_Preview_Tab below. - var (_, vm) = await TestHarness.BootAsync(3); - - var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; - var previousMainTab = factory.MainTab!; - previousMainTab.IsPreview.Should().BeTrue("baseline"); - - // Load content into MainTab so the freeze has something meaningful to keep. - var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample)); - vm.AssemblyTreeModel.SelectNode(typeNode); - vm.DockWorkspace.SettleSelection(); - TestCapture.Step("sample-selected"); - ReferenceEquals(previousMainTab.SourceNode, typeNode).Should().BeTrue( - "baseline: tree selection populated MainTab with the chosen node"); - - var tabCountBefore = vm.DockWorkspace.Documents!.VisibleDockables!.Count; - - // Freeze. - vm.DockWorkspace.FreezeCurrentTab(); - TestCapture.Step("tab-frozen"); - - // Tab is now frozen, content preserved. - previousMainTab.IsPreview.Should().BeFalse( - "after freeze, the previously-preview MainTab becomes a regular frozen tab"); - ReferenceEquals(previousMainTab.SourceNode, typeNode).Should().BeTrue( - "freeze must not throw away the tab's content"); - - // factory.MainTab still points at the same (now-frozen) tab — no rotation. - factory.MainTab.Should().BeSameAs(previousMainTab, - "freeze alone must not rotate factory.MainTab — the frozen tab keeps the slot until a selection change spawns a new preview"); - - // And critically: no new tab spawned. - vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBefore, - "freezing alone must not open a new tab; new tabs open lazily on the next selection change"); - } - [AvaloniaTest] public async Task Selecting_A_Different_Node_After_Freeze_Opens_A_New_Preview_Tab() { @@ -133,12 +59,16 @@ public async Task Selecting_A_Different_Node_After_Freeze_Opens_A_New_Preview_Ta var frozenTab = factory.MainTab!; var frozenContent = frozenTab.Content; - // Phase 2: freeze. factory.MainTab still points at the same (now-frozen) tab — - // no spawn yet (covered by FreezeCurrentTab_Flips_IsPreview_Without_Spawning_A_New_Tab). + // Phase 2: freeze. Freeze only flips IsPreview on the current MainTab: factory.MainTab + // still points at the same (now-frozen) tab and no new tab spawns yet. var tabCountBeforeSelection = vm.DockWorkspace.Documents!.VisibleDockables!.Count; vm.DockWorkspace.FreezeCurrentTab(); TestCapture.Step("tab-frozen"); + frozenTab.IsPreview.Should().BeFalse("after freeze, the previously-preview MainTab is a regular frozen tab"); + ReferenceEquals(frozenTab.SourceNode, typeA).Should().BeTrue("freeze must not throw away the tab's content"); factory.MainTab.Should().BeSameAs(frozenTab, "freeze alone keeps the slot"); + vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBeforeSelection, + "freezing alone must not open a new tab; new tabs open lazily on the next selection change"); // Phase 3: select a different type — NOW a new preview tab should spawn AND // receive the new content. diff --git a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs index 06022530d34..a252ffadebf 100644 --- a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs +++ b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs @@ -47,35 +47,6 @@ namespace ICSharpCode.ILSpy.Tests; [TestFixture] public class BrowseBackForwardCommandTests { - [AvaloniaTest] - public void BrowseBack_And_BrowseForward_Are_Registered_In_View_Menu_With_Alt_Arrow_Gestures() - { - // Task 26: BrowseBack / BrowseForward are MEF-exported main-menu commands so they appear - // in the View menu with InputGestureText that the menu builder turns into both a - // displayed gesture and a window-scoped HotKey. Verifies registration, parent menu, - // category, gesture text, and that the export carries the right resource header. - - // Arrange — pull the registry directly; no window needed for metadata-only assertions. - var registry = AppComposition.Current.GetExport(); - - // Act — find the two exports by header. - var back = registry.Commands.SingleOrDefault( - c => c.Metadata.Header == nameof(Resources.Back) - && c.Metadata.ParentMenuID == nameof(Resources._View)); - var forward = registry.Commands.SingleOrDefault( - c => c.Metadata.Header == nameof(Resources.Forward) - && c.Metadata.ParentMenuID == nameof(Resources._View)); - - // Assert — both exports exist, sit under View > Navigation, carry Alt+Left / Alt+Right. - back.Should().NotBeNull("BrowseBack must be exported as a View-menu command"); - back!.Metadata.MenuCategory.Should().Be(nameof(Resources.Navigation)); - back.Metadata.InputGestureText.Should().Be("Alt+Left"); - - forward.Should().NotBeNull("BrowseForward must be exported as a View-menu command"); - forward!.Metadata.MenuCategory.Should().Be(nameof(Resources.Navigation)); - forward.Metadata.InputGestureText.Should().Be("Alt+Right"); - } - [AvaloniaTest] public async Task BrowseBack_MenuItem_Forwards_CanExecute_And_Execute_To_DockWorkspace() { @@ -301,5 +272,13 @@ public void BrowseBack_MenuItem_Carries_The_Alt_Left_Gesture() backItem.Gesture.Should().Be(KeyGesture.Parse("Alt+Left")); forwardItem.Gesture.Should().Be(KeyGesture.Parse("Alt+Right")); + + // Both exports sit in the View menu's Navigation category, which is what groups them + // behind a separator. + var registry = AppComposition.Current.GetExport(); + registry.Commands + .Where(c => c.Metadata.Header is nameof(Resources.Back) or nameof(Resources.Forward)) + .Should().HaveCount(2) + .And.OnlyContain(c => c.Metadata.MenuCategory == nameof(Resources.Navigation)); } } diff --git a/ILSpy.Tests/Options/ApiVisibilityFilterTests.cs b/ILSpy.Tests/Options/ApiVisibilityFilterTests.cs index 7a65b641332..43407e98db1 100644 --- a/ILSpy.Tests/Options/ApiVisibilityFilterTests.cs +++ b/ILSpy.Tests/Options/ApiVisibilityFilterTests.cs @@ -83,25 +83,6 @@ public async Task IsPublicAPI_Reflects_Method_Accessibility() "a namespace whose children include public types is itself public-API"); } - [AvaloniaTest] - public async Task Filter_Hides_NonPublic_Method_When_ShowApiLevel_Is_PublicOnly() - { - // At PublicOnly, every member tree node whose IsPublicAPI is false reports - // FilterResult.Hidden so the assembly tree stops rendering it. - - // Arrange — boot, find a known non-public method. - var (vm, _) = await BootAndExpandEnumerableAsync(); - var (privateNode, _) = FindNonPublicMethodInLoadedAssemblies(vm); - var settings = AppComposition.Current.GetExport().SessionSettings.LanguageSettings; - - // Act + Assert — flip ApiLevel on the singleton settings between calls (they alias the - // same instance, so we must re-read after each set rather than capture both up front). - settings.ShowApiLevel = ApiVisibility.PublicOnly; - privateNode.Filter(settings).Should().Be(FilterResult.Hidden); - settings.ShowApiLevel = ApiVisibility.All; - privateNode.Filter(settings).Should().Be(FilterResult.Match); - } - [AvaloniaTest] public async Task Filter_PublicAndInternal_Shows_NonCompilerGenerated_Private_Members() { From 00dadd99a1ef701b9ec3771358166f6a4402298f Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Mon, 19 Oct 2026 21:43:43 +0200 Subject: [PATCH 4/6] Merge UI tests that share one arrangement and one action Each merged pair booted the app, built the same state and drove the same action, differing only in which property they then asserted; one test now carries all of those assertions, so the shared setup runs once. The package tests in AssemblyTreeModelTests also built the same zip (two emitted assemblies) per test, which is immutable input and now built once per fixture. #4262 Single-process wall clock on the same machine: 251 s -> 256 s, inside the 20 s run-to-run noise; the sum of test durations went 249 s -> 253 s with 15 tests fewer, so this step is about not re-arranging the same state rather than about seconds. Assisted-by: Claude:claude-fable-5-1:Claude Code --- .../Analyzers/AnalyzeContextMenuTests.cs | 17 +-- ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs | 125 +++++------------- .../AssemblyTree/AssemblyTreeModelTests.cs | 51 ++----- ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs | 95 ++----------- .../ContextMenus/DecompileInNewViewTests.cs | 44 +----- .../Docking/PreviewTabPromotionTests.cs | 64 ++------- ILSpy.Tests/Editor/DecompilerViewTests.cs | 69 +++------- ILSpy.Tests/Editor/ReferenceClickTests.cs | 23 +--- .../BrowseBackForwardCommandTests.cs | 22 --- ILSpy.Tests/Navigation/NavigationTests.cs | 14 +- 10 files changed, 109 insertions(+), 415 deletions(-) diff --git a/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs index a047330b3a8..6862193095f 100644 --- a/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs @@ -48,7 +48,7 @@ namespace ICSharpCode.ILSpy.Tests.Analyzers; public class AnalyzeContextMenuTests { [AvaloniaTest] - public async Task Analyze_Entry_Is_Visible_For_Member_Tree_Nodes_And_Hidden_For_AssemblyTreeNode() + public async Task Analyze_Entry_Is_Visible_And_Enabled_Only_For_Member_Tree_Nodes() { // AnalyzeContextMenuEntry visibility contract: visible when every selected node is // an IMemberTreeNode (types, methods, fields, properties, events), hidden otherwise. @@ -71,25 +71,12 @@ public async Task Analyze_Entry_Is_Visible_For_Member_Tree_Nodes_And_Hidden_For_ .Should().BeFalse("no selection means no entity to analyse"); entry.IsVisible(new TextViewContext { SelectedTreeNodes = System.Array.Empty() }) .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() - .GetEntry(nameof(Resources.Analyze)); - - var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); - var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); var mixed = new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode, assemblyNode } }; - - entry.IsVisible(mixed).Should().BeFalse("precondition: a mixed selection hides the entry"); + entry.IsVisible(mixed).Should().BeFalse("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"); diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs index 55010e58c75..3998b6b82cf 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs @@ -56,7 +56,7 @@ namespace ICSharpCode.ILSpy.Tests; public class AssemblyTreeTests { [AvaloniaTest] - public async Task Assembly_Node_Has_Resources_Folder_When_Module_Has_Resources() + public async Task Assembly_Node_Has_Resources_Folder_With_Resolvable_Resources_Files() { // Assemblies with embedded resources expose a "Resources" folder under the assembly // node. Verifies the folder is present, contains entries, and every entry is an @@ -76,6 +76,20 @@ public async Task Assembly_Node_Has_Resources_Folder_When_Module_Has_Resources() resources.Expand(); resources.Children.Should().NotBeEmpty(); resources.Children.Should().AllBeAssignableTo(); + + // .resources entries get the dedicated ResourcesFileTreeNode handler, which exposes a + // string-table or inner ResourceEntryNode children (depending on payload type). + var resourceFileNode = resources.Children.OfType().FirstOrDefault(); + ((object?)resourceFileNode).Should().NotBeNull( + "CoreLib must ship at least one embedded .resources file"); + resourceFileNode!.Resource.Name.Should().EndWith(".resources"); + + resourceFileNode.EnsureLazyChildren(); + + // Either inner children or string-table entries — at least one branch must surface + // non-empty content. + (resourceFileNode.Children.Count > 0 || resourceFileNode.StringTableEntries.Count > 0) + .Should().BeTrue("a .resources file must surface either inner ResourceEntryNode children or string-table entries"); } [AvaloniaTest] @@ -176,35 +190,7 @@ public async Task Loading_Zip_Package_Surfaces_Folders_And_Entries() } [AvaloniaTest] - public async Task Dot_Resources_File_Resolves_To_ResourcesFileTreeNode() - { - // .resources entries get the dedicated ResourcesFileTreeNode handler, which exposes a - // string-table or inner ResourceEntryNode children (depending on payload type). - - // Arrange — boot, wait for assemblies, expand CoreLib's Resources folder. - var (_, vm) = await TestHarness.BootAsync(3); - - var assemblyNode = vm.AssemblyTreeModel.FindCoreLib(); - assemblyNode.Expand(); - var resources = assemblyNode.Children.OfType().Single(); - resources.Expand(); - - // Act — look up the first .resources file and select it. - var resourceFileNode = resources.Children.OfType().FirstOrDefault(); - ((object?)resourceFileNode).Should().NotBeNull( - "CoreLib must ship at least one embedded .resources file"); - resourceFileNode!.Resource.Name.Should().EndWith(".resources"); - - resourceFileNode.EnsureLazyChildren(); - - // Assert — either inner children or string-table entries — at least one branch must - // surface non-empty content. - (resourceFileNode.Children.Count > 0 || resourceFileNode.StringTableEntries.Count > 0) - .Should().BeTrue("a .resources file must surface either inner ResourceEntryNode children or string-table entries"); - } - - [AvaloniaTest] - public async Task Assembly_Node_Has_References_Folder_Child() + public async Task Assembly_Node_Has_References_Folder_With_Referenced_Types() { // Each assembly node exposes a "References" folder child whose children are // AssemblyReferenceTreeNodes — one per AssemblyRef row. @@ -224,6 +210,19 @@ public async Task Assembly_Node_Has_References_Folder_Child() refFolder.Expand(); refFolder.Children.Should().NotBeEmpty(); refFolder.Children.Should().AllBeAssignableTo(); + + // Each AssemblyReferenceTreeNode contains a "Referenced Types" subnode that lists the + // TypeRef rows the host assembly imports from that reference. + var refNode = refFolder.Children.OfType() + .First(n => n.AssemblyReference.Name == "System.Runtime"); + refNode.Expand(); + + var typesNode = refNode.Children.OfType().Single(); + typesNode.Expand(); + + typesNode.Text.ToString().Should().Contain(Resources.ReferencedTypes); + typesNode.Children.Should().NotBeEmpty(); + typesNode.Children.Should().Contain(c => c is TypeReferenceTreeNode); } [AvaloniaTest] @@ -257,35 +256,6 @@ await Waiters.WaitForAsync(() => refNode.Icon.Should().BeSameAs(ICSharpCode.ILSpy.Images.Assembly); } - [AvaloniaTest] - public async Task Assembly_Reference_Has_Referenced_Types_Subnode() - { - // Each AssemblyReferenceTreeNode contains a "Referenced Types" subnode that lists the - // TypeRef rows the host assembly imports from that reference. - - // Arrange — boot, wait for assemblies, expand System.Linq → References → System.Runtime. - var (_, vm) = await TestHarness.BootAsync(3); - - var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); - assemblyNode.Expand(); - var refFolder = assemblyNode.Children.OfType().Single(); - refFolder.Expand(); - - var refNode = refFolder.Children.OfType() - .First(n => n.AssemblyReference.Name == "System.Runtime"); - refNode.Expand(); - - // Act — find the ReferencedTypes subnode and expand it. - var typesNode = refNode.Children.OfType().Single(); - typesNode.Expand(); - - // Assert — header text contains the expected label, and the children include at least - // one TypeReferenceTreeNode. - typesNode.Text.ToString().Should().Contain(Resources.ReferencedTypes); - typesNode.Children.Should().NotBeEmpty(); - typesNode.Children.Should().Contain(c => c is TypeReferenceTreeNode); - } - [AvaloniaTest] public async Task FindNamespaceNode_Returns_The_Tree_Node_For_A_Loaded_Namespace() { @@ -486,7 +456,7 @@ sealed class StubRoutedEventArgs : ICSharpCode.ILSpyX.TreeView.PlatformAbstracti } [AvaloniaTest] - public async Task Selecting_Method_Auto_Loads_Referenced_Assemblies() + public async Task Selecting_Method_Auto_Loads_Referenced_Assemblies_Which_Are_Not_Persisted() { // Decompiling a method whose body references types from currently-unloaded assemblies // must auto-load those assemblies (so the decompiler can resolve them) — and the @@ -531,35 +501,10 @@ await Waiters.WaitForAsync(() => var node = vm.AssemblyTreeModel.FindNode(auto.ShortName); node.IsAutoLoaded.Should().BeTrue(); } - } - [AvaloniaTest] - public async Task Auto_Loaded_Assemblies_Are_Not_Persisted() - { // Auto-loaded assemblies are an artefact of an in-flight decompile, not of the user's // curated list — saving the assembly list must omit them while keeping the ones the // user actually opened. - - // Arrange — boot, wait for assemblies, open + select a method that triggers auto-load. - var (_, vm) = await TestHarness.BootAsync(3); - - var newAsmPath = typeof(System.Net.Http.HttpClient).Assembly.Location; - var loaded = await vm.OpenAssemblyAsync(newAsmPath); - - var typeNode = vm.AssemblyTreeModel.FindNode( - loaded.ShortName, "System.Net.Http", "System.Net.Http.HttpClient"); - typeNode.Expand(); - var methodNode = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "CancelPendingRequests"); - - vm.AssemblyTreeModel.SelectNode(methodNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); - - await Waiters.WaitForAsync(() => - vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Any(a => a.IsAutoLoaded)); - - // Act — save the assembly list to disk, then read the file back and tally the persisted - // FileName entries. var settingsService = AppComposition.Current.GetExport(); var manager = settingsService.AssemblyListManager; manager.SaveList(vm.AssemblyTreeModel.AssemblyList!); @@ -570,15 +515,9 @@ await Waiters.WaitForAsync(() => .Select(e => e.Value) .ToHashSet(System.StringComparer.OrdinalIgnoreCase); - // Assert — every IsAutoLoaded assembly was omitted; every manual one is present. - var assemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies(); - var autoLoaded = assemblies.Where(a => a.IsAutoLoaded).ToList(); - autoLoaded.Should().NotBeEmpty(); - foreach (var asm in autoLoaded) + foreach (var asm in addedAssemblies) savedFiles.Should().NotContain(asm.FileName, "auto-loaded assembly {0} must not be persisted", asm.FileName); - - var manualLoaded = assemblies.Where(a => !a.IsAutoLoaded).ToList(); - foreach (var asm in manualLoaded) + foreach (var asm in vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Where(a => !a.IsAutoLoaded)) savedFiles.Should().Contain(asm.FileName, "manually loaded assembly {0} must be persisted", asm.FileName); } diff --git a/ILSpy.Tests/AssemblyTree/AssemblyTreeModelTests.cs b/ILSpy.Tests/AssemblyTree/AssemblyTreeModelTests.cs index 3e4e7f98b40..c2dba625eee 100644 --- a/ILSpy.Tests/AssemblyTree/AssemblyTreeModelTests.cs +++ b/ILSpy.Tests/AssemblyTree/AssemblyTreeModelTests.cs @@ -75,15 +75,18 @@ public void Initialize_populates_AssemblyLists_and_selects_the_default() static readonly string TempRoot = Path.Combine(Path.GetTempPath(), "ILSpy.Tests.Packages", Guid.NewGuid().ToString("N")); - // Two assemblies in sibling folder chains, so a walk that reaches only one of them fails. - static string CreatePackage() + // Two assemblies in sibling folder chains, so a walk that reaches only one of them fails. The + // package is immutable input, so one per fixture serves every test. + static string packagePath = null!; + + [OneTimeSetUp] + public void CreatePackage() { - var dir = Directory.CreateDirectory(Path.Combine(TempRoot, Guid.NewGuid().ToString("N"))).FullName; - var zipPath = Path.Combine(dir, "package.zip"); - using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create); + var dir = Directory.CreateDirectory(TempRoot).FullName; + packagePath = Path.Combine(dir, "package.zip"); + using var zip = ZipFile.Open(packagePath, ZipArchiveMode.Create); zip.CreateEntryFromFile(FixtureAssembly.Emit("Nested"), "lib/net10.0/Nested.dll"); zip.CreateEntryFromFile(FixtureAssembly.Emit("Sibling"), "runtimes/win-x64/Sibling.dll"); - return zipPath; } [OneTimeTearDown] @@ -99,7 +102,7 @@ public async Task EnumerateAllAssemblies_expands_a_package_into_its_entries() // The search corpus is this walk, so an assembly it does not yield is an assembly no // search can ever match. var (_, vm) = await TestHarness.BootAsync(); - await vm.OpenAssemblyAsync(CreatePackage()); + await vm.OpenAssemblyAsync(packagePath); var nested = new List(); await foreach (var asm in vm.AssemblyTreeModel.AssemblyList!.EnumerateAllAssemblies()) @@ -120,7 +123,7 @@ public async Task EnumerateAllAssemblies_stops_expanding_a_package_once_cancelle // Both search panes restart on every keystroke, so an abandoned walk that keeps // extracting package entries competes with the run the user is waiting for. var (_, vm) = await TestHarness.BootAsync(); - await vm.OpenAssemblyAsync(CreatePackage()); + await vm.OpenAssemblyAsync(packagePath); using var cts = new CancellationTokenSource(); var walk = vm.AssemblyTreeModel.AssemblyList!.EnumerateAllAssemblies(cts.Token).GetAsyncEnumerator(); @@ -144,12 +147,12 @@ await next.Should().ThrowAsync( } [AvaloniaTest] - public async Task FindTreeNode_resolves_a_type_inside_an_unexpanded_package() + public async Task FindTreeNode_resolves_a_type_inside_an_unexpanded_package_and_the_node_decompiles() { // Search enumerates the contents of packages whether or not the user ever opened them in // the tree, so activating such a result has to reach a node that does not exist yet. var (_, vm) = await TestHarness.BootAsync(); - await vm.OpenAssemblyAsync(CreatePackage()); + var package = await vm.OpenAssemblyAsync(packagePath); var nested = (await vm.AssemblyTreeModel.AssemblyList!.GetAllAssemblies()) .Single(a => a.FileName == "lib/net10.0/Nested.dll"); @@ -164,43 +167,15 @@ public async Task FindTreeNode_resolves_a_type_inside_an_unexpanded_package() ((object?)node).Should().BeOfType() .Which.Module.Should().BeSameAs(nested.GetMetadataFileOrNull(), "the lookup must descend into the package's folders, expanding them on the way."); - } - [AvaloniaTest] - public async Task FindTreeNode_leaves_package_folders_off_the_path_unexpanded() - { // Expanding a package folder resolves and extracts every .dll it holds, so a lookup that // swept the package depth-first would pay for entries the user never asked about. - var (_, vm) = await TestHarness.BootAsync(); - var package = await vm.OpenAssemblyAsync(CreatePackage()); - - var nested = (await vm.AssemblyTreeModel.AssemblyList!.GetAllAssemblies()) - .Single(a => a.FileName == "lib/net10.0/Nested.dll"); - var type = nested.GetTypeSystemOrNull()!.MainModule.TypeDefinitions - .Single(t => t.Name == FixtureAssembly.TypeName); - - ((object?)vm.AssemblyTreeModel.FindTreeNode(type)).Should().NotBeNull(); - var packageNode = vm.AssemblyTreeModel.FindAssemblyNode(package); ((object?)packageNode).Should().NotBeNull(); var sibling = packageNode!.Children.OfType() .Single(f => f.Text as string == "runtimes/win-x64"); sibling.Children.Should().BeEmpty( "only the folders on the path down to the target get expanded."); - } - - [AvaloniaTest] - public async Task Selecting_a_type_inside_a_package_entry_decompiles_it() - { - var (_, vm) = await TestHarness.BootAsync(); - await vm.OpenAssemblyAsync(CreatePackage()); - - var nested = (await vm.AssemblyTreeModel.AssemblyList!.GetAllAssemblies()) - .Single(a => a.FileName == "lib/net10.0/Nested.dll"); - var type = nested.GetTypeSystemOrNull()!.MainModule.TypeDefinitions - .Single(t => t.Name == FixtureAssembly.TypeName); - var node = vm.AssemblyTreeModel.FindTreeNode(type); - ((object?)node).Should().BeOfType(); vm.AssemblyTreeModel.SelectNode(node!); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); diff --git a/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs index b4d7edce76c..774c8a00503 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs @@ -45,9 +45,9 @@ namespace ICSharpCode.ILSpy.Tests.Bookmarks; public class BookmarkGutterTests { [AvaloniaTest] - public async Task Clicking_Bookmark_Gutter_Toggles_The_Visible_Line() + public async Task Gutter_Clicks_Toggle_Bookmarks_And_A_Removed_Line_Hides_Its_Hover_Preview_Until_The_Pointer_Leaves() { - var (window, vm) = await TestHarness.BootAsync(3); + var (window, vm) = await TestHarness.BootAsync(); var manager = AppComposition.Current.GetExport(); manager.Clear(); @@ -59,84 +59,6 @@ public async Task Clicking_Bookmark_Gutter_Toggles_The_Visible_Line() var margin = view.Editor.TextArea.LeftMargins.OfType().Single(); margin.Bounds.Width.Should().BeGreaterThan(0, "the bookmark gutter must reserve hit-testable space"); - - var textView = view.Editor.TextArea.TextView; - textView.EnsureVisualLines(); - var visualLine = textView.VisualLines.First(line => view.CanToggleBookmarkAtLine(line.FirstDocumentLine.LineNumber)); - int documentLine = visualLine.FirstDocumentLine.LineNumber; - double y = visualLine.GetTextLineVisualYPosition(visualLine.TextLines[0], VisualYPosition.LineMiddle) - textView.VerticalOffset; - y.Should().BeInRange(0, margin.Bounds.Height, "the selected visual line must be inside the gutter bounds"); - var point = margin.TranslatePoint(new Point(margin.Bounds.Width / 2, y), window); - point.Should().NotBeNull("the gutter line coordinate must map into the test window"); - - int pressed = 0; - margin.AddHandler(InputElement.PointerPressedEvent, (_, _) => pressed++, RoutingStrategies.Tunnel | RoutingStrategies.Bubble); - - window.MouseDown(point!.Value, MouseButton.Left); - window.MouseUp(point.Value, MouseButton.Left); - Dispatcher.UIThread.RunJobs(); - - pressed.Should().BeGreaterThan(0, "the real pointer event must hit the bookmark gutter"); - manager.Bookmarks.Should().ContainSingle(); - view.GetLineForBookmark(manager.Bookmarks[0]).Should().Be(documentLine); - - window.MouseDown(point.Value, MouseButton.Left); - window.MouseUp(point.Value, MouseButton.Left); - Dispatcher.UIThread.RunJobs(); - - manager.Bookmarks.Should().BeEmpty(); - } - - [AvaloniaTest] - public async Task RightClicking_Bookmark_Gutter_Does_Not_Toggle() - { - var (window, vm) = await TestHarness.BootAsync(3); - var manager = AppComposition.Current.GetExport(); - manager.Clear(); - - await vm.ShowSampleAsync(typeof(DecompileSample)); - - var view = await window.WaitForComponent(); - await Waiters.WaitForIdleAsync(); - window.UpdateLayout(); - - var margin = view.Editor.TextArea.LeftMargins.OfType().Single(); - var textView = view.Editor.TextArea.TextView; - textView.EnsureVisualLines(); - var visualLine = textView.VisualLines.First(line => view.CanToggleBookmarkAtLine(line.FirstDocumentLine.LineNumber)); - double y = visualLine.GetTextLineVisualYPosition(visualLine.TextLines[0], VisualYPosition.LineMiddle) - textView.VerticalOffset; - var point = margin.TranslatePoint(new Point(margin.Bounds.Width / 2, y), window); - point.Should().NotBeNull("the gutter line coordinate must map into the test window"); - - window.MouseDown(point!.Value, MouseButton.Right); - window.MouseUp(point.Value, MouseButton.Right); - Dispatcher.UIThread.RunJobs(); - - manager.Bookmarks.Should().BeEmpty("a right-click in the gutter must not toggle a bookmark"); - - // The same coordinate still toggles on a left-click, proving the gesture is button-gated - // rather than disabled at that line. - window.MouseDown(point.Value, MouseButton.Left); - window.MouseUp(point.Value, MouseButton.Left); - Dispatcher.UIThread.RunJobs(); - - manager.Bookmarks.Should().ContainSingle("a left-click at the same coordinate still toggles"); - } - - [AvaloniaTest] - public async Task Removing_A_Bookmark_From_The_Gutter_Hides_Its_Hover_Preview_Until_The_Pointer_Leaves_The_Line() - { - var (window, vm) = await TestHarness.BootAsync(3); - var manager = AppComposition.Current.GetExport(); - manager.Clear(); - - await vm.ShowSampleAsync(typeof(DecompileSample)); - - var view = await window.WaitForComponent(); - await Waiters.WaitForIdleAsync(); - window.UpdateLayout(); - - var margin = view.Editor.TextArea.LeftMargins.OfType().Single(); var textView = view.Editor.TextArea.TextView; textView.EnsureVisualLines(); var bookmarkable = textView.VisualLines @@ -163,11 +85,22 @@ Point GutterPoint(VisualLine line) Dispatcher.UIThread.RunJobs(); margin.HoverPreviewLine.Should().Be(line0, "hovering a bookmarkable line previews its glyph"); - // Click adds the bookmark; the pointer stays on the line. + // A right-click in the gutter must not toggle: the gesture is button-gated. + window.MouseDown(point0, MouseButton.Right); + window.MouseUp(point0, MouseButton.Right); + Dispatcher.UIThread.RunJobs(); + manager.Bookmarks.Should().BeEmpty("a right-click in the gutter must not toggle a bookmark"); + + // A left-click at the same coordinate adds the bookmark on that line; the pointer stays on + // the line. + int pressed = 0; + margin.AddHandler(InputElement.PointerPressedEvent, (_, _) => pressed++, RoutingStrategies.Tunnel | RoutingStrategies.Bubble); window.MouseDown(point0, MouseButton.Left); window.MouseUp(point0, MouseButton.Left); Dispatcher.UIThread.RunJobs(); + pressed.Should().BeGreaterThan(0, "the real pointer event must hit the bookmark gutter"); manager.Bookmarks.Should().ContainSingle(); + view.GetLineForBookmark(manager.Bookmarks[0]).Should().Be(line0); // Clicking again removes it: the line must visibly empty rather than redraw a ghost. window.MouseDown(point0, MouseButton.Left); diff --git a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs index 588d930e3db..90f2fbfbf2b 100644 --- a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs +++ b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs @@ -338,13 +338,14 @@ public async Task Opening_A_Node_In_A_New_Tab_Syncs_The_Tree_Selection_To_It() } [AvaloniaTest] - public async Task Activating_A_Single_Node_Tab_Syncs_The_Tree_After_A_Multi_Node_Selection() + public async Task Activating_Tabs_After_A_Multi_Node_Selection_Syncs_The_Tree_Both_Ways() { // Regression: once the tree has held a multi-selection (e.g. a tab decompiled from // several nodes), activating a single-node tab must still pull the tree selection over. // The SelectedItem setter replaces the collection via Clear()+Add(); for the count>1 case // that exposed a transient empty selection, whose grid sync deferred its done-flag and // then suppressed the sync for the real value -- so the tree stopped following the tab. + // Re-activating the multi-node tab afterwards must bring both original nodes back. var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; @@ -357,13 +358,14 @@ public async Task Activating_A_Single_Node_Tab_Syncs_The_Tree_After_A_Multi_Node var nodeB = assemblies[1]; var nodeC = assemblies[2]; - // Hold a multi-selection (A + B), as a multi-node decompile tab would. + // Hold a multi-selection (A + B): the active (preview) tab decompiles both. vm.AssemblyTreeModel.SelectedItems.Clear(); vm.AssemblyTreeModel.SelectedItems.Add(nodeA); vm.AssemblyTreeModel.SelectedItems.Add(nodeB); await vm.DockWorkspace.WaitForDecompiledTextAsync(); await Waiters.WaitForIdleAsync(); vm.AssemblyTreeModel.SelectedItems.Count.Should().Be(2, "precondition: a multi-selection is held"); + var multiTab = (ContentTabPage)((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!.ActiveDockable!; // Open C in a new tab -> activates it -> the tree must follow to C. var registry = AppComposition.Current.GetExport(); @@ -378,48 +380,16 @@ public async Task Activating_A_Single_Node_Tab_Syncs_The_Tree_After_A_Multi_Node "activating the single-node tab must collapse the multi-selection to that one node"); ReferenceEquals(GridSelectedNode(grid), nodeC).Should().BeTrue( "the grid's visual selection must follow even after a prior multi-selection"); - } - [AvaloniaTest] - public async Task Activating_A_Multi_Node_Tab_Restores_Its_Multi_Selection_In_The_Tree() - { - // A tab decompiled from several nodes carries no single SourceNode, so activating it has - // to restore the WHOLE set in the tree -- not nothing, and not just one. Scenario: select - // two nodes (multi-node preview tab), open a third in a new tab, then re-activate the - // multi-node tab; the tree selection must come back to both original nodes. - var window = AppComposition.Current.GetExport(); - window.Show(); - var vm = (MainWindowViewModel)window.DataContext!; - await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); - var pane = await window.WaitForComponent(); - var grid = await pane.WaitForComponent(); + // A tab decompiled from several nodes carries no single SourceNode, so re-activating it + // has to restore the WHOLE set in the tree -- not nothing, and not just one. var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; - - var assemblies = await OpenFixtureRowsAsync(vm); - var nodeA = assemblies[0]; - var nodeB = assemblies[1]; - var nodeC = assemblies[2]; - - // 1) Multi-select A + B -> the active (preview) tab decompiles both. - vm.AssemblyTreeModel.SelectedItems.Clear(); - vm.AssemblyTreeModel.SelectedItems.Add(nodeA); - vm.AssemblyTreeModel.SelectedItems.Add(nodeB); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); - var multiTab = (ContentTabPage)documents.ActiveDockable!; - - // 2) Open C in a new (single-node) tab -> it becomes active, tree -> C. - var registry = AppComposition.Current.GetExport(); - var menu = pane.BuildContextMenuForCurrentState(registry.Entries, rightClickedNode: nodeC); - menu!.ClickItem(Resources.DecompileToNewPanel); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); ReferenceEquals(multiTab, documents.ActiveDockable).Should().BeFalse( "precondition: the new single-node tab is active, not the multi-node one"); - - // 3) Re-activate the multi-node tab. vm.DockWorkspace.Factory.SetActiveDockable(multiTab); await Waiters.WaitForIdleAsync(); - // 4) The tree selection must contain BOTH original nodes again -- in the model... + // The tree selection must contain BOTH original nodes again -- in the model... vm.AssemblyTreeModel.SelectedItems.Should().Contain(nodeA) .And.Contain(nodeB); vm.AssemblyTreeModel.SelectedItems.Count.Should().Be(2, diff --git a/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs b/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs index 51b53b9cd58..0cb620d0ef8 100644 --- a/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs +++ b/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs @@ -51,13 +51,15 @@ public async Task Selecting_A_Different_Node_After_Freeze_Opens_A_New_Preview_Ta var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; - // Phase 1: load type A into MainTab. + // Phase 1: load type A into MainTab. The One lives at documents-dock index 0. var typeA = await vm.OpenSampleAsync(typeof(DecompileSample)); vm.AssemblyTreeModel.SelectNode(typeA); vm.DockWorkspace.SettleSelection(); TestCapture.Step("type-a-selected"); var frozenTab = factory.MainTab!; var frozenContent = frozenTab.Content; + vm.DockWorkspace.Documents!.VisibleDockables!.IndexOf(frozenTab).Should().Be(0, + "the One is the leftmost document tab"); // Phase 2: freeze. Freeze only flips IsPreview on the current MainTab: factory.MainTab // still points at the same (now-frozen) tab and no new tab spawns yet. @@ -92,6 +94,10 @@ public async Task Selecting_A_Different_Node_After_Freeze_Opens_A_New_Preview_Ta "the frozen tab must keep type A — not be overwritten by the new selection"); frozenTab.Content.Should().BeSameAs(frozenContent, "frozen tab's Content reference must survive subsequent tree selections"); + vm.DockWorkspace.Documents!.VisibleDockables!.IndexOf(newMainTab).Should().Be(0, + "the fresh One must be created at index 0, leftmost"); + vm.DockWorkspace.Documents!.VisibleDockables!.IndexOf(frozenTab).Should().BeGreaterThan(0, + "the frozen ex-One slides to the right of the new One"); } [AvaloniaTest] @@ -425,7 +431,7 @@ public async Task File_Menu_Separates_MenuCategory_Groups() } [AvaloniaTest] - public async Task Freeze_Button_Inherits_The_Close_Button_Theme() + public async Task Freeze_Button_Inherits_The_Close_Button_Theme_And_Close_Names_Its_Shortcut() { // Visual parity: the freeze button should look like the close button — same size, same // hover background. Both are plain Avalonia.Controls.Button instances; the close @@ -459,33 +465,9 @@ await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType window.GetVisualDescendants().OfType().Count() >= 2, - System.TimeSpan.FromSeconds(10)); - global::Avalonia.Threading.Dispatcher.UIThread.RunJobs(); - - var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; - var mainTabItem = window.GetVisualDescendants().OfType() - .Single(item => ReferenceEquals(item.DataContext, factory.MainTab)); - - var closeButton = mainTabItem.GetVisualDescendants() - .OfType() - .FirstOrDefault(b => (b.Tag as string) != "PreviewTabFreezeButton"); - closeButton.Should().NotBeNull("baseline: close button exists as a sibling of the freeze"); - global::Avalonia.Controls.ToolTip.GetTip(closeButton!).Should().Be("Close (Ctrl+W)", "the close button must name its Ctrl+W shortcut on hover"); } @@ -582,36 +564,6 @@ public async Task Tree_Selection_While_Frozen_Tab_Active_Reuses_The_One() "reusing the One activates it"); } - [AvaloniaTest] - public async Task The_Preview_Is_Always_At_Index_0() - { - // The One lives at documents-dock index 0. After it is frozen, the next selection forges - // a fresh One -- which must again land at index 0 (the frozen ex-One slides to the right). - var (_, vm) = await TestHarness.BootAsync(3); - var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; - var docs = vm.DockWorkspace.Documents!; - - var typeA = await vm.OpenSampleAsync(typeof(DecompileSample)); - vm.AssemblyTreeModel.SelectNode(typeA); - vm.DockWorkspace.SettleSelection(); - docs.VisibleDockables!.IndexOf(factory.MainTab!).Should().Be(0, - "the One is the leftmost document tab"); - - // Freeze it, then select a different node -> a fresh One must appear at index 0. - vm.DockWorkspace.FreezeCurrentTab(); - var frozen = factory.MainTab!; - var typeB = await vm.OpenSampleAsync(typeof(DecompileSampleOther)); - vm.AssemblyTreeModel.SelectNode(typeB); - vm.DockWorkspace.SettleSelection(); - - ReferenceEquals(factory.MainTab, frozen).Should().BeFalse("a fresh One was forged"); - factory.MainTab!.IsPreview.Should().BeTrue("the fresh One is a preview tab"); - docs.VisibleDockables!.IndexOf(factory.MainTab!).Should().Be(0, - "the fresh One must be created at index 0, leftmost"); - docs.VisibleDockables!.IndexOf(frozen).Should().BeGreaterThan(0, - "the frozen ex-One slides to the right of the new One"); - } - [AvaloniaTest] public async Task Dragging_The_Preview_Freezes_It() { diff --git a/ILSpy.Tests/Editor/DecompilerViewTests.cs b/ILSpy.Tests/Editor/DecompilerViewTests.cs index 0e73aee6a25..f583fe65225 100644 --- a/ILSpy.Tests/Editor/DecompilerViewTests.cs +++ b/ILSpy.Tests/Editor/DecompilerViewTests.cs @@ -98,25 +98,6 @@ public async Task Selecting_References_Folder_Decompiles_References_Listing() tab.Text.Should().Contain("Assembly load log including transitive references:"); } - [AvaloniaTest] - public async Task Selecting_Type_Node_Decompiles_Type_Definition() - { - // Selecting a TypeTreeNode must decompile the full class declaration including - // representative members. - - // Arrange — boot. - var (_, vm) = await TestHarness.BootAsync(); - - // Act — select the type node, wait for the decompile. - var (_, tab) = await vm.ShowSampleAsync(typeof(DecompileSample)); - TestCapture.Step("sample-class"); - - // Assert — class declaration + representative member names land in the output. - tab.Text.Should().Contain("class " + nameof(DecompileSample)); - tab.Text.Should().Contain(nameof(DecompileSample.Name)); - tab.Text.Should().Contain(nameof(DecompileSample.Count)); - } - [AvaloniaTest] public async Task Selecting_Property_Node_Decompiles_Property() { @@ -328,42 +309,15 @@ void OnTabPropertyChanged(object? s, PropertyChangedEventArgs e) } } - [AvaloniaTest] - public async Task Multi_Selection_Tab_Title_Joins_All_Selected_Names_With_Comma() - { - // Selecting several nodes via Ctrl+Click feeds them all into one decompile. The tab - // title must list every selected node's display text comma-joined. - - // Arrange — boot, expand the sample type, grab two methods. - var (_, vm) = await TestHarness.BootAsync(3); - - var typeNode = (await vm.OpenSampleAsync(typeof(DecompileSample))).Expand(); - var describe = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe)); - var twice = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice)); - - // Act — replace the selection with both nodes. - vm.AssemblyTreeModel.SelectedItems.Clear(); - vm.AssemblyTreeModel.SelectedItems.Add(describe); - vm.AssemblyTreeModel.SelectedItems.Add(twice); - TestCapture.Step("multi-selected"); - - var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - TestCapture.Step("multi-selection-title"); - - // Assert — title is the two display texts joined with ", ". - tab.Title.Should().Be($"{describe.Text}, {twice.Text}"); - } - [AvaloniaTest] public async Task Multi_Selecting_Methods_Decompiles_All_Of_Them_Into_One_View() { - // Multi-selection isn't just a title concern — both methods' bodies must actually be - // decompiled into one combined document. + // Selecting several nodes via Ctrl+Click feeds them all into one decompile: the tab + // title lists every selected node's display text comma-joined, and both methods' + // bodies land in one combined document. // Arrange — boot, expand the sample type, grab two methods. - var (_, vm) = await TestHarness.BootAsync(3); + var (_, vm) = await TestHarness.BootAsync(); var typeNode = (await vm.OpenSampleAsync(typeof(DecompileSample))).Expand(); var describe = typeNode.Children.OfType() @@ -380,8 +334,10 @@ public async Task Multi_Selecting_Methods_Decompiles_All_Of_Them_Into_One_View() var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); TestCapture.Step("multi-selection-decompiled"); - // Assert — both names AND a body-fragment from one of them confirm both bodies - // actually rendered (not just signatures). + // Assert — the title is the two display texts joined with ", "; both names AND a + // body-fragment from one of them confirm both bodies actually rendered (not just + // signatures). + tab.Title.Should().Be($"{describe.Text}, {twice.Text}"); tab.Text.Should().Contain(nameof(DecompileSample.Describe)); tab.Text.Should().Contain(nameof(DecompileSample.Twice)); tab.Text.Should().Contain("text.Length"); @@ -465,13 +421,20 @@ public async Task Expand_Display_Settings_Reach_The_Decompiler_Foldings() // sets each fold's DefaultClosed from settings.ExpandUsingDeclarations / // ExpandMemberDefinitions. So the live decompile must bridge them in from DisplaySettings; // without the bridge they default false and every fold comes back collapsed. - var (_, vm) = await TestHarness.BootAsync(3); + var (_, vm) = await TestHarness.BootAsync(); var settings = AppComposition.Current.GetExport(); settings.DisplaySettings.ExpandMemberDefinitions = true; settings.DisplaySettings.ExpandUsingDeclarations = true; var (_, tab) = await vm.ShowSampleAsync(typeof(DecompileSample)); + TestCapture.Step("sample-class"); + + // Selecting a TypeTreeNode decompiles the full class declaration including its members + // (the expand flags change only the fold state, not the text). + tab.Text.Should().Contain("class " + nameof(DecompileSample)); + tab.Text.Should().Contain(nameof(DecompileSample.Name)); + tab.Text.Should().Contain(nameof(DecompileSample.Count)); tab.Foldings.Should().NotBeNull(); var foldings = tab.Foldings!; diff --git a/ILSpy.Tests/Editor/ReferenceClickTests.cs b/ILSpy.Tests/Editor/ReferenceClickTests.cs index 215fde7262b..3a177749e4d 100644 --- a/ILSpy.Tests/Editor/ReferenceClickTests.cs +++ b/ILSpy.Tests/Editor/ReferenceClickTests.cs @@ -119,6 +119,12 @@ public async Task Stationary_Click_On_A_Link_Navigates() args.Reference.Should().BeSameAs(segment.Reference); args.Source.Should().BeNull(); args.InNewTabPage.Should().BeFalse(); + + // The gesture must end cleanly so that afterwards moving the mouse with no button held + // does not extend a selection (issue #3793). + window.MouseMove(point + new Point(60, 0)); + view.Editor.TextArea.Selection.IsEmpty.Should().BeTrue( + "moving the mouse after a click, with no button held, must not select text"); } [AvaloniaTest] @@ -155,23 +161,6 @@ public async Task Ctrl_Middle_Click_On_A_Link_Navigates_In_New_Tab() args!.InNewTabPage.Should().BeTrue("middle-click chooses the target tab; Ctrl only forces navigation over highlighting"); } - [AvaloniaTest] - public async Task Click_On_A_Link_Then_Moving_The_Mouse_Does_Not_Select_Text() - { - var (window, view, tab) = await SetupAsync(); - var (_, point) = FindVisibleReference(window, view, tab); - tab.NavigateRequested += (_, _) => { }; - - // A stationary click navigates; the gesture must end cleanly so that afterwards moving - // the mouse with no button held does not extend a selection (issue #3793). - window.MouseDown(point, MouseButton.Left); - window.MouseUp(point, MouseButton.Left); - window.MouseMove(point + new Point(60, 0)); - - view.Editor.TextArea.Selection.IsEmpty.Should().BeTrue( - "moving the mouse after a click, with no button held, must not select text"); - } - /// /// Scrolls the document until a navigable reference sits behind /// (a visual that covers part of the text area) and returns a point inside the reference's diff --git a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs index a252ffadebf..d5a21a69268 100644 --- a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs +++ b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs @@ -165,28 +165,6 @@ await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedIt "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"); - } - [AvaloniaTest] public async Task Browse_Back_Keeps_The_Keyboard_Focus_In_The_Editor() { diff --git a/ILSpy.Tests/Navigation/NavigationTests.cs b/ILSpy.Tests/Navigation/NavigationTests.cs index 985994234c0..b8abf7695b1 100644 --- a/ILSpy.Tests/Navigation/NavigationTests.cs +++ b/ILSpy.Tests/Navigation/NavigationTests.cs @@ -25,6 +25,7 @@ using AwesomeAssertions; +using ICSharpCode.ILSpy.AssemblyTree; using ICSharpCode.ILSpy.TreeNodes; using NUnit.Framework; @@ -65,20 +66,27 @@ public async Task Back_Navigation_Restores_Previously_Selected_Node() secondTab.Text.Should().Contain(nameof(DecompileSample.Twice)); TestCapture.Step("second-method-decompiled"); - // Act 3 — fire NavigateBack. + // Give the assembly pane the focus: the tab being navigated is already the active + // document, so Back must not move the active pane to it (WPF kept the current view focused). + vm.DockWorkspace.ShowToolPane(AssemblyTreeModel.PaneContentId); + var activePane = vm.DockWorkspace.Layout.FocusedDockable; + activePane.Should().NotBeNull("showing the assembly pane must make it the focused dockable"); + + // Act 3 — fire NavigateBack, which is what the Alt+Left key binding and the View menu invoke. vm.DockWorkspace.NavigateBackCommand.CanExecute(null).Should().BeTrue(); - // execute vm.DockWorkspace.NavigateBackCommand vm.DockWorkspace.NavigateBackCommand.Execute(null); TestCapture.Step("navigated-back"); // Assert — selection restores to the first method, the document re-decompiles to its body, - // and the row is centred back into view. + // the row is centred back into view, and the active pane stays put. await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod)); var restoredTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); restoredTab.Text.Should().Contain(nameof(DecompileSample.Describe)); restoredTab.Text.Should().Contain("return Name + Count"); firstMethod.Should().Be().CenteredInView(); + vm.DockWorkspace.Layout.FocusedDockable.Should().BeSameAs(activePane, + "navigating back must not move the active pane to the editor"); } [AvaloniaTest] From b9c8bef570349faa7293ecaa28c67150371c2ef2 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Mon, 19 Oct 2026 21:54:13 +0200 Subject: [PATCH 5/6] Add runuitests.ps1 to run the UI tests as concurrent shards ILSpy.Tests is serial by design: AvaloniaTestIsolation(PerAssembly) gives one headless session whose dispatcher every [AvaloniaTest] blocks on, and the app composition is process-global, so NUnit workers would only queue. The only parallelism available is several test hosts with disjoint filters, which this script arranges: a greedy split of a measured per-group weight table, with the last shard taking the complement of everything the others name so a new fixture can never fall out of every shard. The namespace-only split is not enough because about a hundred fixtures in sub-folders still declare the root namespace, which is why root fixtures appear in the table and why the complement is needed. The leftover-session sweep in TestApp deleted every sibling directory under %TEMP%\ILSpy.Tests at startup, including the live session directories of another host, so it now only removes directories old enough to belong to an earlier run. #4262 Measured on the same machine, Debug, after the test edits (256 s in one process): 4 shards 91 s (2.8x), 2 shards 153 s (1.7x), 1 shard through the script 259 s. Shard totals sum to every non-[Explicit] test. Assisted-by: Claude:claude-fable-5-1:Claude Code --- .gitignore | 4 +- AGENTS.md | 1 + ILSpy.Tests/TestApp.axaml.cs | 9 ++- runuitests.ps1 | 104 +++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 runuitests.ps1 diff --git a/.gitignore b/.gitignore index 5f804292285..8a4de466c96 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,6 @@ DecompilerTests.config.json report/ # Claude Code local session/skills directory /.claude/ -/.understand-anything \ No newline at end of file +/.understand-anything +# Written by runuitests.ps1 and the CI test steps. +test-results/ diff --git a/AGENTS.md b/AGENTS.md index 56034a237fb..84e4bf5c2ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,7 @@ Never put more than one C# language feature on one branch. ## Test discipline - Always run the test suite with `--report-trx` so failures survive: `dotnet test --solution ILSpy.sln --report-trx` (the repo pins Microsoft.Testing.Platform in `global.json`; the bare `dotnet test ` form is the old VSTest syntax). Don't dismiss failures as flaky without first reproducing in isolation, then running repeatedly. +- **`ILSpy.Tests` is serial by design** (one headless Avalonia session and one process-wide app composition per test host), so it cannot use more than one core in-process. The local optimization is **sharding across processes**: `./runuitests.ps1` runs the built Debug suite as 4 concurrent `dotnet test` hosts with disjoint filters (about 2.5x faster than one host); CI runs the same script with 2 shards on Linux/macOS. For one fixture use `dotnet test --project ILSpy.Tests/ILSpy.Tests.csproj --no-build --filter "FullyQualifiedName~MyFixture"` (the `--treenode-filter` / `--filter-class` forms are rejected by the NUnit bridge). - The decompiler test suite (test kinds, fixture structure, how to write tests, the compiler-matrix model) is documented in [ICSharpCode.Decompiler.Tests/AGENTS.md](ICSharpCode.Decompiler.Tests/AGENTS.md). - After matcher / rewriter edits, **run the relevant tests, not just the build.** `dotnet build` green ≠ behaviour correct. - **To see what a transform did, dump the ILAst:** `ilspycmd -m --ilast` prints the IL transform pipeline's result, and `--after-transform ` stops the pipeline early so two stages can be diffed. Debug builds only (like the UI's Debug Steps pane), so run it from a local build, not the installed tool. diff --git a/ILSpy.Tests/TestApp.axaml.cs b/ILSpy.Tests/TestApp.axaml.cs index 094c1e1c5c2..7855f1ae773 100644 --- a/ILSpy.Tests/TestApp.axaml.cs +++ b/ILSpy.Tests/TestApp.axaml.cs @@ -61,8 +61,15 @@ static void SweepLeftovers(string sessionsRoot) { if (!Directory.Exists(sessionsRoot)) return; + // Several test hosts can run at once (runuitests.ps1 shards the suite into concurrent + // processes), each creating session directories here while it runs. Only directories + // old enough to belong to an earlier run are orphans; a young one is another host's. + var orphanAge = DateTime.UtcNow - TimeSpan.FromMinutes(30); foreach (var sub in Directory.EnumerateDirectories(sessionsRoot)) - TryDeleteDirectory(sub); + { + if (Directory.GetCreationTimeUtc(sub) < orphanAge) + TryDeleteDirectory(sub); + } } static void TryDeleteDirectory(string dir) diff --git a/runuitests.ps1 b/runuitests.ps1 new file mode 100644 index 00000000000..c893375e44e --- /dev/null +++ b/runuitests.ps1 @@ -0,0 +1,104 @@ +#!/usr/bin/env pwsh +# Runs ILSpy.Tests as several concurrent test-host processes with disjoint test filters. +# The suite is serial by design (one headless Avalonia session and one process-wide app +# composition per test host), so the only way to use more than one core is to run several +# hosts, each on its own share of the fixtures. Defaults to 4 shards of the Debug build; +# pass -Shards 1 for a plain single-process run. Build first (./build.ps1), this runs +# `dotnet test --no-build`. Extra arguments are forwarded to every `dotnet test`. +# Examples: ./runuitests.ps1 +# ./runuitests.ps1 -Shards 2 -Configuration Release -ResultsDirectory test-results +param( + [int]$Shards = 4, + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Debug', + [string]$ResultsDirectory = 'test-results' +) +$ErrorActionPreference = 'Stop' + +# Seconds per group in a single-process Debug run (2026-10-19, 24-core workstation); only the +# ratios matter. A key ending in '.' is a sub-namespace of ICSharpCode.ILSpy.Tests, the others +# are fixtures that live directly in that namespace. 'Analyzers.' also covers +# 'Analyzers.Library.' and 'Views.' covers 'Views.Filters.'. +$weights = [ordered]@{ + 'Metadata.' = 30 + 'Analyzers.' = 30 + 'AssemblyTreeTests.' = 20 + 'Search.' = 17 + 'TextView.' = 16 + 'Bookmarks.' = 9 + 'OptionsTabTests.' = 9 + 'Docking.' = 8 + 'Views.' = 7 + 'Languages.' = 6 + 'PreviewTabPromotionTests.' = 6 + 'DecompilerViewTests.' = 5 + 'Input.' = 4 + 'DecompileInNewViewTests.' = 3 + 'ApiVisibilityFilterTests.' = 3 + 'Processes.' = 3 + 'Compare.' = 3 + 'Controls.' = 3 + 'Commands.' = 3 + 'CommandLineArgumentsTests.' = 3 + 'BrowseBackForwardCommandTests.' = 3 + 'EditorCommandsTests.' = 2 + 'AboutPageUpdateSectionTests.' = 2 + 'HelpCommandTests.' = 2 + 'SelectAllDeleteBatchingTests.' = 2 +} +# Everything the keys above do not name: the many small root-namespace fixtures. +$restSeconds = 55 + +# Greedy balance: heaviest group first into the lightest shard. The last shard takes the +# complement of every key the other shards name (plus the rest), so a fixture added later is +# always run by some shard. +$bins = @(1..$Shards | ForEach-Object { [pscustomobject]@{ Keys = [System.Collections.Generic.List[string]]::new(); Seconds = 0.0 } }) +$bins[-1].Seconds = $restSeconds +foreach ($entry in ($weights.GetEnumerator() | Sort-Object Value -Descending)) { + $lightest = $bins | Sort-Object Seconds | Select-Object -First 1 + $lightest.Keys.Add($entry.Key) + $lightest.Seconds += $entry.Value +} + +$prefix = 'ICSharpCode.ILSpy.Tests.' +$filters = @() +$named = @() +for ($i = 0; $i -lt $Shards - 1; $i++) { + $filters += ($bins[$i].Keys | ForEach-Object { "FullyQualifiedName~$prefix$_" }) -join '|' + $named += $bins[$i].Keys +} +$filters += ($named | ForEach-Object { "FullyQualifiedName!~$prefix$_" }) -join '&' + +New-Item -ItemType Directory -Force $ResultsDirectory | Out-Null +$stopwatch = [System.Diagnostics.Stopwatch]::StartNew() +$processes = @() +for ($i = 0; $i -lt $Shards; $i++) { + $n = $i + 1 + $arguments = @( + 'test', '--project', 'ILSpy.Tests/ILSpy.Tests.csproj', + '--configuration', $Configuration, '--no-build', + '--report-trx', '--report-trx-filename', "ui-shard$n.trx", + '--results-directory', $ResultsDirectory + ) + $args + if ($Shards -gt 1) { + $arguments += @('--filter', $filters[$i]) + } + $log = Join-Path $ResultsDirectory "ui-shard$n.log" + $what = if ($Shards -eq 1) { 'everything' } elseif ($i -eq $Shards - 1) { 'everything else' } else { $bins[$i].Keys -join ' ' } + Write-Host "shard $n of ${Shards}: $what" + $processes += Start-Process dotnet -ArgumentList $arguments -PassThru -NoNewWindow -RedirectStandardOutput $log +} + +$processes | Wait-Process +$failed = 0 +for ($i = 0; $i -lt $Shards; $i++) { + $log = Join-Path $ResultsDirectory "ui-shard$($i + 1).log" + $summary = (Get-Content $log | Select-String '^\s+(total|failed|succeeded|skipped|duration):' | ForEach-Object { $_.Line.Trim() }) -join ', ' + Write-Host "shard $($i + 1): exit $($processes[$i].ExitCode); $summary" + if ($processes[$i].ExitCode -ne 0) { + $failed++ + Get-Content $log | Select-String '^failed ' | ForEach-Object { Write-Host " $($_.Line)" } + } +} +Write-Host "wall clock: $([int]$stopwatch.Elapsed.TotalSeconds) s; logs and TRX files are in $ResultsDirectory" +exit $failed From 0ea20edab9dba95ae7a1ada77b129bfe2ed21474 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Mon, 19 Oct 2026 21:54:28 +0200 Subject: [PATCH 6/6] Run the UI tests as two shards on the Linux and macOS jobs The "Execute UI tests" step is the second-longest in the Desktop job and runs one serial test host on a 3-4 core runner. Two concurrent shards through runuitests.ps1 use a second core; the TRX upload and report steps already glob test-results/*.trx, so the per-shard files flow into them unchanged. The Windows job keeps its solution-wide run: it already overlaps ILSpy.Tests with the other test hosts and is bounded by the decompiler suite. #4262 Assisted-by: Claude:claude-fable-5-1:Claude Code --- .github/workflows/build-ilspy.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-ilspy.yml b/.github/workflows/build-ilspy.yml index 2e1a4a5921e..77c3458a13d 100644 --- a/.github/workflows/build-ilspy.yml +++ b/.github/workflows/build-ilspy.yml @@ -362,12 +362,11 @@ jobs: -c Release /p:Platform="Any CPU" /m + # ILSpy.Tests is serial per test host (one headless Avalonia session), so the script runs + # it as two concurrent hosts with disjoint filters; two because the heavier shard peaks + # at ~3 GB and these runners have 3-4 cores. Each shard writes its own TRX file here. - name: Execute UI tests - run: > - dotnet test --project ILSpy.Tests/ILSpy.Tests.csproj - --configuration Release - --no-build --report-trx - --results-directory test-results + run: ./runuitests.ps1 -Shards 2 -Configuration Release -ResultsDirectory test-results - name: Execute decompiler tests id: decompiler-tests