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 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/Analyzers/AnalyzeContextMenuTests.cs b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs index 53c883c4b22..6862193095f 100644 --- a/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs @@ -48,17 +48,7 @@ namespace ICSharpCode.ILSpy.Tests.Analyzers; 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() + 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. @@ -81,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"); @@ -129,33 +106,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 +124,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 +220,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/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..25ebc87d2ee 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,17 +81,14 @@ 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. - 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/AssemblyTreeTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs index 3d3a2027c2d..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); } @@ -873,8 +812,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 +867,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)); @@ -1018,37 +964,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() { @@ -1079,6 +994,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); @@ -1100,10 +1017,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)); @@ -1336,32 +1254,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() { @@ -1440,30 +1332,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() { @@ -1493,26 +1361,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() { @@ -1670,53 +1518,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/AssemblyList/SwitchListAndDeleteAllTests.cs b/ILSpy.Tests/AssemblyList/SwitchListAndDeleteAllTests.cs index 03583ae9788..b167fde0503 100644 --- a/ILSpy.Tests/AssemblyList/SwitchListAndDeleteAllTests.cs +++ b/ILSpy.Tests/AssemblyList/SwitchListAndDeleteAllTests.cs @@ -46,15 +46,12 @@ 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/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/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..774c8a00503 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs @@ -45,16 +45,13 @@ 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(); - 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(); @@ -62,90 +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(); - - var coreLibName = typeof(object).Assembly.GetName().Name!; - var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); - vm.AssemblyTreeModel.SelectNode(typeNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); - - 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(); - - var coreLibName = typeof(object).Assembly.GetName().Name!; - var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); - vm.AssemblyTreeModel.SelectNode(typeNode); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); - - 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 @@ -172,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); @@ -211,24 +135,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 +164,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..d179f5db3a8 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" }); @@ -139,32 +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:System.Linq.Enumerable") which the - // consumer resolves through the loaded assemblies and selects the matching tree node - // (which then triggers a decompile). - - // Arrange — boot. - 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" }); - - // 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). - ((object?)vm.AssemblyTreeModel.SelectedItem).Should().NotBeNull(); - vm.AssemblyTreeModel.SelectedItem!.GetType().Should().Be(typeof(TypeTreeNode)); - vm.AssemblyTreeModel.SelectedItem!.ToString().Should().Be("System.Linq.Enumerable"); - } - [AvaloniaTest] public async Task NavigateTo_None_Arg_Leaves_Selection_Empty() { @@ -288,14 +259,18 @@ 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. + // 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!.ToString().Should().Be("System.Linq.Enumerable"); + vm.AssemblyTreeModel.SelectedItem!.GetType().Should().Be(typeof(TypeTreeNode)); + 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..a2273a9ba38 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,19 +196,15 @@ 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!; 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/ContextMenus/DecompileInNewViewTests.cs b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs index dd2cbaa50ce..90f2fbfbf2b 100644 --- a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs +++ b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs @@ -50,20 +50,20 @@ namespace ICSharpCode.ILSpy.Tests; [TestFixture] public class DecompileInNewViewTests { - [AvaloniaTest] - public async Task DecompileInNewView_Entry_Is_Registered() + /// + /// 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) { - // "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)); + 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; } // audit (2026-05-12): converted 📦 → 🧪. Was calling `entry.IsVisible(synthetic ctx)` @@ -117,18 +117,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 +148,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 +173,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 +185,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 +220,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 +280,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 +315,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(); @@ -337,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!; @@ -351,19 +353,19 @@ 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]; - // 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,49 +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 = vm.AssemblyTreeModel.Root!.Children.OfType().Take(3).ToArray(); - assemblies.Length.Should().BeGreaterThanOrEqualTo(3, "need three top-level rows"); - 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/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..0cb620d0ef8 100644 --- a/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs +++ b/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs @@ -41,82 +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 = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); - 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 = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); - vm.AssemblyTreeModel.SelectNode(typeNode); - vm.DockWorkspace.SettleSelection(); - TestCapture.Step("enumerable-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() { @@ -127,26 +51,30 @@ 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"); + // 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. 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. - 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"); @@ -166,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] @@ -222,8 +154,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 +225,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 +331,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 +365,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"); @@ -503,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 @@ -513,8 +441,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"); @@ -538,34 +465,9 @@ await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); - vm.DockWorkspace.OpenNodeInNewTab(typeNode); - TestCapture.Step("carve-out-tab-opened"); - - await Waiters.WaitForAsync(() => 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"); } @@ -580,16 +482,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 +500,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 +531,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 +545,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"); @@ -667,38 +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 = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); - 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 = vm.AssemblyTreeModel.FindNode( - "System.Private.Uri", "System", "System.Uri"); - 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() { @@ -709,14 +574,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 +602,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 +623,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..f583fe65225 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(); } @@ -101,98 +98,71 @@ 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 (System.Version) must decompile the full class declaration - // including representative members. - - // Arrange — boot, find System.Version. - var (_, vm) = await TestHarness.BootAsync(3); - - var typeNode = vm.AssemblyTreeModel.FindNode( - TreeNavigation.CoreLibName, "System", "System.Version"); - - // 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"); - - // 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"); - } - [AvaloniaTest] 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 +285,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"); @@ -342,65 +309,38 @@ 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 Enumerable, 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"); - - // Act — replace the selection with both nodes. - vm.AssemblyTreeModel.SelectedItems.Clear(); - vm.AssemblyTreeModel.SelectedItems.Add(asEnumerable); - vm.AssemblyTreeModel.SelectedItems.Add(empty); - 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}"); - } - [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 Enumerable, grab two methods. - var (_, vm) = await TestHarness.BootAsync(3); + // Arrange — boot, expand the sample type, grab two methods. + var (_, vm) = await TestHarness.BootAsync(); - 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(); TestCapture.Step("multi-selection-decompiled"); - // 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"); + // 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"); } [AvaloniaTest] @@ -481,16 +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 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)); + 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!; @@ -530,11 +474,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..3a177749e4d 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); @@ -123,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] @@ -159,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/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..d5a21a69268 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() { @@ -86,13 +57,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) @@ -110,7 +80,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"); @@ -196,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() { @@ -265,21 +212,19 @@ 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 - // 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 = 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(); - await Task.Delay(600); vm.AssemblyTreeModel.SelectNode(secondMethod); await vm.DockWorkspace.WaitForDecompiledTextAsync(); return (firstMethod, secondMethod); @@ -305,5 +250,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/Navigation/NavigationTests.cs b/ILSpy.Tests/Navigation/NavigationTests.cs index 62312b72383..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; @@ -41,49 +42,51 @@ 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. - 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("Empty"); - TestCapture.Step("empty-decompiled"); + secondTab.Text.Should().Contain(nameof(DecompileSample.Twice)); + TestCapture.Step("second-method-decompiled"); + + // 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. + // 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 AsEnumerable, the document re-decompiles to its body, - // and the row is centred back into view. + // Assert — selection restores to the first method, the document re-decompiles to its body, + // 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("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(); + vm.DockWorkspace.Layout.FocusedDockable.Should().BeSameAs(activePane, + "navigating back must not move the active pane to the editor"); } [AvaloniaTest] @@ -94,30 +97,26 @@ 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). + // 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("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 +136,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 +161,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(); @@ -176,10 +173,9 @@ 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("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/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() { 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() 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