Browse Source

Add per-step visual breakpoints to the headless UI tests

To audit what each UI test actually exercises, every step now snapshots the
live window to <TestFixtureName>/<TestName>_<NN>_<ShortDescription>.png: a
booted frame (emitted automatically by TestHarness.BootAsync), one after each
state-changing action, and one before each assertion. Flip ILSPY_TESTS_VISIBLE=1
to render the filmstrip; it lands under %TEMP%/ilspy-test-captures (overridable
via ILSPY_TEST_CAPTURES).

The step number and fixture/test name are derived automatically so inserting a
breakpoint never renumbers the rest. The identity is recorded up front from the
real ITest in an ITestAction hook rather than read live: NUnit's
TestContext.CurrentContext does not flow onto async continuations, so a capture
after an await would otherwise collide under the ad-hoc context. And when
rendering is off the whole call is a true no-op -- not even a dispatcher pump --
so instrumenting a test can never perturb the navigation/tab timing it asserts
on. Full headless suite stays green.
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
f466b2399b
  1. 11
      ILSpy.Tests.Windows/ILSpy.Tests.Windows.csproj
  2. 2
      ILSpy.Tests.Windows/ImageList/ImageListResourceEntryNodeTests.cs
  3. 31
      ILSpy.Tests.Windows/TestAppConfiguration.cs
  4. 5
      ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs
  5. 2
      ILSpy.Tests/Analyzers/AnalyzedMethodTreeNodeTests.cs
  6. 2
      ILSpy.Tests/Analyzers/AnalyzedTypeTreeNodeTests.cs
  7. 3
      ILSpy.Tests/Analyzers/AnalyzerConstructorUsesTests.cs
  8. 2
      ILSpy.Tests/Analyzers/AnalyzerNavigationTests.cs
  9. 1
      ILSpy.Tests/Analyzers/AnalyzerPaneCopyResultsTests.cs
  10. 2
      ILSpy.Tests/Analyzers/AnalyzerPaneRemoveTests.cs
  11. 4
      ILSpy.Tests/Analyzers/AnalyzerResultHighlightTests.cs
  12. 2
      ILSpy.Tests/Analyzers/AnalyzerSearchTreeNodeTests.cs
  13. 1
      ILSpy.Tests/Analyzers/AnalyzerTreeNodeTests.cs
  14. 3
      ILSpy.Tests/AssemblyList/AssemblyTreeContextMenuTests.cs
  15. 3
      ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs
  16. 2
      ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs
  17. 5
      ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs
  18. 16
      ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs
  19. 7
      ILSpy.Tests/AssemblyList/ReloadAssemblyContextMenuTests.cs
  20. 9
      ILSpy.Tests/AssemblyList/RemoveAssemblyContextMenuTests.cs
  21. 2
      ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs
  22. 53
      ILSpy.Tests/CaptureContextAttribute.cs
  23. 2
      ILSpy.Tests/ContextMenus/CopyFullyQualifiedNameTests.cs
  24. 3
      ILSpy.Tests/ContextMenus/CreateDiagramContextMenuTests.cs
  25. 12
      ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs
  26. 6
      ILSpy.Tests/ContextMenus/OpenContainingFolderTests.cs
  27. 6
      ILSpy.Tests/ContextMenus/SearchMsdnTests.cs
  28. 1
      ILSpy.Tests/Docking/LayoutPersistenceTests.cs
  29. 21
      ILSpy.Tests/Docking/PreviewTabPromotionTests.cs
  30. 8
      ILSpy.Tests/Docking/SingletonDocumentTabTests.cs
  31. 3
      ILSpy.Tests/MainWindow/MainToolBarLayoutTests.cs
  32. 5
      ILSpy.Tests/MainWindow/MainWindowTests.cs
  33. 2
      ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs
  34. 2
      ILSpy.Tests/Metadata/DebugDirectoryChildNodesTests.cs
  35. 4
      ILSpy.Tests/Metadata/EmbeddedPdbTreeTests.cs
  36. 4
      ILSpy.Tests/Metadata/HeapTreeTests.cs
  37. 1
      ILSpy.Tests/Metadata/HideEmptyMetadataTablesTests.cs
  38. 2
      ILSpy.Tests/Metadata/HideEmptyMetadataTablesUiBindingTests.cs
  39. 2
      ILSpy.Tests/Metadata/MetadataDisablesLanguageSwitchingTests.cs
  40. 3
      ILSpy.Tests/Metadata/MetadataFilterTests.cs
  41. 1
      ILSpy.Tests/Metadata/MetadataNodeIconParityTests.cs
  42. 1
      ILSpy.Tests/Metadata/MetadataProtocolHandlerDrillDownTests.cs
  43. 4
      ILSpy.Tests/Metadata/MetadataRowActivationTests.cs
  44. 3
      ILSpy.Tests/Metadata/MetadataTabSessionRestoreTests.cs
  45. 3
      ILSpy.Tests/Metadata/MetadataTablesTreeTests.cs
  46. 4
      ILSpy.Tests/Metadata/MetadataTokenNavigationTests.cs
  47. 2
      ILSpy.Tests/Metadata/MetadataTreeShapeTests.cs
  48. 14
      ILSpy.Tests/Metadata/PEHeaderTreeTests.cs
  49. 1
      ILSpy.Tests/Metadata/ShowInMetadataContextMenuTests.cs
  50. 5
      ILSpy.Tests/Metadata/TypedMetadataTableTreeTests.cs
  51. 3
      ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs
  52. 8
      ILSpy.Tests/Navigation/NavigationTests.cs
  53. 9
      ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs
  54. 9
      ILSpy.Tests/Options/ApiVisibilityFilterTests.cs
  55. 4
      ILSpy.Tests/Options/OptionsPageScrollReachTests.cs
  56. 14
      ILSpy.Tests/Options/OptionsTabTests.cs
  57. 2
      ILSpy.Tests/Search/ScopeSearchToTests.cs
  58. 2
      ILSpy.Tests/Search/SearchInputFocusTests.cs
  59. 2
      ILSpy.Tests/Search/SearchPaneAssemblyListChangedTests.cs
  60. 6
      ILSpy.Tests/Search/SearchPaneStreamingTests.cs
  61. 5
      ILSpy.Tests/Search/SearchPaneViewTests.cs
  62. 3
      ILSpy.Tests/Search/SearchProgressTests.cs
  63. 2
      ILSpy.Tests/Search/SearchResultNavigationTests.cs
  64. 1
      ILSpy.Tests/Search/SearchResultSortOrderTests.cs
  65. 1
      ILSpy.Tests/Search/ShowSearchCommandTests.cs
  66. 1
      ILSpy.Tests/TestAppBuilder.cs
  67. 117
      ILSpy.Tests/TestCaptureExtensions.cs
  68. 2
      ILSpy.Tests/TestHarness.cs

11
ILSpy.Tests.Windows/ILSpy.Tests.Windows.csproj

@ -59,17 +59,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ILSpy.Tests\ILSpy.Tests.csproj" />
<ProjectReference Include="..\ILSpy\ILSpy.csproj" /> <ProjectReference Include="..\ILSpy\ILSpy.csproj" />
</ItemGroup> </ItemGroup>
<!-- Share TestApp + TestAppBuilder with the cross-platform test project. Linked
rather than duplicated so changes in the main project (data templates, theme
stack, settings sandbox) automatically apply here. -->
<ItemGroup>
<Compile Include="..\ILSpy.Tests\TestApp.axaml.cs" Link="TestApp.axaml.cs" />
<Compile Include="..\ILSpy.Tests\TestAppBuilder.cs" Link="TestAppBuilder.cs" />
<Compile Include="..\ILSpy.Tests\ResetAppStateAttribute.cs" Link="ResetAppStateAttribute.cs" />
<AvaloniaResource Include="..\ILSpy.Tests\TestApp.axaml" Link="TestApp.axaml" />
</ItemGroup>
</Project> </Project>

2
ILSpy.Tests.Windows/ImageList/ImageListResourceEntryNodeTests.cs

@ -70,7 +70,7 @@ public class ImageListResourceEntryNodeTests
node.EnsureLazyChildren(); node.EnsureLazyChildren();
node.Children.Should().HaveCount(1, "the .resources file held exactly one entry"); node.Children.Should().HaveCount(1, "the .resources file held exactly one entry");
node.Children[0].Should().BeOfType<ImageListResourceEntryNode>( ((object?)node.Children[0]).Should().BeOfType<ImageListResourceEntryNode>(
"an ImageListStreamer typed entry should be claimed by ImageListResourceNodeFactory"); "an ImageListStreamer typed entry should be claimed by ImageListResourceNodeFactory");
} }

31
ILSpy.Tests.Windows/TestAppConfiguration.cs

@ -0,0 +1,31 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using Avalonia.Headless;
using ICSharpCode.ILSpy.Tests;
// ILSpy.Tests.Windows reuses the headless app + builder from ILSpy.Tests so its [AvaloniaTest]
// methods boot the same MEF-composed app (which runs AppComposition.Initialize). These attributes
// are assembly-scoped and do NOT carry across the ILSpy.Tests project reference, so they must be
// declared here too -- without them [AvaloniaTest] runs without an app and AppComposition.Current
// throws "Composition host is not yet initialized."
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
[assembly: AvaloniaTestIsolation(AvaloniaTestIsolationLevel.PerAssembly)]
[assembly: ResetAppState]
[assembly: CaptureContext]

5
ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs

@ -95,6 +95,7 @@ public class AnalyzeContextMenuTests
var beforeCount = analyzerVm.Root.Children.Count; var beforeCount = analyzerVm.Root.Children.Count;
entry.Execute(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)typeNode } }); entry.Execute(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)typeNode } });
TestCapture.Step("analyze-executed");
analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1, analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1,
"Execute on the analyze entry must add exactly one new row to the pane root"); "Execute on the analyze entry must add exactly one new row to the pane root");
@ -114,6 +115,7 @@ public class AnalyzeContextMenuTests
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeNode); vm.AssemblyTreeModel.SelectNode(typeNode);
TestCapture.Step("type-selected");
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>(); var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();
var beforeCount = analyzerVm.Root.Children.Count; var beforeCount = analyzerVm.Root.Children.Count;
@ -125,6 +127,7 @@ public class AnalyzeContextMenuTests
RoutedEvent = global::Avalonia.Input.InputElement.KeyDownEvent, RoutedEvent = global::Avalonia.Input.InputElement.KeyDownEvent,
Source = grid, Source = grid,
}); });
TestCapture.Step("ctrl-r-pressed");
analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1, analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1,
"Ctrl+R with a type selected must analyse it"); "Ctrl+R with a type selected must analyse it");
@ -171,6 +174,7 @@ public class AnalyzeContextMenuTests
entry.Execute(new TextViewContext { entry.Execute(new TextViewContext {
SelectedTreeNodes = new ICSharpCode.ILSpyX.TreeView.SharpTreeNode[] { methodNode } SelectedTreeNodes = new ICSharpCode.ILSpyX.TreeView.SharpTreeNode[] { methodNode }
}); });
TestCapture.Step("analyzer-pane-surfaced");
// After Execute, the active dockable in the analyzer pane's owning dock should be // After Execute, the active dockable in the analyzer pane's owning dock should be
// the analyzer pane itself — that's what ShowToolPane does. // the analyzer pane itself — that's what ShowToolPane does.
@ -204,6 +208,7 @@ public class AnalyzeContextMenuTests
entry.Execute(new TextViewContext { entry.Execute(new TextViewContext {
SelectedTreeNodes = new ICSharpCode.ILSpyX.TreeView.SharpTreeNode[] { methodNode } SelectedTreeNodes = new ICSharpCode.ILSpyX.TreeView.SharpTreeNode[] { methodNode }
}); });
TestCapture.Step("analyzer-results-populated");
// Walk every materialised node under the analyzer root and assert each has Icon. // Walk every materialised node under the analyzer root and assert each has Icon.
var entityRow = analyzerVm.Root.Children.OfType<AnalyzerEntityTreeNode>().Last(); var entityRow = analyzerVm.Root.Children.OfType<AnalyzerEntityTreeNode>().Last();

2
ILSpy.Tests/Analyzers/AnalyzedMethodTreeNodeTests.cs

@ -56,6 +56,7 @@ public class AnalyzedMethodTreeNodeTests
.First(m => m.MethodDefinition.Name == "Empty").Member!; .First(m => m.MethodDefinition.Name == "Empty").Member!;
var node = new AnalyzedMethodTreeNode(emptyMethod, source: null); var node = new AnalyzedMethodTreeNode(emptyMethod, source: null);
TestCapture.Step("before-method-text");
node.Member.Should().BeSameAs(emptyMethod); node.Member.Should().BeSameAs(emptyMethod);
node.Text.ToString().Should().Contain("Empty"); node.Text.ToString().Should().Contain("Empty");
node.Text.ToString().Should().Contain("Enumerable", node.Text.ToString().Should().Contain("Enumerable",
@ -86,6 +87,7 @@ public class AnalyzedMethodTreeNodeTests
.Select(c => c.AnalyzerHeader) .Select(c => c.AnalyzerHeader)
.ToArray(); .ToArray();
TestCapture.Step("before-uses-and-used-by-headers");
headers.Should().Contain("Used By", headers.Should().Contain("Used By",
"MethodUsedByAnalyzer is the headline reverse-lookup for any concrete method"); "MethodUsedByAnalyzer is the headline reverse-lookup for any concrete method");
headers.Should().Contain("Uses", headers.Should().Contain("Uses",

2
ILSpy.Tests/Analyzers/AnalyzedTypeTreeNodeTests.cs

@ -59,6 +59,7 @@ public class AnalyzedTypeTreeNodeTests
.Single(t => t.FullName == "System.Object"); .Single(t => t.FullName == "System.Object");
var node = new AnalyzedTypeTreeNode(objectType, source: null); var node = new AnalyzedTypeTreeNode(objectType, source: null);
TestCapture.Step("before-type-text");
node.Text.ToString().Should().Contain("Object"); node.Text.ToString().Should().Contain("Object");
node.Member.Should().BeSameAs(objectType); node.Member.Should().BeSameAs(objectType);
node.Icon.Should().NotBeNull("every entity node must surface a non-null icon"); node.Icon.Should().NotBeNull("every entity node must surface a non-null icon");
@ -90,6 +91,7 @@ public class AnalyzedTypeTreeNodeTests
.Select(c => c.AnalyzerHeader) .Select(c => c.AnalyzerHeader)
.ToArray(); .ToArray();
TestCapture.Step("before-type-analyzer-headers");
headers.Should().Contain("Used By", "TypeUsedByAnalyzer applies to every TypeDefinition"); headers.Should().Contain("Used By", "TypeUsedByAnalyzer applies to every TypeDefinition");
headers.Should().Contain("Exposed By", "TypeExposedByAnalyzer applies to public types"); headers.Should().Contain("Exposed By", "TypeExposedByAnalyzer applies to public types");
headers.Should().Contain("Extension Methods", headers.Should().Contain("Extension Methods",

3
ILSpy.Tests/Analyzers/AnalyzerConstructorUsesTests.cs

@ -80,6 +80,7 @@ public class AnalyzerConstructorUsesTests
AssemblyList = assemblyList, AssemblyList = assemblyList,
}; };
var results = analyzer.Analyze(ctor, context).Take(20).ToList(); var results = analyzer.Analyze(ctor, context).Take(20).ToList();
TestCapture.Step("before-used-by-results");
results.Should().NotBeEmpty("constructors of public LINQ types must have at least one in-tree caller"); results.Should().NotBeEmpty("constructors of public LINQ types must have at least one in-tree caller");
} }
@ -110,6 +111,7 @@ public class AnalyzerConstructorUsesTests
AssemblyList = assemblyList, AssemblyList = assemblyList,
}; };
var results = analyzer.Analyze(ctor, context).Take(20).ToList(); var results = analyzer.Analyze(ctor, context).Take(20).ToList();
TestCapture.Step("before-uses-results");
results.Should().NotBeEmpty(); results.Should().NotBeEmpty();
} }
@ -129,6 +131,7 @@ public class AnalyzerConstructorUsesTests
// This was the user-reported throw point. // This was the user-reported throw point.
analyzerVm.Analyze(ctor); analyzerVm.Analyze(ctor);
TestCapture.Step("constructor-pushed-to-pane");
analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1); analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1);
} }

2
ILSpy.Tests/Analyzers/AnalyzerNavigationTests.cs

@ -58,6 +58,7 @@ public class AnalyzerNavigationTests
var analyzed = new AnalyzedTypeTreeNode(entity, source: null); var analyzed = new AnalyzedTypeTreeNode(entity, source: null);
analyzed.ActivateItem(new StubRoutedEventArgs()); analyzed.ActivateItem(new StubRoutedEventArgs());
TestCapture.Step("navigated-to-type-node");
((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeSameAs(typeNode, ((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeSameAs(typeNode,
"ActivateItem must move the assembly-tree selection to the entity's tree node"); "ActivateItem must move the assembly-tree selection to the entity's tree node");
@ -77,6 +78,7 @@ public class AnalyzerNavigationTests
var analyzed = new AnalyzedMethodTreeNode(method, source: null); var analyzed = new AnalyzedMethodTreeNode(method, source: null);
analyzed.ActivateItem(new StubRoutedEventArgs()); analyzed.ActivateItem(new StubRoutedEventArgs());
TestCapture.Step("navigated-to-method-node");
((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeSameAs(methodTreeNode, ((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeSameAs(methodTreeNode,
"ActivateItem must walk down to the right method tree-node under its declaring type"); "ActivateItem must walk down to the right method tree-node under its declaring type");

1
ILSpy.Tests/Analyzers/AnalyzerPaneCopyResultsTests.cs

@ -85,6 +85,7 @@ public class AnalyzerPaneCopyResultsTests
search.Children.Add(new StubResultNode("beta")); search.Children.Add(new StubResultNode("beta"));
entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { search } }); entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { search } });
TestCapture.Step("copy-results-executed");
// We can't inspect the clipboard contract in a headless test without a window with // We can't inspect the clipboard contract in a headless test without a window with
// a real lifetime — the test asserts the entry handled execution without throwing // a real lifetime — the test asserts the entry handled execution without throwing

2
ILSpy.Tests/Analyzers/AnalyzerPaneRemoveTests.cs

@ -54,6 +54,7 @@ public class AnalyzerPaneRemoveTests
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>(); var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();
var added = analyzerVm.Analyze((ICSharpCode.Decompiler.TypeSystem.IEntity)typeNode.Member!); var added = analyzerVm.Analyze((ICSharpCode.Decompiler.TypeSystem.IEntity)typeNode.Member!);
TestCapture.Step("entity-analysed");
entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { added } }) entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { added } })
.Should().BeTrue("Remove applies to top-level analysed entities"); .Should().BeTrue("Remove applies to top-level analysed entities");
@ -80,6 +81,7 @@ public class AnalyzerPaneRemoveTests
var beforeCount = analyzerVm.Root.Children.Count; var beforeCount = analyzerVm.Root.Children.Count;
entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { added } }); entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { added } });
TestCapture.Step("entity-removed-from-pane");
analyzerVm.Root.Children.Should().NotContain(added, analyzerVm.Root.Children.Should().NotContain(added,
"Execute on Remove must drop the row from the pane root"); "Execute on Remove must drop the row from the pane root");

4
ILSpy.Tests/Analyzers/AnalyzerResultHighlightTests.cs

@ -72,12 +72,14 @@ public class AnalyzerResultHighlightTests
// DockWorkspace.ActiveDecompilerTab is null and the highlight path has no sink. // DockWorkspace.ActiveDecompilerTab is null and the highlight path has no sink.
vm.AssemblyTreeModel.SelectNode(stringNode); vm.AssemblyTreeModel.SelectNode(stringNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("string-decompiled");
var decompTab = vm.DockWorkspace.ActiveDecompilerTab; var decompTab = vm.DockWorkspace.ActiveDecompilerTab;
Assert.That(decompTab, Is.Not.Null, "selecting a type must materialise the decompiler tab"); Assert.That(decompTab, Is.Not.Null, "selecting a type must materialise the decompiler tab");
decompTab!.HighlightedReference = null; decompTab!.HighlightedReference = null;
var resultNode = new AnalyzedMethodTreeNode(toStringNode.MethodDefinition, source: concatNode.MethodDefinition); var resultNode = new AnalyzedMethodTreeNode(toStringNode.MethodDefinition, source: concatNode.MethodDefinition);
resultNode.ActivateItem(new StubArgs()); resultNode.ActivateItem(new StubArgs());
TestCapture.Step("highlighted-source-member");
((object?)decompTab.HighlightedReference).Should().BeSameAs(concatNode.MethodDefinition, ((object?)decompTab.HighlightedReference).Should().BeSameAs(concatNode.MethodDefinition,
"NavigateToReferenceEventArgs.Source (= SourceMember) must land in HighlightedReference so the editor view can paint local-reference marks"); "NavigateToReferenceEventArgs.Source (= SourceMember) must land in HighlightedReference so the editor view can paint local-reference marks");
@ -103,6 +105,7 @@ public class AnalyzerResultHighlightTests
// Same materialisation as the first test. // Same materialisation as the first test.
vm.AssemblyTreeModel.SelectNode(stringNode); vm.AssemblyTreeModel.SelectNode(stringNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("string-decompiled");
var decompTab = vm.DockWorkspace.ActiveDecompilerTab!; var decompTab = vm.DockWorkspace.ActiveDecompilerTab!;
decompTab.HighlightedReference = lengthProp.PropertyDefinition; decompTab.HighlightedReference = lengthProp.PropertyDefinition;
@ -110,6 +113,7 @@ public class AnalyzerResultHighlightTests
stringNode.Children.OfType<MethodTreeNode>().First().MethodDefinition, stringNode.Children.OfType<MethodTreeNode>().First().MethodDefinition,
source: null); source: null);
topLevel.ActivateItem(new StubArgs()); topLevel.ActivateItem(new StubArgs());
TestCapture.Step("activated-top-level-entity");
((object?)decompTab.HighlightedReference).Should().BeSameAs(lengthProp.PropertyDefinition, ((object?)decompTab.HighlightedReference).Should().BeSameAs(lengthProp.PropertyDefinition,
"top-level analysed-entity rows have no SourceMember and must not overwrite the active highlight"); "top-level analysed-entity rows have no SourceMember and must not overwrite the active highlight");

2
ILSpy.Tests/Analyzers/AnalyzerSearchTreeNodeTests.cs

@ -74,6 +74,7 @@ public class AnalyzerSearchTreeNodeTests
() => search.Children.Count > 0 && !search.IsLoading, () => search.Children.Count > 0 && !search.IsLoading,
timeout: TimeSpan.FromSeconds(30)); timeout: TimeSpan.FromSeconds(30));
TestCapture.Step("before-search-results");
search.Children.Should().NotBeEmpty( search.Children.Should().NotBeEmpty(
"the Used By analyzer must surface at least one entity that references Enumerable"); "the Used By analyzer must surface at least one entity that references Enumerable");
search.Children.Should().AllBeAssignableTo<AnalyzerTreeNode>( search.Children.Should().AllBeAssignableTo<AnalyzerTreeNode>(
@ -115,6 +116,7 @@ public class AnalyzerSearchTreeNodeTests
// Collapse before completion to exercise the cancellation path. // Collapse before completion to exercise the cancellation path.
search.IsExpanded = false; search.IsExpanded = false;
TestCapture.Step("before-collapse-cancelled");
search.IsLoading.Should().BeFalse( search.IsLoading.Should().BeFalse(
"cancellation must stop the background task synchronously from the collapse handler"); "cancellation must stop the background task synchronously from the collapse handler");
search.Children.Should().BeEmpty( search.Children.Should().BeEmpty(

1
ILSpy.Tests/Analyzers/AnalyzerTreeNodeTests.cs

@ -94,6 +94,7 @@ public class AnalyzerTreeNodeTests
var snapshot = assemblyList.GetAssemblies(); var snapshot = assemblyList.GetAssemblies();
// Clearing then re-adding the assemblies emits a Reset on the underlying ObservableCollection. // Clearing then re-adding the assemblies emits a Reset on the underlying ObservableCollection.
assemblyList.Clear(); assemblyList.Clear();
TestCapture.Step("assembly-list-cleared");
root.Children.Should().BeEmpty( root.Children.Should().BeEmpty(
"AnalyzerRootNode.CurrentAssemblyList_Changed must wipe children on Reset"); "AnalyzerRootNode.CurrentAssemblyList_Changed must wipe children on Reset");

3
ILSpy.Tests/AssemblyList/AssemblyTreeContextMenuTests.cs

@ -69,6 +69,7 @@ public class AssemblyTreeContextMenuTests
var pane = await window.WaitForComponent<AssemblyListPane>(); var pane = await window.WaitForComponent<AssemblyListPane>();
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
vm.AssemblyTreeModel.SelectNode(assemblyNode); vm.AssemblyTreeModel.SelectNode(assemblyNode);
TestCapture.Step("system-linq-selected");
TextViewContext? executionContext = null; TextViewContext? executionContext = null;
var entry = new RecordingEntry(c => executionContext = c); var entry = new RecordingEntry(c => executionContext = c);
@ -81,6 +82,7 @@ public class AssemblyTreeContextMenuTests
// Trigger the build path the live Opening event would take. // Trigger the build path the live Opening event would take.
var built = pane.BuildContextMenuForCurrentState(new IContextMenuEntryExport[] { export }); var built = pane.BuildContextMenuForCurrentState(new IContextMenuEntryExport[] { export });
TestCapture.Step("context-menu-built");
// Assert 1 — menu carries our entry. // Assert 1 — menu carries our entry.
built.Should().NotBeNull(); built.Should().NotBeNull();
@ -89,6 +91,7 @@ public class AssemblyTreeContextMenuTests
// Act 2 — invoke the click handler that the Build attached. // Act 2 — invoke the click handler that the Build attached.
item.RaiseEvent(new global::Avalonia.Interactivity.RoutedEventArgs(MenuItem.ClickEvent)); item.RaiseEvent(new global::Avalonia.Interactivity.RoutedEventArgs(MenuItem.ClickEvent));
TestCapture.Step("probe-entry-clicked");
// Assert 2 — entry's Execute saw the right context: the tree grid + the selection. // Assert 2 — entry's Execute saw the right context: the tree grid + the selection.
executionContext.Should().NotBeNull(); executionContext.Should().NotBeNull();

3
ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs

@ -96,6 +96,7 @@ public class AssemblyTreeDragReorderTests
position: DataGridRowDropPosition.After); position: DataGridRowDropPosition.After);
handler.Validate(args).Should().BeTrue(); handler.Validate(args).Should().BeTrue();
handler.Execute(args).Should().BeTrue(); handler.Execute(args).Should().BeTrue();
TestCapture.Step("after-reorder-first-after-second");
var after = list.GetAssemblies(); var after = list.GetAssemblies();
after[0].Should().BeSameAs(before[1]); after[0].Should().BeSameAs(before[1]);
@ -134,6 +135,7 @@ public class AssemblyTreeDragReorderTests
var topLevel = vm.AssemblyTreeModel.Root!.Children.OfType<AssemblyTreeNode>().First(); var topLevel = vm.AssemblyTreeModel.Root!.Children.OfType<AssemblyTreeNode>().First();
topLevel.IsExpanded = true; topLevel.IsExpanded = true;
TestCapture.Step("top-level-expanded");
var childNode = topLevel.Children.First(); var childNode = topLevel.Children.First();
var handler = (AssemblyRowDropHandler)grid.RowDropHandler; var handler = (AssemblyRowDropHandler)grid.RowDropHandler;
@ -153,6 +155,7 @@ public class AssemblyTreeDragReorderTests
var topLevel = vm.AssemblyTreeModel.Root!.Children.OfType<AssemblyTreeNode>().ToArray(); var topLevel = vm.AssemblyTreeModel.Root!.Children.OfType<AssemblyTreeNode>().ToArray();
topLevel[0].IsExpanded = true; topLevel[0].IsExpanded = true;
TestCapture.Step("first-assembly-expanded");
var childOfFirst = topLevel[0].Children.First(); var childOfFirst = topLevel[0].Children.First();
var handler = (AssemblyRowDropHandler)grid.RowDropHandler; var handler = (AssemblyRowDropHandler)grid.RowDropHandler;

2
ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs

@ -58,6 +58,7 @@ public class AssemblyTreeExpanderHitboxTests
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.Expand(); assemblyNode.Expand();
vm.AssemblyTreeModel.SelectNode(assemblyNode); vm.AssemblyTreeModel.SelectNode(assemblyNode);
TestCapture.Step("system-linq-expanded-and-selected");
var pane = await window.WaitForComponent<AssemblyListPane>(); var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>(); var grid = await pane.WaitForComponent<DataGrid>();
@ -103,6 +104,7 @@ public class AssemblyTreeExpanderHitboxTests
hitPoint.Should().NotBeNull(); hitPoint.Should().NotBeNull();
HeadlessWindowExtensions.MouseDown(window, hitPoint!.Value, MouseButton.Left); HeadlessWindowExtensions.MouseDown(window, hitPoint!.Value, MouseButton.Left);
HeadlessWindowExtensions.MouseUp(window, hitPoint.Value, MouseButton.Left); HeadlessWindowExtensions.MouseUp(window, hitPoint.Value, MouseButton.Left);
TestCapture.Step("clicked-enlarged-expander-area");
await Waiters.WaitForAsync(() => !assemblyNode.IsExpanded, await Waiters.WaitForAsync(() => !assemblyNode.IsExpanded,
description: "clicking the enlarged expander area (below the glyph) must toggle the node"); description: "clicking the enlarged expander area (below the glyph) must toggle the node");

5
ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs

@ -66,6 +66,7 @@ public class AssemblyTreeFileDropTests
try try
{ {
pane.HandleFileDrop(new[] { tempPath }, target: null, DataGridRowDropPosition.After); pane.HandleFileDrop(new[] { tempPath }, target: null, DataGridRowDropPosition.After);
TestCapture.Step("file-dropped-no-target");
var after = list.GetAssemblies(); var after = list.GetAssemblies();
after.Should().HaveCount(beforeCount + 1); after.Should().HaveCount(beforeCount + 1);
@ -97,6 +98,7 @@ public class AssemblyTreeFileDropTests
{ {
pane.HandleFileDrop(new[] { tempPath }, target: firstNode, pane.HandleFileDrop(new[] { tempPath }, target: firstNode,
DataGridRowDropPosition.Before); DataGridRowDropPosition.Before);
TestCapture.Step("file-dropped-before-first-row");
var after = list.GetAssemblies(); var after = list.GetAssemblies();
after.Should().HaveCount(beforeCount + 1); after.Should().HaveCount(beforeCount + 1);
@ -125,6 +127,7 @@ public class AssemblyTreeFileDropTests
{ {
pane.HandleFileDrop(new[] { tempPath }, target: firstNode, pane.HandleFileDrop(new[] { tempPath }, target: firstNode,
DataGridRowDropPosition.After); DataGridRowDropPosition.After);
TestCapture.Step("file-dropped-after-first-row");
var after = list.GetAssemblies(); var after = list.GetAssemblies();
after[1].FileName.Should().Be(tempPath); after[1].FileName.Should().Be(tempPath);
@ -152,6 +155,7 @@ public class AssemblyTreeFileDropTests
{ {
pane.HandleFileDrop(new[] { tempPath }, target: null, pane.HandleFileDrop(new[] { tempPath }, target: null,
DataGridRowDropPosition.After); DataGridRowDropPosition.After);
TestCapture.Step("file-dropped-new-node-selected");
var newAsm = list.GetAssemblies() var newAsm = list.GetAssemblies()
.First(a => string.Equals(a.FileName, tempPath, StringComparison.OrdinalIgnoreCase)); .First(a => string.Equals(a.FileName, tempPath, StringComparison.OrdinalIgnoreCase));
@ -187,6 +191,7 @@ public class AssemblyTreeFileDropTests
pane.HandleFileDrop(new[] { bogusPath }, target: null, pane.HandleFileDrop(new[] { bogusPath }, target: null,
DataGridRowDropPosition.After); DataGridRowDropPosition.After);
TestCapture.Step("bogus-path-dropped");
// OpenAssembly does add the entry (it lazy-loads), so the count may grow. The contract // OpenAssembly does add the entry (it lazy-loads), so the count may grow. The contract
// we exercise here is "the call returns without throwing" — verified by reaching this // we exercise here is "the call returns without throwing" — verified by reaching this

16
ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs

@ -514,6 +514,7 @@ public class AssemblyTreeTests
vm.AssemblyTreeModel.SelectNode(methodNode); vm.AssemblyTreeModel.SelectNode(methodNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("method-decompiled");
await Waiters.WaitForAsync(() => await Waiters.WaitForAsync(() =>
vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Any(a => !initialFiles.Contains(a.FileName))); vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Any(a => !initialFiles.Contains(a.FileName)));
@ -649,6 +650,7 @@ public class AssemblyTreeTests
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
scrollViewer.Offset.Y.Should().BeGreaterThan(5, scrollViewer.Offset.Y.Should().BeGreaterThan(5,
"the test scenario requires the viewport be parked mid-list"); "the test scenario requires the viewport be parked mid-list");
TestCapture.Step("viewport-parked-mid-list");
// Pick the bottom-most visible non-selected row — that's the strictest probe. If the // Pick the bottom-most visible non-selected row — that's the strictest probe. If the
// bug regressed, CenterRowInView would scroll it up to the middle and offset would // bug regressed, CenterRowInView would scroll it up to the middle and offset would
@ -670,6 +672,7 @@ public class AssemblyTreeTests
window)!.Value; window)!.Value;
global::Avalonia.Headless.HeadlessWindowExtensions.MouseDown(window, rowCentre, global::Avalonia.Input.MouseButton.Left); global::Avalonia.Headless.HeadlessWindowExtensions.MouseDown(window, rowCentre, global::Avalonia.Input.MouseButton.Left);
global::Avalonia.Headless.HeadlessWindowExtensions.MouseUp(window, rowCentre, global::Avalonia.Input.MouseButton.Left); global::Avalonia.Headless.HeadlessWindowExtensions.MouseUp(window, rowCentre, global::Avalonia.Input.MouseButton.Left);
TestCapture.Step("visible-row-clicked");
for (int i = 0; i < 8; i++) for (int i = 0; i < 8; i++)
{ {
@ -731,6 +734,7 @@ public class AssemblyTreeTests
.First(m => m.MethodDefinition.Name == "AsEnumerable"); .First(m => m.MethodDefinition.Name == "AsEnumerable");
vm.AssemblyTreeModel.SelectNode(method); vm.AssemblyTreeModel.SelectNode(method);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("asenumerable-decompiled");
// Act — invoke SaveCodeAsync with a temp path (bypassing the file picker so the test // Act — invoke SaveCodeAsync with a temp path (bypassing the file picker so the test
// is deterministic). // is deterministic).
@ -777,6 +781,7 @@ public class AssemblyTreeTests
var sacrificialNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(sacrificialName); var sacrificialNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(sacrificialName);
vm.AssemblyTreeModel.SelectNode(sacrificialNode); vm.AssemblyTreeModel.SelectNode(sacrificialNode);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, sacrificialNode)); await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, sacrificialNode));
TestCapture.Step("sacrificial-selected");
var pane = await window.WaitForComponent<AssemblyListPane>(); var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>(); var grid = await pane.WaitForComponent<DataGrid>();
@ -793,6 +798,7 @@ public class AssemblyTreeTests
global::Avalonia.Input.RawInputModifiers.None, global::Avalonia.Input.RawInputModifiers.None,
global::Avalonia.Input.PhysicalKey.Delete, global::Avalonia.Input.PhysicalKey.Delete,
null); null);
TestCapture.Step("delete-key-pressed");
// Assert — the sacrificial assembly is gone from the data list AND the grid's bound // Assert — the sacrificial assembly is gone from the data list AND the grid's bound
// ItemsSource drops by one. Both halves matter: a passing AssemblyList assertion alone // ItemsSource drops by one. Both halves matter: a passing AssemblyList assertion alone
@ -915,6 +921,7 @@ public class AssemblyTreeTests
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
await Task.Delay(20); await Task.Delay(20);
} }
TestCapture.Step("assembly-row-expanded");
// Assert — the grid wrapper now reports IsExpanded == true. // Assert — the grid wrapper now reports IsExpanded == true.
hm.FindNode(assemblyNode)!.IsExpanded.Should().BeTrue( hm.FindNode(assemblyNode)!.IsExpanded.Should().BeTrue(
@ -939,6 +946,7 @@ public class AssemblyTreeTests
.First(m => m.MethodDefinition.Name == "Empty"); .First(m => m.MethodDefinition.Name == "Empty");
vm.AssemblyTreeModel.SelectNode(pinned); vm.AssemblyTreeModel.SelectNode(pinned);
var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("asenumerable-in-first-tab");
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<AssemblyListPane>().Any()); await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<AssemblyListPane>().Any());
var pane = await window.WaitForComponent<AssemblyListPane>(); var pane = await window.WaitForComponent<AssemblyListPane>();
@ -951,6 +959,7 @@ public class AssemblyTreeTests
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => (documents.VisibleDockables?.Count ?? 0) > initialCount); () => (documents.VisibleDockables?.Count ?? 0) > initialCount);
var newTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var newTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("empty-spawned-in-new-tab");
ReferenceEquals(newTab, firstTab).Should().BeFalse( ReferenceEquals(newTab, firstTab).Should().BeFalse(
"a fresh decompiler tab must be created instead of reusing the existing one"); "a fresh decompiler tab must be created instead of reusing the existing one");
newTab.Text.Should().Contain("Empty"); newTab.Text.Should().Contain("Empty");
@ -986,12 +995,14 @@ public class AssemblyTreeTests
vm.DockWorkspace.OpenNodeInNewTab(second); vm.DockWorkspace.OpenNodeInNewTab(second);
await Waiters.WaitForAsync(() => await Waiters.WaitForAsync(() =>
ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, second)); ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, second));
TestCapture.Step("second-tab-active");
// Re-activate the first tab — selection must swing back. // Re-activate the first tab — selection must swing back.
vm.DockWorkspace.Factory.SetActiveDockable(firstTab!); vm.DockWorkspace.Factory.SetActiveDockable(firstTab!);
await Waiters.WaitForAsync(() => await Waiters.WaitForAsync(() =>
ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, first)); ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, first));
TestCapture.Step("switched-back-to-first-tab");
ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, first).Should().BeTrue( ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, first).Should().BeTrue(
"clicking back to the previous tab must pull the tree selection with it"); "clicking back to the previous tab must pull the tree selection with it");
} }
@ -1014,6 +1025,7 @@ public class AssemblyTreeTests
// First metadata view via tree-selection (active tab gets MetadataTablePageModel content). // First metadata view via tree-selection (active tab gets MetadataTablePageModel content).
vm.AssemblyTreeModel.SelectNode(typeDefNode); vm.AssemblyTreeModel.SelectNode(typeDefNode);
var firstMeta = await vm.DockWorkspace.WaitForMetadataTabAsync(); var firstMeta = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("typedef-metadata-tab");
var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!;
var initialCount = documents.VisibleDockables?.Count ?? 0; var initialCount = documents.VisibleDockables?.Count ?? 0;
@ -1027,6 +1039,7 @@ public class AssemblyTreeTests
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => (documents.VisibleDockables?.Count ?? 0) > initialCount); () => (documents.VisibleDockables?.Count ?? 0) > initialCount);
var secondMeta = await vm.DockWorkspace.WaitForMetadataTabAsync(); var secondMeta = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("method-table-second-metadata-tab");
ReferenceEquals(secondMeta, firstMeta).Should().BeFalse( ReferenceEquals(secondMeta, firstMeta).Should().BeFalse(
"a fresh metadata tab must be created — the previous one keeps its TypeDef state"); "a fresh metadata tab must be created — the previous one keeps its TypeDef state");
} }
@ -1370,6 +1383,7 @@ public class AssemblyTreeTests
// SelectedItems still holds all of them — so assert on the grid, not the model. // SelectedItems still holds all of them — so assert on the grid, not the model.
int gridAfterFirst = grid.SelectedItems.Count; int gridAfterFirst = grid.SelectedItems.Count;
int modelAfterFirst = vm.AssemblyTreeModel.SelectedItems.Count; int modelAfterFirst = vm.AssemblyTreeModel.SelectedItems.Count;
TestCapture.Step("after-first-ctrl-a");
// Second press. // Second press.
window.KeyPress(Key.A, RawInputModifiers.Control, PhysicalKey.A, keySymbol: "a"); window.KeyPress(Key.A, RawInputModifiers.Control, PhysicalKey.A, keySymbol: "a");
@ -1405,6 +1419,7 @@ public class AssemblyTreeTests
window.KeyPress(Key.A, RawInputModifiers.Control, PhysicalKey.A, keySymbol: "a"); window.KeyPress(Key.A, RawInputModifiers.Control, PhysicalKey.A, keySymbol: "a");
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
await Waiters.WaitForAsync(() => grid.SelectedItems.Count == topLevelCount); await Waiters.WaitForAsync(() => grid.SelectedItems.Count == topLevelCount);
TestCapture.Step("all-rows-selected");
// Act — plain left-click on the second visible row. // Act — plain left-click on the second visible row.
var targetRow = grid.GetVisualDescendants().OfType<DataGridRow>() var targetRow = grid.GetVisualDescendants().OfType<DataGridRow>()
@ -1419,6 +1434,7 @@ public class AssemblyTreeTests
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
await Task.Delay(50); await Task.Delay(50);
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
TestCapture.Step("plain-click-collapsed-selection");
// Assert — selection collapsed to exactly the clicked row, in both grid and model. // Assert — selection collapsed to exactly the clicked row, in both grid and model.
grid.SelectedItems.Count.Should().Be(1, grid.SelectedItems.Count.Should().Be(1,

7
ILSpy.Tests/AssemblyList/ReloadAssemblyContextMenuTests.cs

@ -108,23 +108,24 @@ public class ReloadAssemblyContextMenuTests
var originalAssembly = node.LoadedAssembly; var originalAssembly = node.LoadedAssembly;
var fileName = originalAssembly.FileName; var fileName = originalAssembly.FileName;
window.CaptureForReview(1, "initial-tree"); window.Capture("initial-tree");
// Act — select the row, build the live context menu, click Reload. // Act — select the row, build the live context menu, click Reload.
vm.AssemblyTreeModel.SelectNode(node); vm.AssemblyTreeModel.SelectNode(node);
window.CaptureForReview(2, "system-linq-selected"); window.Capture("system-linq-selected");
var menu = pane.BuildContextMenuForCurrentState(registry.Entries); var menu = pane.BuildContextMenuForCurrentState(registry.Entries);
menu.Should().NotBeNull(); menu.Should().NotBeNull();
var reloadItem = menu!.Items.OfType<MenuItem>() var reloadItem = menu!.Items.OfType<MenuItem>()
.Single(i => (string?)i.Header == Resources._Reload); .Single(i => (string?)i.Header == Resources._Reload);
reloadItem.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); reloadItem.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
window.Capture("reload-clicked");
// Assert — the LoadedAssembly for that file name is a different instance now. // Assert — the LoadedAssembly for that file name is a different instance now.
await Waiters.WaitForAsync(() => await Waiters.WaitForAsync(() =>
vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Any(a => vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Any(a =>
a.FileName == fileName && !ReferenceEquals(a, originalAssembly))); a.FileName == fileName && !ReferenceEquals(a, originalAssembly)));
window.CaptureForReview(3, "after-reload-same-file-fresh-instance"); window.Capture("after-reload-same-file-fresh-instance");
} }
} }

9
ILSpy.Tests/AssemblyList/RemoveAssemblyContextMenuTests.cs

@ -112,18 +112,19 @@ public class RemoveAssemblyContextMenuTests
var survivorName = typeof(object).Assembly.GetName().Name!; var survivorName = typeof(object).Assembly.GetName().Name!;
var sacrificial = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(sacrificialName); var sacrificial = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(sacrificialName);
window.CaptureForReview(1, "initial-tree-three-assemblies"); window.Capture("initial-tree-three-assemblies");
// Act — select the row, build the menu the way the live Opening event would, find // Act — select the row, build the menu the way the live Opening event would, find
// the Remove item, and click it. // the Remove item, and click it.
vm.AssemblyTreeModel.SelectNode(sacrificial); vm.AssemblyTreeModel.SelectNode(sacrificial);
window.CaptureForReview(2, "sacrificial-selected"); window.Capture("sacrificial-selected");
var menu = pane.BuildContextMenuForCurrentState(registry.Entries); var menu = pane.BuildContextMenuForCurrentState(registry.Entries);
menu.Should().NotBeNull(); menu.Should().NotBeNull();
var removeItem = menu!.Items.OfType<MenuItem>() var removeItem = menu!.Items.OfType<MenuItem>()
.Single(i => (string?)i.Header == Resources._Remove); .Single(i => (string?)i.Header == Resources._Remove);
removeItem.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); removeItem.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
window.Capture("remove-clicked");
// Assert — the assembly is gone from the list; the survivor is still there. // Assert — the assembly is gone from the list; the survivor is still there.
await Waiters.WaitForAsync(() => await Waiters.WaitForAsync(() =>
@ -132,7 +133,7 @@ public class RemoveAssemblyContextMenuTests
vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() vm.AssemblyTreeModel.AssemblyList!.GetAssemblies()
.Should().Contain(a => a.ShortName == survivorName); .Should().Contain(a => a.ShortName == survivorName);
window.CaptureForReview(3, "after-remove-sacrificial-gone"); window.Capture("after-remove-sacrificial-gone");
} }
[AvaloniaTest] [AvaloniaTest]
@ -149,11 +150,13 @@ public class RemoveAssemblyContextMenuTests
var pane = await window.WaitForComponent<AssemblyListPane>(); var pane = await window.WaitForComponent<AssemblyListPane>();
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
vm.AssemblyTreeModel.SelectNode(assemblyNode); vm.AssemblyTreeModel.SelectNode(assemblyNode);
window.Capture("system-linq-selected");
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>(); var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
// Act — drive the same build path the live Opening event uses. // Act — drive the same build path the live Opening event uses.
var built = pane.BuildContextMenuForCurrentState(registry.Entries); var built = pane.BuildContextMenuForCurrentState(registry.Entries);
window.Capture("context-menu-built");
// Assert — the live menu contains a "Remove" item (resolved to its localized form by // Assert — the live menu contains a "Remove" item (resolved to its localized form by
// the menu builder). // the menu builder).

2
ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs

@ -55,6 +55,7 @@ public class UseNestedNamespaceNodesGridVerification
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.IsExpanded = true; assemblyNode.IsExpanded = true;
await Waiters.WaitForAsync(() => grid.HierarchicalModel != null); await Waiters.WaitForAsync(() => grid.HierarchicalModel != null);
TestCapture.Step("system-linq-expanded-flat");
try try
{ {
@ -66,6 +67,7 @@ public class UseNestedNamespaceNodesGridVerification
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => !ReferenceEquals(grid.HierarchicalModel, modelBefore), () => !ReferenceEquals(grid.HierarchicalModel, modelBefore),
System.TimeSpan.FromSeconds(5)); System.TimeSpan.FromSeconds(5));
TestCapture.Step("nested-namespaces-rebound");
grid.HierarchicalModel.Should().NotBeSameAs(modelBefore, grid.HierarchicalModel.Should().NotBeSameAs(modelBefore,
"toggling UseNestedNamespaceNodes must force AssemblyListPane.BindTree, " "toggling UseNestedNamespaceNodes must force AssemblyListPane.BindTree, "

53
ILSpy.Tests/CaptureContextAttribute.cs

@ -0,0 +1,53 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using NUnit.Framework;
using NUnit.Framework.Interfaces;
namespace ICSharpCode.ILSpy.Tests;
/// <summary>
/// Feeds the running test's fixture and method name to <see cref="TestCapture"/> before each test.
/// <para>
/// Visual-breakpoint captures fire from inside the async test body, often after an <c>await</c>.
/// NUnit's <c>TestContext.CurrentContext</c> is unreliable there -- its execution context does not
/// flow onto every async continuation, so a live lookup can fall back to the ad-hoc context and
/// dump frames from unrelated tests under one colliding filename. <see cref="ITestAction.BeforeTest"/>
/// instead hands us the real <see cref="ITest"/> up front, on the same dispatcher thread the test
/// body runs on, so we record the identity once and the captures read it from a plain static.
/// </para>
/// Kept separate from <c>ResetAppStateAttribute</c> so that attribute stays self-contained for the
/// build sweep -- capture instrumentation must not ride along into older commits.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly)]
public sealed class CaptureContextAttribute : Attribute, ITestAction
{
public ActionTargets Targets => ActionTargets.Test;
public void BeforeTest(ITest test)
{
ArgumentNullException.ThrowIfNull(test);
TestCapture.BeginTest(test.ClassName, test.Name);
}
public void AfterTest(ITest test)
{
}
}

2
ILSpy.Tests/ContextMenus/CopyFullyQualifiedNameTests.cs

@ -68,6 +68,7 @@ public class CopyFullyQualifiedNameTests
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
TestCapture.Step("booted");
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>(); var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
var entry = registry.Entries var entry = registry.Entries
.Single(e => e.Metadata.Header == nameof(Resources.CopyName)) .Single(e => e.Metadata.Header == nameof(Resources.CopyName))
@ -101,6 +102,7 @@ public class CopyFullyQualifiedNameTests
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
TestCapture.Step("booted");
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>(); var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
var entry = (CopyFullyQualifiedNameContextMenuEntry)registry.Entries var entry = (CopyFullyQualifiedNameContextMenuEntry)registry.Entries
.Single(e => e.Metadata.Header == nameof(Resources.CopyName)) .Single(e => e.Metadata.Header == nameof(Resources.CopyName))

3
ILSpy.Tests/ContextMenus/CreateDiagramContextMenuTests.cs

@ -50,6 +50,7 @@ public class CreateDiagramContextMenuTests
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
TestCapture.Step("booted");
var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>().Entries var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>().Entries
.Select(e => e.Value).OfType<CreateDiagramContextMenuEntry>().Single(); .Select(e => e.Value).OfType<CreateDiagramContextMenuEntry>().Single();
@ -67,6 +68,7 @@ public class CreateDiagramContextMenuTests
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
TestCapture.Step("booted");
var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>().Entries var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>().Entries
.Select(e => e.Value).OfType<CreateDiagramContextMenuEntry>().Single(); .Select(e => e.Value).OfType<CreateDiagramContextMenuEntry>().Single();
@ -85,6 +87,7 @@ public class CreateDiagramContextMenuTests
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
TestCapture.Step("booted");
var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>().Entries var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>().Entries
.Select(e => e.Value).OfType<CreateDiagramContextMenuEntry>().Single(); .Select(e => e.Value).OfType<CreateDiagramContextMenuEntry>().Single();

12
ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs

@ -79,11 +79,11 @@ public class DecompileInNewViewTests
var asm = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var asm = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
var header = ICSharpCode.ILSpy.Properties.Resources.DecompileToNewPanel; var header = ICSharpCode.ILSpy.Properties.Resources.DecompileToNewPanel;
window.CaptureForReview(1, "initial-no-selection"); window.Capture("initial-no-selection");
// With an assembly node selected → the menu surfaces "Decompile in new tab". // With an assembly node selected → the menu surfaces "Decompile in new tab".
vm.AssemblyTreeModel.SelectNode(asm); vm.AssemblyTreeModel.SelectNode(asm);
window.CaptureForReview(2, "assembly-selected"); window.Capture("assembly-selected");
var withSelection = pane.BuildContextMenuForCurrentState(registry.Entries); var withSelection = pane.BuildContextMenuForCurrentState(registry.Entries);
withSelection.Should().NotBeNull(); withSelection.Should().NotBeNull();
withSelection!.Items.OfType<MenuItem>().Select(i => (string?)i.Header) withSelection!.Items.OfType<MenuItem>().Select(i => (string?)i.Header)
@ -91,7 +91,7 @@ public class DecompileInNewViewTests
// With no selection → the entry is filtered out of the built menu. // With no selection → the entry is filtered out of the built menu.
vm.AssemblyTreeModel.SelectedItems.Clear(); vm.AssemblyTreeModel.SelectedItems.Clear();
window.CaptureForReview(3, "selection-cleared"); window.Capture("selection-cleared");
var withoutSelection = pane.BuildContextMenuForCurrentState(registry.Entries); var withoutSelection = pane.BuildContextMenuForCurrentState(registry.Entries);
// `Build` returns null when no entry would be visible; otherwise it may still build a // `Build` returns null when no entry would be visible; otherwise it may still build a
// menu (other entries don't gate on selection). Either way, the header must not appear. // menu (other entries don't gate on selection). Either way, the header must not appear.
@ -126,7 +126,7 @@ public class DecompileInNewViewTests
// addition to the existing one and shows the dispatched method". // addition to the existing one and shows the dispatched method".
vm.AssemblyTreeModel.SelectNode(firstMethod); vm.AssemblyTreeModel.SelectNode(firstMethod);
var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
window.CaptureForReview(1, "first-method-decompiled-into-active-tab"); window.Capture("first-method-decompiled-into-active-tab");
var pane = await window.WaitForComponent<AssemblyListPane>(); var pane = await window.WaitForComponent<AssemblyListPane>();
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>(); var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
@ -136,7 +136,7 @@ public class DecompileInNewViewTests
// Select the second method and click the menu entry through the live context-menu path. // Select the second method and click the menu entry through the live context-menu path.
vm.AssemblyTreeModel.SelectNode(secondMethod); vm.AssemblyTreeModel.SelectNode(secondMethod);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
window.CaptureForReview(2, "second-method-replaced-active-tab"); window.Capture("second-method-replaced-active-tab");
var menu = pane.BuildContextMenuForCurrentState(registry.Entries); var menu = pane.BuildContextMenuForCurrentState(registry.Entries);
menu.Should().NotBeNull(); menu.Should().NotBeNull();
@ -154,6 +154,6 @@ public class DecompileInNewViewTests
// of firstTab. (The right-click selection move into secondMethod re-uses firstTab; the // of firstTab. (The right-click selection move into secondMethod re-uses firstTab; the
// menu-click then creates an additional tab. Net effect: +1 dockable.) // menu-click then creates an additional tab. Net effect: +1 dockable.)
documents.VisibleDockables!.Count.Should().Be(initialCount + 1); documents.VisibleDockables!.Count.Should().Be(initialCount + 1);
window.CaptureForReview(3, "after-click-new-tab-spawned"); window.Capture("after-click-new-tab-spawned");
} }
} }

6
ILSpy.Tests/ContextMenus/OpenContainingFolderTests.cs

@ -79,14 +79,15 @@ public class OpenContainingFolderTests
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
var expectedPath = assemblyNode.LoadedAssembly.FileName; var expectedPath = assemblyNode.LoadedAssembly.FileName;
window.CaptureForReview(1, "initial-tree"); window.Capture("initial-tree");
// Selecting the deep TypeTreeNode must still surface "Open Containing Folder" in the // Selecting the deep TypeTreeNode must still surface "Open Containing Folder" in the
// live menu (the entry's IsVisible walks up to the assembly). // live menu (the entry's IsVisible walks up to the assembly).
vm.AssemblyTreeModel.SelectNode(typeNode); vm.AssemblyTreeModel.SelectNode(typeNode);
window.CaptureForReview(2, "enumerable-type-selected-deep-node"); window.Capture("enumerable-type-selected-deep-node");
var menu = pane.BuildContextMenuForCurrentState(registry.Entries); var menu = pane.BuildContextMenuForCurrentState(registry.Entries);
TestCapture.Step("context-menu-built");
menu.Should().NotBeNull(); menu.Should().NotBeNull();
menu!.Items.OfType<MenuItem>().Select(i => (string?)i.Header) menu!.Items.OfType<MenuItem>().Select(i => (string?)i.Header)
.Should().Contain(Resources._OpenContainingFolder); .Should().Contain(Resources._OpenContainingFolder);
@ -112,6 +113,7 @@ public class OpenContainingFolderTests
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
TestCapture.Step("booted");
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>(); var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
var entry = registry.Entries var entry = registry.Entries
.Single(e => e.Metadata.Header == nameof(Resources._OpenContainingFolder)) .Single(e => e.Metadata.Header == nameof(Resources._OpenContainingFolder))

6
ILSpy.Tests/ContextMenus/SearchMsdnTests.cs

@ -69,6 +69,7 @@ public class SearchMsdnTests
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
TestCapture.Step("booted");
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>(); var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
var entry = (SearchMsdnContextMenuEntry)registry.Entries var entry = (SearchMsdnContextMenuEntry)registry.Entries
.Single(e => e.Metadata.Header == nameof(Resources.SearchMSDN)) .Single(e => e.Metadata.Header == nameof(Resources.SearchMSDN))
@ -106,13 +107,14 @@ public class SearchMsdnTests
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
window.CaptureForReview(1, "initial-tree"); window.Capture("initial-tree");
// Selecting the type and building the live menu must surface "Search Microsoft Docs…". // Selecting the type and building the live menu must surface "Search Microsoft Docs…".
vm.AssemblyTreeModel.SelectNode(typeNode); vm.AssemblyTreeModel.SelectNode(typeNode);
window.CaptureForReview(2, "enumerable-type-selected"); window.Capture("enumerable-type-selected");
var menu = pane.BuildContextMenuForCurrentState(registry.Entries); var menu = pane.BuildContextMenuForCurrentState(registry.Entries);
TestCapture.Step("context-menu-built");
menu.Should().NotBeNull(); menu.Should().NotBeNull();
menu!.Items.OfType<MenuItem>().Select(i => (string?)i.Header) menu!.Items.OfType<MenuItem>().Select(i => (string?)i.Header)
.Should().Contain(Resources.SearchMSDN); .Should().Contain(Resources.SearchMSDN);

1
ILSpy.Tests/Docking/LayoutPersistenceTests.cs

@ -186,6 +186,7 @@ public class LayoutPersistenceTests
// being persisted at all. // being persisted at all.
dockWorkspace.OpenNewTab(new object()); dockWorkspace.OpenNewTab(new object());
dockWorkspace.OpenNewTab(new object()); dockWorkspace.OpenNewTab(new object());
TestCapture.Step("three-document-tabs-open");
var sourceDocs = dockWorkspace.Documents!.VisibleDockables! var sourceDocs = dockWorkspace.Documents!.VisibleDockables!
.OfType<ContentTabPage>().Count(); .OfType<ContentTabPage>().Count();
sourceDocs.Should().Be(3, "test must open 3 tabs (MainTab + 2 extras) before saving"); sourceDocs.Should().Be(3, "test must open 3 tabs (MainTab + 2 extras) before saving");

21
ILSpy.Tests/Docking/PreviewTabPromotionTests.cs

@ -48,6 +48,7 @@ public class PreviewTabPromotionTests
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
TestCapture.Step("booted");
var mainTab = ((ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!; var mainTab = ((ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!;
mainTab.IsPreview.Should().BeTrue( mainTab.IsPreview.Should().BeTrue(
"the freshly-created MainTab is the preview slot until the user pins it"); "the freshly-created MainTab is the preview slot until the user pins it");
@ -63,6 +64,7 @@ public class PreviewTabPromotionTests
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.DockWorkspace.OpenNodeInNewTab(typeNode); vm.DockWorkspace.OpenNodeInNewTab(typeNode);
TestCapture.Step("carve-out-tab-opened");
var carveOut = vm.DockWorkspace.Documents!.VisibleDockables! var carveOut = vm.DockWorkspace.Documents!.VisibleDockables!
.OfType<ContentTabPage>() .OfType<ContentTabPage>()
@ -89,6 +91,7 @@ public class PreviewTabPromotionTests
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeNode); vm.AssemblyTreeModel.SelectNode(typeNode);
vm.DockWorkspace.SettleSelection(); vm.DockWorkspace.SettleSelection();
TestCapture.Step("enumerable-selected");
ReferenceEquals(previousMainTab.SourceNode, typeNode).Should().BeTrue( ReferenceEquals(previousMainTab.SourceNode, typeNode).Should().BeTrue(
"baseline: tree selection populated MainTab with the chosen node"); "baseline: tree selection populated MainTab with the chosen node");
@ -96,6 +99,7 @@ public class PreviewTabPromotionTests
// Pin. // Pin.
vm.DockWorkspace.PinCurrentTab(); vm.DockWorkspace.PinCurrentTab();
TestCapture.Step("tab-pinned");
// Tab is now pinned, content preserved. // Tab is now pinned, content preserved.
previousMainTab.IsPreview.Should().BeFalse( previousMainTab.IsPreview.Should().BeFalse(
@ -127,6 +131,7 @@ public class PreviewTabPromotionTests
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeA); vm.AssemblyTreeModel.SelectNode(typeA);
vm.DockWorkspace.SettleSelection(); vm.DockWorkspace.SettleSelection();
TestCapture.Step("type-a-selected");
var pinnedTab = factory.MainTab!; var pinnedTab = factory.MainTab!;
var pinnedContent = pinnedTab.Content; var pinnedContent = pinnedTab.Content;
@ -134,6 +139,7 @@ public class PreviewTabPromotionTests
// no spawn yet (covered by PinCurrentTab_Flips_IsPreview_Without_Spawning_A_New_Tab). // no spawn yet (covered by PinCurrentTab_Flips_IsPreview_Without_Spawning_A_New_Tab).
var tabCountBeforeSelection = vm.DockWorkspace.Documents!.VisibleDockables!.Count; var tabCountBeforeSelection = vm.DockWorkspace.Documents!.VisibleDockables!.Count;
vm.DockWorkspace.PinCurrentTab(); vm.DockWorkspace.PinCurrentTab();
TestCapture.Step("tab-pinned");
factory.MainTab.Should().BeSameAs(pinnedTab, "pin alone keeps the slot"); factory.MainTab.Should().BeSameAs(pinnedTab, "pin alone keeps the slot");
// Phase 3: select a different type — NOW a new preview tab should spawn AND // Phase 3: select a different type — NOW a new preview tab should spawn AND
@ -142,6 +148,7 @@ public class PreviewTabPromotionTests
"System.Private.Uri", "System", "System.Uri"); "System.Private.Uri", "System", "System.Uri");
vm.AssemblyTreeModel.SelectNode(typeB); vm.AssemblyTreeModel.SelectNode(typeB);
vm.DockWorkspace.SettleSelection(); vm.DockWorkspace.SettleSelection();
TestCapture.Step("type-b-opens-new-preview");
// New tab spawned beside the pinned one. // New tab spawned beside the pinned one.
vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBeforeSelection + 1, vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBeforeSelection + 1,
@ -175,6 +182,7 @@ public class PreviewTabPromotionTests
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>() var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, factory.MainTab)); .Single(item => ReferenceEquals(item.DataContext, factory.MainTab));
TestCapture.Step("before-italic-header-check");
mainTabItem.FontStyle.Should().Be(FontStyle.Italic, mainTabItem.FontStyle.Should().Be(FontStyle.Italic,
"the App.axaml Style for DocumentTabStripItem must apply, italicising the tab title via FontStyle inheritance"); "the App.axaml Style for DocumentTabStripItem must apply, italicising the tab title via FontStyle inheritance");
@ -188,6 +196,7 @@ public class PreviewTabPromotionTests
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
await ((MainWindowViewModel)window.DataContext!).AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); await ((MainWindowViewModel)window.DataContext!).AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
TestCapture.Step("booted");
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Any(), await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Any(),
System.TimeSpan.FromSeconds(10)); System.TimeSpan.FromSeconds(10));
@ -195,6 +204,7 @@ public class PreviewTabPromotionTests
var factory = (ILSpyDockFactory)((MainWindowViewModel)window.DataContext!).DockWorkspace.Factory; var factory = (ILSpyDockFactory)((MainWindowViewModel)window.DataContext!).DockWorkspace.Factory;
var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>() var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, factory.MainTab)); .Single(item => ReferenceEquals(item.DataContext, factory.MainTab));
TestCapture.Step("before-accent-stripe-check");
((object?)mainTabItem.BorderBrush).Should().NotBeNull( ((object?)mainTabItem.BorderBrush).Should().NotBeNull(
"the preview MainTab must carry the accent BorderBrush"); "the preview MainTab must carry the accent BorderBrush");
@ -217,6 +227,7 @@ public class PreviewTabPromotionTests
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.DockWorkspace.OpenNodeInNewTab(typeNode); vm.DockWorkspace.OpenNodeInNewTab(typeNode);
TestCapture.Step("carve-out-tab-opened");
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Count() >= 2, await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Count() >= 2,
System.TimeSpan.FromSeconds(10)); System.TimeSpan.FromSeconds(10));
@ -288,6 +299,7 @@ public class PreviewTabPromotionTests
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.DockWorkspace.OpenNodeInNewTab(typeNode); vm.DockWorkspace.OpenNodeInNewTab(typeNode);
TestCapture.Step("carve-out-tab-opened");
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Count() >= 2, await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Count() >= 2,
System.TimeSpan.FromSeconds(10)); System.TimeSpan.FromSeconds(10));
@ -298,6 +310,7 @@ public class PreviewTabPromotionTests
mainTab.Title = "This.Is.An.Extremely.Long.Tab.Title.That.Would.Normally.Overflow<Foo, Bar, Baz>"; mainTab.Title = "This.Is.An.Extremely.Long.Tab.Title.That.Would.Normally.Overflow<Foo, Bar, Baz>";
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs(); global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();
TestCapture.Step("long-title-applied");
var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>() var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, mainTab)); .Single(item => ReferenceEquals(item.DataContext, mainTab));
// Constrain the tab to 200px (mimics production where the document strip shares // Constrain the tab to 200px (mimics production where the document strip shares
@ -373,6 +386,7 @@ public class PreviewTabPromotionTests
// Simulate a user click on the pin button. // Simulate a user click on the pin button.
pinButton.RaiseEvent(new global::Avalonia.Interactivity.RoutedEventArgs( pinButton.RaiseEvent(new global::Avalonia.Interactivity.RoutedEventArgs(
global::Avalonia.Controls.Button.ClickEvent)); global::Avalonia.Controls.Button.ClickEvent));
TestCapture.Step("pin-button-clicked");
// New semantics: clicking pin flips IsPreview, doesn't spawn a new tab. // New semantics: clicking pin flips IsPreview, doesn't spawn a new tab.
previousMainTab.IsPreview.Should().BeFalse( previousMainTab.IsPreview.Should().BeFalse(
@ -392,6 +406,7 @@ public class PreviewTabPromotionTests
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.DockWorkspace.OpenNodeInNewTab(typeNode); vm.DockWorkspace.OpenNodeInNewTab(typeNode);
TestCapture.Step("carve-out-tab-opened");
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Count() >= 2, await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Count() >= 2,
System.TimeSpan.FromSeconds(10)); System.TimeSpan.FromSeconds(10));
@ -424,6 +439,7 @@ public class PreviewTabPromotionTests
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.DockWorkspace.OpenNodeInNewTab(typeNode); vm.DockWorkspace.OpenNodeInNewTab(typeNode);
TestCapture.Step("carve-out-tab-opened");
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Any(), await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Any(),
System.TimeSpan.FromSeconds(10)); System.TimeSpan.FromSeconds(10));
@ -450,12 +466,14 @@ public class PreviewTabPromotionTests
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
TestCapture.Step("booted");
// Manually flip the current MainTab to pinned, simulating the post-pin state. // Manually flip the current MainTab to pinned, simulating the post-pin state.
factory.MainTab!.IsPreview = false; factory.MainTab!.IsPreview = false;
var before = factory.MainTab; var before = factory.MainTab;
vm.DockWorkspace.PinCurrentTab(); vm.DockWorkspace.PinCurrentTab();
TestCapture.Step("pin-noop");
factory.MainTab.Should().BeSameAs(before, factory.MainTab.Should().BeSameAs(before,
"with no preview MainTab to flip, PinCurrentTab must leave the factory state untouched"); "with no preview MainTab to flip, PinCurrentTab must leave the factory state untouched");
@ -496,6 +514,7 @@ public class PreviewTabPromotionTests
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.DockWorkspace.OpenNodeInNewTab(typeNode); vm.DockWorkspace.OpenNodeInNewTab(typeNode);
TestCapture.Step("carve-out-tab-opened");
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Count() >= 2, await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Count() >= 2,
System.TimeSpan.FromSeconds(10)); System.TimeSpan.FromSeconds(10));
@ -540,6 +559,7 @@ public class PreviewTabPromotionTests
.OfType<ContentTabPage>() .OfType<ContentTabPage>()
.Last(t => t.SourceNode == typeA); .Last(t => t.SourceNode == typeA);
factory.SetActiveDockable(carveOut); factory.SetActiveDockable(carveOut);
TestCapture.Step("carve-out-active");
carveOut.IsPreview.Should().BeFalse("baseline: carve-out tab is frozen"); carveOut.IsPreview.Should().BeFalse("baseline: carve-out tab is frozen");
ReferenceEquals(vm.DockWorkspace.Documents.ActiveDockable, carveOut).Should().BeTrue( ReferenceEquals(vm.DockWorkspace.Documents.ActiveDockable, carveOut).Should().BeTrue(
"baseline: the carve-out is the active document"); "baseline: the carve-out is the active document");
@ -552,6 +572,7 @@ public class PreviewTabPromotionTests
"System.Private.Uri", "System", "System.Uri"); "System.Private.Uri", "System", "System.Uri");
vm.AssemblyTreeModel.SelectNode(typeB); vm.AssemblyTreeModel.SelectNode(typeB);
vm.DockWorkspace.SettleSelection(); vm.DockWorkspace.SettleSelection();
TestCapture.Step("type-b-opens-new-preview");
vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBefore + 1, vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBefore + 1,
"selecting a tree node while a frozen tab is active must spawn a new preview tab"); "selecting a tree node while a frozen tab is active must spawn a new preview tab");

8
ILSpy.Tests/Docking/SingletonDocumentTabTests.cs

@ -56,18 +56,22 @@ public class SingletonDocumentTabTests
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
var dock = vm.DockWorkspace; var dock = vm.DockWorkspace;
TestCapture.Step("booted");
Invoke(window, nameof(Resources._Options)); Invoke(window, nameof(Resources._Options));
TestCapture.Step("options-opened");
var first = dock.Documents!.VisibleDockables!.OfType<ContentTabPage>() var first = dock.Documents!.VisibleDockables!.OfType<ContentTabPage>()
.Single(t => t.Content is OptionsPageModel); .Single(t => t.Content is OptionsPageModel);
var firstContent = first.Content; var firstContent = first.Content;
dock.Factory.CloseDockable(first); dock.Factory.CloseDockable(first);
Avalonia.Threading.Dispatcher.UIThread.RunJobs(); Avalonia.Threading.Dispatcher.UIThread.RunJobs();
TestCapture.Step("options-closed");
dock.Documents!.VisibleDockables!.OfType<ContentTabPage>() dock.Documents!.VisibleDockables!.OfType<ContentTabPage>()
.Any(t => t.Content is OptionsPageModel).Should().BeFalse("closing must remove the Options tab"); .Any(t => t.Content is OptionsPageModel).Should().BeFalse("closing must remove the Options tab");
Invoke(window, nameof(Resources._Options)); Invoke(window, nameof(Resources._Options));
TestCapture.Step("options-reopened");
var second = dock.Documents!.VisibleDockables!.OfType<ContentTabPage>() var second = dock.Documents!.VisibleDockables!.OfType<ContentTabPage>()
.Single(t => t.Content is OptionsPageModel); .Single(t => t.Content is OptionsPageModel);
@ -84,16 +88,20 @@ public class SingletonDocumentTabTests
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
var dock = vm.DockWorkspace; var dock = vm.DockWorkspace;
TestCapture.Step("booted");
bool IsAbout(ContentTabPage t) => t.Content is DecompilerTabPageModel { Title: var title } && title == Resources.About; bool IsAbout(ContentTabPage t) => t.Content is DecompilerTabPageModel { Title: var title } && title == Resources.About;
Invoke(window, nameof(Resources._About)); Invoke(window, nameof(Resources._About));
TestCapture.Step("about-opened");
var first = dock.Documents!.VisibleDockables!.OfType<ContentTabPage>().Single(IsAbout); var first = dock.Documents!.VisibleDockables!.OfType<ContentTabPage>().Single(IsAbout);
dock.Factory.CloseDockable(first); dock.Factory.CloseDockable(first);
Avalonia.Threading.Dispatcher.UIThread.RunJobs(); Avalonia.Threading.Dispatcher.UIThread.RunJobs();
TestCapture.Step("about-closed");
Invoke(window, nameof(Resources._About)); Invoke(window, nameof(Resources._About));
TestCapture.Step("about-reopened");
var second = dock.Documents!.VisibleDockables!.OfType<ContentTabPage>().Single(IsAbout); var second = dock.Documents!.VisibleDockables!.OfType<ContentTabPage>().Single(IsAbout);
second.Should().BeSameAs(first, second.Should().BeSameAs(first,

3
ILSpy.Tests/MainWindow/MainToolBarLayoutTests.cs

@ -59,6 +59,7 @@ public class MainToolBarLayoutTests
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
TestCapture.Step("booted");
var toolbar = await window.WaitForComponent<MainToolBar>(); var toolbar = await window.WaitForComponent<MainToolBar>();
var combo = toolbar.GetVisualDescendants().OfType<ComboBox>() var combo = toolbar.GetVisualDescendants().OfType<ComboBox>()
@ -107,6 +108,7 @@ public class MainToolBarLayoutTests
var search = AppComposition.Current.GetExport<SearchPaneModel>(); var search = AppComposition.Current.GetExport<SearchPaneModel>();
searchButton!.Command!.CanExecute(null).Should().BeTrue(); searchButton!.Command!.CanExecute(null).Should().BeTrue();
searchButton.Command.Execute(null); searchButton.Command.Execute(null);
TestCapture.Step("search-pane-activated");
search.IsActive.Should().BeTrue( search.IsActive.Should().BeTrue(
"clicking the Show-Search toolbar button must activate the search tool pane"); "clicking the Show-Search toolbar button must activate the search tool pane");
@ -162,6 +164,7 @@ public class MainToolBarLayoutTests
var rootPanel = toolbar.GetVisualDescendants().OfType<StackPanel>() var rootPanel = toolbar.GetVisualDescendants().OfType<StackPanel>()
.Single(s => s.Name == "ToolbarRoot"); .Single(s => s.Name == "ToolbarRoot");
var labels = rootPanel.Children.Select(Label).ToList(); var labels = rootPanel.Children.Select(Label).ToList();
TestCapture.Step("before-toolbar-order-check");
labels.Should().Equal( labels.Should().Equal(
"BackSplitButton", "BackSplitButton",

5
ILSpy.Tests/MainWindow/MainWindowTests.cs

@ -50,6 +50,7 @@ public class MainWindowTests
// Arrange + Act — resolve and show. // Arrange + Act — resolve and show.
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
TestCapture.Step("booted");
// Assert — visible, titled, with the correct DataContext. // Assert — visible, titled, with the correct DataContext.
window.IsVisible.Should().BeTrue(); window.IsVisible.Should().BeTrue();
@ -71,6 +72,7 @@ public class MainWindowTests
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<AssemblyListPane>().Any()); await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<AssemblyListPane>().Any());
var pane = await window.WaitForComponent<AssemblyListPane>(); var pane = await window.WaitForComponent<AssemblyListPane>();
TestCapture.Step("before-assembly-pane-check");
// Assert — visible with positive width and height. // Assert — visible with positive width and height.
pane.IsVisible.Should().BeTrue(); pane.IsVisible.Should().BeTrue();
@ -95,6 +97,7 @@ public class MainWindowTests
.OfType<Button>() .OfType<Button>()
.Any(b => !b.IsEffectivelyEnabled .Any(b => !b.IsEffectivelyEnabled
&& b.GetVisualDescendants().OfType<Image>().Any())); && b.GetVisualDescendants().OfType<Image>().Any()));
TestCapture.Step("before-disabled-icon-check");
// Assert — every disabled toolbar icon is the grayscale-aware variant, not a plain Image. // Assert — every disabled toolbar icon is the grayscale-aware variant, not a plain Image.
var disabledIcons = window.GetVisualDescendants() var disabledIcons = window.GetVisualDescendants()
@ -124,6 +127,7 @@ public class MainWindowTests
var openButton = window.GetVisualDescendants() var openButton = window.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => (string?)b.Tag == nameof(ICSharpCode.ILSpy.Properties.Resources.Open)); .Single(b => (string?)b.Tag == nameof(ICSharpCode.ILSpy.Properties.Resources.Open));
TestCapture.Step("before-open-button-check");
// Assert — Command is wired and CanExecute is true. // Assert — Command is wired and CanExecute is true.
openButton.Command.Should().NotBeNull(); openButton.Command.Should().NotBeNull();
@ -148,6 +152,7 @@ public class MainWindowTests
var node = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var node = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
vm.AssemblyTreeModel.SelectNode(node); vm.AssemblyTreeModel.SelectNode(node);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("linq-decompiled");
service.StateChanged -= Observe; service.StateChanged -= Observe;

2
ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs

@ -52,10 +52,12 @@ public class WindowMenuOpenDocumentsTests
var firstAsm = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var firstAsm = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
vm.AssemblyTreeModel.SelectNode(firstAsm); vm.AssemblyTreeModel.SelectNode(firstAsm);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("first-assembly-decompiled");
var secondAsm = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Private.Uri"); var secondAsm = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Private.Uri");
vm.DockWorkspace.OpenNodeInNewTab(secondAsm); vm.DockWorkspace.OpenNodeInNewTab(secondAsm);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("second-assembly-in-new-tab");
var nativeMenu = NativeMenu.GetMenu(window) var nativeMenu = NativeMenu.GetMenu(window)
?? throw new InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window"); ?? throw new InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window");

2
ILSpy.Tests/Metadata/DebugDirectoryChildNodesTests.cs

@ -98,6 +98,7 @@ public class DebugDirectoryChildNodesTests
.GetChild<MetadataTreeNode>() .GetChild<MetadataTreeNode>()
.GetChild<DebugDirectoryTreeNode>(); .GetChild<DebugDirectoryTreeNode>();
debugDir.EnsureLazyChildren(); debugDir.EnsureLazyChildren();
TestCapture.Step("debug-directory-children");
return debugDir; return debugDir;
} }
@ -113,6 +114,7 @@ public class DebugDirectoryChildNodesTests
.GetChild<MetadataTreeNode>() .GetChild<MetadataTreeNode>()
.GetChild<DebugDirectoryTreeNode>(); .GetChild<DebugDirectoryTreeNode>();
debugDir.EnsureLazyChildren(); debugDir.EnsureLazyChildren();
TestCapture.Step("debug-directory-children");
return (debugDir, rawCount); return (debugDir, rawCount);
} }
} }

4
ILSpy.Tests/Metadata/EmbeddedPdbTreeTests.cs

@ -54,6 +54,7 @@ public class EmbeddedPdbTreeTests
// the test SDK builds with portable + sidecar PDB. // the test SDK builds with portable + sidecar PDB.
var testDllPath = typeof(ICSharpCode.Decompiler.Metadata.MetadataFile).Assembly.Location; var testDllPath = typeof(ICSharpCode.Decompiler.Metadata.MetadataFile).Assembly.Location;
var loaded = await vm.OpenAssemblyAsync(testDllPath); var loaded = await vm.OpenAssemblyAsync(testDllPath);
TestCapture.Step("opened-decompiler-dll");
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(loaded.ShortName); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(loaded.ShortName);
// AssemblyTreeNode now surfaces two MetadataTreeNode children for assemblies with an // AssemblyTreeNode now surfaces two MetadataTreeNode children for assemblies with an
@ -63,6 +64,7 @@ public class EmbeddedPdbTreeTests
var debugDirectoryNode = metadataNode.GetChild<DebugDirectoryTreeNode>(); var debugDirectoryNode = metadataNode.GetChild<DebugDirectoryTreeNode>();
debugDirectoryNode.EnsureLazyChildren(); debugDirectoryNode.EnsureLazyChildren();
TestCapture.Step("debug-directory-expanded");
// The embedded-PDB sub-tree is a MetadataTreeNode whose own children include a // The embedded-PDB sub-tree is a MetadataTreeNode whose own children include a
// MetadataTablesTreeNode listing the debug-only tables. // MetadataTablesTreeNode listing the debug-only tables.
@ -89,9 +91,11 @@ public class EmbeddedPdbTreeTests
var testDllPath = typeof(ICSharpCode.Decompiler.Metadata.MetadataFile).Assembly.Location; var testDllPath = typeof(ICSharpCode.Decompiler.Metadata.MetadataFile).Assembly.Location;
var loaded = await vm.OpenAssemblyAsync(testDllPath); var loaded = await vm.OpenAssemblyAsync(testDllPath);
TestCapture.Step("opened-decompiler-dll");
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(loaded.ShortName); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(loaded.ShortName);
assemblyNode.EnsureLazyChildren(); assemblyNode.EnsureLazyChildren();
TestCapture.Step("assembly-node-expanded");
var topLevelMetadataNodes = assemblyNode.Children.OfType<MetadataTreeNode>().ToList(); var topLevelMetadataNodes = assemblyNode.Children.OfType<MetadataTreeNode>().ToList();
topLevelMetadataNodes.Should().HaveCount(2, topLevelMetadataNodes.Should().HaveCount(2,

4
ILSpy.Tests/Metadata/HeapTreeTests.cs

@ -45,6 +45,7 @@ public class HeapTreeTests
var metadataNode = vm.AssemblyTreeModel.FindCoreLib().GetChild<MetadataTreeNode>(); var metadataNode = vm.AssemblyTreeModel.FindCoreLib().GetChild<MetadataTreeNode>();
metadataNode.EnsureLazyChildren(); metadataNode.EnsureLazyChildren();
TestCapture.Step("metadata-node-expanded");
metadataNode.Children.OfType<StringHeapTreeNode>().Should().ContainSingle(); metadataNode.Children.OfType<StringHeapTreeNode>().Should().ContainSingle();
metadataNode.Children.OfType<UserStringHeapTreeNode>().Should().ContainSingle(); metadataNode.Children.OfType<UserStringHeapTreeNode>().Should().ContainSingle();
@ -67,6 +68,7 @@ public class HeapTreeTests
vm.AssemblyTreeModel.SelectNode(heapNode); vm.AssemblyTreeModel.SelectNode(heapNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("string-heap-grid");
tab.Title.Should().Be("String Heap"); tab.Title.Should().Be("String Heap");
tab.Columns.Select(c => c.Tag).Should().Equal("Offset", "Length", "Value"); tab.Columns.Select(c => c.Tag).Should().Equal("Offset", "Length", "Value");
@ -85,6 +87,7 @@ public class HeapTreeTests
vm.AssemblyTreeModel.SelectNode(heapNode); vm.AssemblyTreeModel.SelectNode(heapNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("guid-heap-grid");
tab.Title.Should().Be("Guid Heap"); tab.Title.Should().Be("Guid Heap");
tab.Columns.Select(c => c.Tag).Should().Equal("Index", "Length", "Value"); tab.Columns.Select(c => c.Tag).Should().Equal("Index", "Length", "Value");
@ -104,6 +107,7 @@ public class HeapTreeTests
vm.AssemblyTreeModel.SelectNode(heapNode); vm.AssemblyTreeModel.SelectNode(heapNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("blob-heap-grid");
tab.Title.Should().Be("Blob Heap"); tab.Title.Should().Be("Blob Heap");
tab.Columns.Select(c => c.Tag).Should().Equal("Offset", "Length", "Value"); tab.Columns.Select(c => c.Tag).Should().Equal("Offset", "Length", "Value");

1
ILSpy.Tests/Metadata/HideEmptyMetadataTablesTests.cs

@ -91,6 +91,7 @@ public class HideEmptyMetadataTablesTests
.GetChild<MetadataTreeNode>() .GetChild<MetadataTreeNode>()
.GetChild<MetadataTablesTreeNode>(); .GetChild<MetadataTablesTreeNode>();
tables.EnsureLazyChildren(); tables.EnsureLazyChildren();
TestCapture.Step("tables-tree-expanded");
metadata = assemblyNode.LoadedAssembly.GetMetadataFileOrNull()!.Metadata; metadata = assemblyNode.LoadedAssembly.GetMetadataFileOrNull()!.Metadata;
return tables; return tables;
} }

2
ILSpy.Tests/Metadata/HideEmptyMetadataTablesUiBindingTests.cs

@ -54,9 +54,11 @@ public class HideEmptyMetadataTablesUiBindingTests
// Disable via the same property the checkbox writes to. // Disable via the same property the checkbox writes to.
settings.DisplaySettings.HideEmptyMetadataTables = false; settings.DisplaySettings.HideEmptyMetadataTables = false;
var withAll = CountVisibleTables(vm, coreLibName); var withAll = CountVisibleTables(vm, coreLibName);
TestCapture.Step("hide-empty-off");
settings.DisplaySettings.HideEmptyMetadataTables = true; settings.DisplaySettings.HideEmptyMetadataTables = true;
var withoutEmpty = CountVisibleTables(vm, coreLibName); var withoutEmpty = CountVisibleTables(vm, coreLibName);
TestCapture.Step("hide-empty-on");
withAll.Should().BeGreaterThan(withoutEmpty, withAll.Should().BeGreaterThan(withoutEmpty,
"flipping the Display-Settings flag must cause additional empty tables to surface in the tree"); "flipping the Display-Settings flag must cause additional empty tables to surface in the tree");

2
ILSpy.Tests/Metadata/MetadataDisablesLanguageSwitchingTests.cs

@ -106,6 +106,7 @@ public class MetadataDisablesLanguageSwitchingTests
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => vm.DockWorkspace.ActiveContentTabPage?.Content is MetadataTablePageModel, () => vm.DockWorkspace.ActiveContentTabPage?.Content is MetadataTablePageModel,
System.TimeSpan.FromSeconds(10)); System.TimeSpan.FromSeconds(10));
TestCapture.Step("metadata-tab-active");
languageCombo.IsEnabled.Should().BeFalse( languageCombo.IsEnabled.Should().BeFalse(
"with a MetadataTablePageModel as the active content, the LanguageComboBox must disable"); "with a MetadataTablePageModel as the active content, the LanguageComboBox must disable");
@ -115,6 +116,7 @@ public class MetadataDisablesLanguageSwitchingTests
coreLibName, "System", "System.Object"); coreLibName, "System", "System.Object");
vm.AssemblyTreeModel.SelectNode(typeNode); vm.AssemblyTreeModel.SelectNode(typeNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync(System.TimeSpan.FromSeconds(30)); await vm.DockWorkspace.WaitForDecompiledTextAsync(System.TimeSpan.FromSeconds(30));
TestCapture.Step("decompiler-tab-active");
languageCombo.IsEnabled.Should().BeTrue( languageCombo.IsEnabled.Should().BeTrue(
"swapping back to a decompiler tab must re-enable the LanguageComboBox"); "swapping back to a decompiler tab must re-enable the LanguageComboBox");

3
ILSpy.Tests/Metadata/MetadataFilterTests.cs

@ -302,6 +302,7 @@ public class MetadataFilterTests
vm.AssemblyTreeModel.SelectNode(typeDefNode); vm.AssemblyTreeModel.SelectNode(typeDefNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("typedef-grid");
// The header layout swaps in the per-column TextBox only on hover or when the // The header layout swaps in the per-column TextBox only on hover or when the
// filter is active (the label and the input share the same slot so the header // filter is active (the label and the input share the same slot so the header
@ -342,6 +343,7 @@ public class MetadataFilterTests
// (TextProperty AvaloniaObject change → builder's PropertyChanged handler → // (TextProperty AvaloniaObject change → builder's PropertyChanged handler →
// ColumnFilter.Text → page.ColumnFilterChanged → view.Refresh()). // ColumnFilter.Text → page.ColumnFilterChanged → view.Refresh()).
headerBox!.Text = "System"; headerBox!.Text = "System";
TestCapture.Step("filtered-by-system");
nameFilter.Text.Should().Be("System", nameFilter.Text.Should().Be("System",
"setting the rendered TextBox.Text must propagate to ColumnFilter.Text"); "setting the rendered TextBox.Text must propagate to ColumnFilter.Text");
@ -353,6 +355,7 @@ public class MetadataFilterTests
// Clearing through the rendered TextBox must restore every row. // Clearing through the rendered TextBox must restore every row.
headerBox.Text = ""; headerBox.Text = "";
TestCapture.Step("filter-cleared");
nameFilter.Text.Should().BeEmpty(); nameFilter.Text.Should().BeEmpty();
view.Count.Should().Be(totalRows, view.Count.Should().Be(totalRows,
"clearing the filter must restore every row in the visible grid view"); "clearing the filter must restore every row in the visible grid view");

1
ILSpy.Tests/Metadata/MetadataNodeIconParityTests.cs

@ -47,6 +47,7 @@ public class MetadataNodeIconParityTests
var metadataNode = vm.AssemblyTreeModel.FindCoreLib().GetChild<MetadataTreeNode>(); var metadataNode = vm.AssemblyTreeModel.FindCoreLib().GetChild<MetadataTreeNode>();
metadataNode.EnsureLazyChildren(); metadataNode.EnsureLazyChildren();
TestCapture.Step("metadata-node-expanded");
var children = metadataNode.Children; var children = metadataNode.Children;
// Assert — root + PE header nodes. // Assert — root + PE header nodes.

1
ILSpy.Tests/Metadata/MetadataProtocolHandlerDrillDownTests.cs

@ -122,6 +122,7 @@ public class MetadataProtocolHandlerDrillDownTests
// Drill-down needs the children realised; EnsureLazyChildren cascades only one level. // Drill-down needs the children realised; EnsureLazyChildren cascades only one level.
var tablesNode = metadataNode.GetChild<MetadataTablesTreeNode>(); var tablesNode = metadataNode.GetChild<MetadataTablesTreeNode>();
tablesNode.EnsureLazyChildren(); tablesNode.EnsureLazyChildren();
TestCapture.Step("tables-tree-expanded");
return metadataNode; return metadataNode;
} }

4
ILSpy.Tests/Metadata/MetadataRowActivationTests.cs

@ -56,11 +56,13 @@ public class MetadataRowActivationTests
vm.AssemblyTreeModel.SelectNode(typeDefNode); vm.AssemblyTreeModel.SelectNode(typeDefNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("typedef-grid");
var objectRow = tab.Items.Cast<TypeDefTableTreeNode.TypeDefEntry>() var objectRow = tab.Items.Cast<TypeDefTableTreeNode.TypeDefEntry>()
.First(e => e.Name == "Object" && e.Namespace == "System"); .First(e => e.Name == "Object" && e.Namespace == "System");
tab.RaiseRowActivated(objectRow); tab.RaiseRowActivated(objectRow);
TestCapture.Step("activated-object-row");
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => vm.AssemblyTreeModel.SelectedItem is TypeTreeNode tn () => vm.AssemblyTreeModel.SelectedItem is TypeTreeNode tn
@ -86,6 +88,7 @@ public class MetadataRowActivationTests
vm.AssemblyTreeModel.SelectNode(typeDefNode); vm.AssemblyTreeModel.SelectNode(typeDefNode);
var metadataTab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var metadataTab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("typedef-grid");
var objectRow = metadataTab.Items.Cast<TypeDefTableTreeNode.TypeDefEntry>() var objectRow = metadataTab.Items.Cast<TypeDefTableTreeNode.TypeDefEntry>()
.First(e => e.Name == "Object" && e.Namespace == "System"); .First(e => e.Name == "Object" && e.Namespace == "System");
@ -97,6 +100,7 @@ public class MetadataRowActivationTests
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => (documents.VisibleDockables?.Count ?? 0) > initialCount); () => (documents.VisibleDockables?.Count ?? 0) > initialCount);
var decompiledTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var decompiledTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("new-decompiler-tab");
decompiledTab.Text.Should().Contain("System.Object"); decompiledTab.Text.Should().Contain("System.Object");
} }

3
ILSpy.Tests/Metadata/MetadataTabSessionRestoreTests.cs

@ -51,6 +51,7 @@ public class MetadataTabSessionRestoreTests
.GetChild<TypeDefTableTreeNode>(); .GetChild<TypeDefTableTreeNode>();
vm.AssemblyTreeModel.SelectNode(typeDefNode); vm.AssemblyTreeModel.SelectNode(typeDefNode);
TestCapture.Step("selected-typedef-table");
var settings = AppComposition.Current.GetExport<SettingsService>().SessionSettings; var settings = AppComposition.Current.GetExport<SettingsService>().SessionSettings;
var savedPath = settings.ActiveTreeViewPath; var savedPath = settings.ActiveTreeViewPath;
@ -76,6 +77,7 @@ public class MetadataTabSessionRestoreTests
var testDllPath = typeof(ICSharpCode.Decompiler.Metadata.MetadataFile).Assembly.Location; var testDllPath = typeof(ICSharpCode.Decompiler.Metadata.MetadataFile).Assembly.Location;
var loaded = await vm.OpenAssemblyAsync(testDllPath); var loaded = await vm.OpenAssemblyAsync(testDllPath);
TestCapture.Step("opened-decompiler-dll");
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(loaded.ShortName); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(loaded.ShortName);
// AssemblyTreeNode surfaces the embedded PDB's metadata as a second top-level // AssemblyTreeNode surfaces the embedded PDB's metadata as a second top-level
@ -89,6 +91,7 @@ public class MetadataTabSessionRestoreTests
.GetChild<global::ILSpy.Metadata.DebugTables.DocumentTableTreeNode>(); .GetChild<global::ILSpy.Metadata.DebugTables.DocumentTableTreeNode>();
vm.AssemblyTreeModel.SelectNode(documentTable); vm.AssemblyTreeModel.SelectNode(documentTable);
TestCapture.Step("selected-document-table");
var settings = AppComposition.Current.GetExport<SettingsService>().SessionSettings; var settings = AppComposition.Current.GetExport<SettingsService>().SessionSettings;
var savedPath = settings.ActiveTreeViewPath; var savedPath = settings.ActiveTreeViewPath;

3
ILSpy.Tests/Metadata/MetadataTablesTreeTests.cs

@ -45,6 +45,7 @@ public class MetadataTablesTreeTests
var metadataNode = vm.AssemblyTreeModel.FindCoreLib().GetChild<MetadataTreeNode>(); var metadataNode = vm.AssemblyTreeModel.FindCoreLib().GetChild<MetadataTreeNode>();
metadataNode.EnsureLazyChildren(); metadataNode.EnsureLazyChildren();
TestCapture.Step("metadata-node-expanded");
var tablesNode = metadataNode.Children.OfType<MetadataTablesTreeNode>().Single(); var tablesNode = metadataNode.Children.OfType<MetadataTablesTreeNode>().Single();
var children = metadataNode.Children.ToList(); var children = metadataNode.Children.ToList();
@ -64,6 +65,7 @@ public class MetadataTablesTreeTests
.GetChild<MetadataTreeNode>() .GetChild<MetadataTreeNode>()
.GetChild<MetadataTablesTreeNode>(); .GetChild<MetadataTablesTreeNode>();
tablesNode.EnsureLazyChildren(); tablesNode.EnsureLazyChildren();
TestCapture.Step("tables-tree-expanded");
var tableChildren = tablesNode.Children.OfType<MetadataTableTreeNode>().ToList(); var tableChildren = tablesNode.Children.OfType<MetadataTableTreeNode>().ToList();
// CoreLib has well over 10 non-empty tables (Module, TypeRef, TypeDef, Field, ...). // CoreLib has well over 10 non-empty tables (Module, TypeRef, TypeDef, Field, ...).
@ -85,6 +87,7 @@ public class MetadataTablesTreeTests
vm.AssemblyTreeModel.SelectNode(tablesNode); vm.AssemblyTreeModel.SelectNode(tablesNode);
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("tables-summary-text");
tab.Text.Should().Contain("Tables"); tab.Text.Should().Contain("Tables");
tab.Text.Should().MatchRegex(@"\bTypeDef\s*[:|]\s*\d+"); tab.Text.Should().MatchRegex(@"\bTypeDef\s*[:|]\s*\d+");

4
ILSpy.Tests/Metadata/MetadataTokenNavigationTests.cs

@ -52,6 +52,7 @@ public class MetadataTokenNavigationTests
vm.AssemblyTreeModel.SelectNode(typeDefNode); vm.AssemblyTreeModel.SelectNode(typeDefNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("typedef-grid");
// Pick a row whose BaseType is non-zero (skip <Module>) and resolve the expected // Pick a row whose BaseType is non-zero (skip <Module>) and resolve the expected
// target table from the handle's runtime kind. // target table from the handle's runtime kind.
@ -65,6 +66,7 @@ public class MetadataTokenNavigationTests
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => vm.AssemblyTreeModel.SelectedItem is MetadataTableTreeNode m () => vm.AssemblyTreeModel.SelectedItem is MetadataTableTreeNode m
&& m.Kind == expectedTableIndex); && m.Kind == expectedTableIndex);
TestCapture.Step("navigated-to-token-table");
// Assert — host swapped the tree selection + Content to the table matching the // Assert — host swapped the tree selection + Content to the table matching the
// handle's kind. The actual scroll-into-view runs through MetadataTablePage's // handle's kind. The actual scroll-into-view runs through MetadataTablePage's
@ -94,6 +96,7 @@ public class MetadataTokenNavigationTests
vm.AssemblyTreeModel.SelectNode(typeDefNode); vm.AssemblyTreeModel.SelectNode(typeDefNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("typedef-grid");
var rowWithBase = tab.Items.Cast<TypeDefTableTreeNode.TypeDefEntry>() var rowWithBase = tab.Items.Cast<TypeDefTableTreeNode.TypeDefEntry>()
.First(e => e.BaseType != 0); .First(e => e.BaseType != 0);
@ -114,6 +117,7 @@ public class MetadataTokenNavigationTests
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => vm.AssemblyTreeModel.SelectedItem is MetadataTableTreeNode m () => vm.AssemblyTreeModel.SelectedItem is MetadataTableTreeNode m
&& m.Kind == expectedTableIndex); && m.Kind == expectedTableIndex);
TestCapture.Step("navigated-to-token-table");
} }
} }

2
ILSpy.Tests/Metadata/MetadataTreeShapeTests.cs

@ -49,6 +49,7 @@ public class MetadataTreeShapeTests
// Assert — exactly one MetadataTreeNode child and it precedes the References folder. // Assert — exactly one MetadataTreeNode child and it precedes the References folder.
var metadataNode = assemblyNode.GetChild<MetadataTreeNode>(); var metadataNode = assemblyNode.GetChild<MetadataTreeNode>();
var children = assemblyNode.Children.ToList(); var children = assemblyNode.Children.ToList();
TestCapture.Step("corelib-children");
children.IndexOf(metadataNode).Should().BeLessThan( children.IndexOf(metadataNode).Should().BeLessThan(
children.IndexOf(assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single()), children.IndexOf(assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single()),
"Metadata folder should sit above References, mirroring WPF AssemblyTreeNode ordering"); "Metadata folder should sit above References, mirroring WPF AssemblyTreeNode ordering");
@ -71,6 +72,7 @@ public class MetadataTreeShapeTests
// SelectedItem), wait for the decompile to finish. // SelectedItem), wait for the decompile to finish.
vm.AssemblyTreeModel.SelectNode(metadataNode); vm.AssemblyTreeModel.SelectNode(metadataNode);
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("metadata-summary-text");
// Assert — text contains the metadata kind line and at least one table row count. // Assert — text contains the metadata kind line and at least one table row count.
tab.Text.Should().Contain("MetadataKind:"); tab.Text.Should().Contain("MetadataKind:");

14
ILSpy.Tests/Metadata/PEHeaderTreeTests.cs

@ -44,6 +44,7 @@ public class PEHeaderTreeTests
var metadataNode = vm.AssemblyTreeModel.FindCoreLib().GetChild<MetadataTreeNode>(); var metadataNode = vm.AssemblyTreeModel.FindCoreLib().GetChild<MetadataTreeNode>();
metadataNode.EnsureLazyChildren(); metadataNode.EnsureLazyChildren();
TestCapture.Step("metadata-node-expanded");
metadataNode.Children.OfType<DosHeaderTreeNode>().Should().ContainSingle(); metadataNode.Children.OfType<DosHeaderTreeNode>().Should().ContainSingle();
metadataNode.Children.OfType<CoffHeaderTreeNode>().Should().ContainSingle(); metadataNode.Children.OfType<CoffHeaderTreeNode>().Should().ContainSingle();
@ -76,6 +77,7 @@ public class PEHeaderTreeTests
vm.AssemblyTreeModel.SelectNode(dosNode); vm.AssemblyTreeModel.SelectNode(dosNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("dos-header-grid");
tab.Title.Should().Be("DOS Header"); tab.Title.Should().Be("DOS Header");
tab.Items.Should().HaveCount(31); tab.Items.Should().HaveCount(31);
@ -108,14 +110,17 @@ public class PEHeaderTreeTests
// AssemblyDef metadata via the decompiler text path). Decompiler tab should be active. // AssemblyDef metadata via the decompiler text path). Decompiler tab should be active.
vm.AssemblyTreeModel.SelectNode(assemblyNode); vm.AssemblyTreeModel.SelectNode(assemblyNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("decompiler-tab");
// Step 2 — pick the DOS-header metadata node. Metadata tab takes the active slot. // Step 2 — pick the DOS-header metadata node. Metadata tab takes the active slot.
vm.AssemblyTreeModel.SelectNode(dosNode); vm.AssemblyTreeModel.SelectNode(dosNode);
await vm.DockWorkspace.WaitForMetadataTabAsync(); await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("dos-header-grid");
// Step 3 — back to the entity node. The decompiler tab must come back into view. // Step 3 — back to the entity node. The decompiler tab must come back into view.
vm.AssemblyTreeModel.SelectNode(assemblyNode); vm.AssemblyTreeModel.SelectNode(assemblyNode);
var decompilerTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var decompilerTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("decompiler-tab-restored");
decompilerTab.Should().NotBeNull(); decompilerTab.Should().NotBeNull();
var mainTab = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!; var mainTab = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!;
@ -139,9 +144,11 @@ public class PEHeaderTreeTests
vm.AssemblyTreeModel.SelectNode(dosNode); vm.AssemblyTreeModel.SelectNode(dosNode);
await vm.DockWorkspace.WaitForMetadataTabAsync(); await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("dos-header-grid");
vm.AssemblyTreeModel.SelectNode(assemblyNode); vm.AssemblyTreeModel.SelectNode(assemblyNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("decompiler-tab");
var mainTab = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!; var mainTab = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!;
mainTab.Content.Should().BeOfType<global::ILSpy.TextView.DecompilerTabPageModel>(); mainTab.Content.Should().BeOfType<global::ILSpy.TextView.DecompilerTabPageModel>();
@ -169,16 +176,19 @@ public class PEHeaderTreeTests
vm.AssemblyTreeModel.SelectNode(dosNode); vm.AssemblyTreeModel.SelectNode(dosNode);
await vm.DockWorkspace.WaitForMetadataTabAsync(); await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("dos-header-grid");
documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab); documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab);
mainTab.Content.Should().BeOfType<global::ILSpy.ViewModels.MetadataTablePageModel>(); mainTab.Content.Should().BeOfType<global::ILSpy.ViewModels.MetadataTablePageModel>();
vm.AssemblyTreeModel.SelectNode(assemblyNode); vm.AssemblyTreeModel.SelectNode(assemblyNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("decompiler-tab");
documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab); documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab);
mainTab.Content.Should().BeOfType<global::ILSpy.TextView.DecompilerTabPageModel>(); mainTab.Content.Should().BeOfType<global::ILSpy.TextView.DecompilerTabPageModel>();
vm.AssemblyTreeModel.SelectNode(coffNode); vm.AssemblyTreeModel.SelectNode(coffNode);
await vm.DockWorkspace.WaitForMetadataTabAsync(); await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("coff-header-grid");
documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab); documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab);
mainTab.Content.Should().BeOfType<global::ILSpy.ViewModels.MetadataTablePageModel>(); mainTab.Content.Should().BeOfType<global::ILSpy.ViewModels.MetadataTablePageModel>();
@ -205,15 +215,18 @@ public class PEHeaderTreeTests
vm.AssemblyTreeModel.SelectNode(dosNode); vm.AssemblyTreeModel.SelectNode(dosNode);
var firstTab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var firstTab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("dos-header-grid");
firstTab.Title.Should().Be("DOS Header"); firstTab.Title.Should().Be("DOS Header");
vm.AssemblyTreeModel.SelectNode(coffNode); vm.AssemblyTreeModel.SelectNode(coffNode);
var secondTab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var secondTab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("coff-header-grid");
secondTab.Should().BeSameAs(firstTab, "metadata clicks reuse the existing grid tab"); secondTab.Should().BeSameAs(firstTab, "metadata clicks reuse the existing grid tab");
secondTab.Title.Should().Be("COFF Header"); secondTab.Title.Should().Be("COFF Header");
vm.AssemblyTreeModel.SelectNode(dosNode); vm.AssemblyTreeModel.SelectNode(dosNode);
var thirdTab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var thirdTab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("dos-header-grid-again");
thirdTab.Should().BeSameAs(firstTab); thirdTab.Should().BeSameAs(firstTab);
thirdTab.Title.Should().Be("DOS Header"); thirdTab.Title.Should().Be("DOS Header");
@ -232,6 +245,7 @@ public class PEHeaderTreeTests
vm.AssemblyTreeModel.SelectNode(coffNode); vm.AssemblyTreeModel.SelectNode(coffNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("coff-header-grid");
tab.Title.Should().Be("COFF Header"); tab.Title.Should().Be("COFF Header");
var members = tab.Items.Cast<Entry>().Select(e => e.Member).ToList(); var members = tab.Items.Cast<Entry>().Select(e => e.Member).ToList();

1
ILSpy.Tests/Metadata/ShowInMetadataContextMenuTests.cs

@ -72,6 +72,7 @@ public class ShowInMetadataContextMenuTests
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => vm.AssemblyTreeModel.SelectedItem is MetadataTableTreeNode m () => vm.AssemblyTreeModel.SelectedItem is MetadataTableTreeNode m
&& m.Kind == expectedTable); && m.Kind == expectedTable);
TestCapture.Step("show-in-metadata-table");
((MetadataTableTreeNode)vm.AssemblyTreeModel.SelectedItem!).Kind.Should().Be(expectedTable); ((MetadataTableTreeNode)vm.AssemblyTreeModel.SelectedItem!).Kind.Should().Be(expectedTable);
} }

5
ILSpy.Tests/Metadata/TypedMetadataTableTreeTests.cs

@ -55,6 +55,7 @@ public class TypedMetadataTableTreeTests
vm.AssemblyTreeModel.SelectNode(assemblyRefNode); vm.AssemblyTreeModel.SelectNode(assemblyRefNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("assemblyref-grid");
tab.Title.Should().StartWith("AssemblyRef"); tab.Title.Should().StartWith("AssemblyRef");
tab.Items.Should().HaveCount(assemblyRefNode.RowCount); tab.Items.Should().HaveCount(assemblyRefNode.RowCount);
@ -79,6 +80,7 @@ public class TypedMetadataTableTreeTests
vm.AssemblyTreeModel.SelectNode(typeDefNode); vm.AssemblyTreeModel.SelectNode(typeDefNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("typedef-grid");
tab.Title.Should().StartWith("TypeDef"); tab.Title.Should().StartWith("TypeDef");
tab.Items.Cast<TypeDefTableTreeNode.TypeDefEntry>() tab.Items.Cast<TypeDefTableTreeNode.TypeDefEntry>()
@ -97,6 +99,7 @@ public class TypedMetadataTableTreeTests
.GetChild<MetadataTreeNode>() .GetChild<MetadataTreeNode>()
.GetChild<MetadataTablesTreeNode>(); .GetChild<MetadataTablesTreeNode>();
tablesNode.EnsureLazyChildren(); tablesNode.EnsureLazyChildren();
TestCapture.Step("tables-tree-expanded");
tablesNode.Children.OfType<FieldTableTreeNode>().Should().ContainSingle(); tablesNode.Children.OfType<FieldTableTreeNode>().Should().ContainSingle();
tablesNode.Children.OfType<MethodTableTreeNode>().Should().ContainSingle(); tablesNode.Children.OfType<MethodTableTreeNode>().Should().ContainSingle();
@ -108,6 +111,7 @@ public class TypedMetadataTableTreeTests
var methodNode = tablesNode.Children.OfType<MethodTableTreeNode>().Single(); var methodNode = tablesNode.Children.OfType<MethodTableTreeNode>().Single();
vm.AssemblyTreeModel.SelectNode(methodNode); vm.AssemblyTreeModel.SelectNode(methodNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("methoddef-grid");
tab.Title.Should().StartWith("MethodDef"); tab.Title.Should().StartWith("MethodDef");
tab.Columns.Select(c => c.Tag).Should().Contain("RID"); tab.Columns.Select(c => c.Tag).Should().Contain("RID");
} }
@ -126,6 +130,7 @@ public class TypedMetadataTableTreeTests
vm.AssemblyTreeModel.SelectNode(moduleNode); vm.AssemblyTreeModel.SelectNode(moduleNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("module-grid");
tab.Title.Should().StartWith("Module"); tab.Title.Should().StartWith("Module");
tab.Items.Should().HaveCount(1); tab.Items.Should().HaveCount(1);

3
ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs

@ -104,13 +104,16 @@ public class BrowseBackForwardCommandTests
// Build history: select two methods with a delay so they record as separate entries. // Build history: select two methods with a delay so they record as separate entries.
vm.AssemblyTreeModel.SelectNode(firstMethod); vm.AssemblyTreeModel.SelectNode(firstMethod);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("first-method-decompiled");
await Task.Delay(600); await Task.Delay(600);
vm.AssemblyTreeModel.SelectNode(secondMethod); vm.AssemblyTreeModel.SelectNode(secondMethod);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("second-method-decompiled");
// Act — fire the menu command (mirrors clicking View → Back). // Act — fire the menu command (mirrors clicking View → Back).
backItem.Command.CanExecute(null).Should().BeTrue(); backItem.Command.CanExecute(null).Should().BeTrue();
backItem.Command.Execute(null); backItem.Command.Execute(null);
TestCapture.Step("navigated-back");
// Assert — selection rewinds to the first method (NavigateBack walked one step). // Assert — selection rewinds to the first method (NavigateBack walked one step).
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod)); await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod));

8
ILSpy.Tests/Navigation/NavigationTests.cs

@ -57,6 +57,7 @@ public class NavigationTests
vm.AssemblyTreeModel.SelectNode(firstMethod); vm.AssemblyTreeModel.SelectNode(firstMethod);
var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
firstTab.Text.Should().Contain("AsEnumerable"); firstTab.Text.Should().Contain("AsEnumerable");
TestCapture.Step("as-enumerable-decompiled");
// NavigationHistory collapses selections that happen within 0.5s into one entry. Wait // 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. // past that window so the second selection records a real back-history entry.
@ -67,11 +68,13 @@ public class NavigationTests
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, secondMethod)); await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, secondMethod));
var secondTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var secondTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
secondTab.Text.Should().Contain("Empty"); secondTab.Text.Should().Contain("Empty");
TestCapture.Step("empty-decompiled");
// Act 3 — fire NavigateBack. // Act 3 — fire NavigateBack.
vm.DockWorkspace.NavigateBackCommand.CanExecute(null).Should().BeTrue(); vm.DockWorkspace.NavigateBackCommand.CanExecute(null).Should().BeTrue();
// execute vm.DockWorkspace.NavigateBackCommand // execute vm.DockWorkspace.NavigateBackCommand
vm.DockWorkspace.NavigateBackCommand.Execute(null); vm.DockWorkspace.NavigateBackCommand.Execute(null);
TestCapture.Step("navigated-back");
// Assert — selection restores to AsEnumerable, the document re-decompiles to its body, // Assert — selection restores to AsEnumerable, the document re-decompiles to its body,
// and the row is centred back into view. // and the row is centred back into view.
@ -114,6 +117,7 @@ public class NavigationTests
await Task.Delay(600); await Task.Delay(600);
vm.AssemblyTreeModel.SelectNode(methodC); vm.AssemblyTreeModel.SelectNode(methodC);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("range-decompiled");
// Act 2 — open the Back SplitButton's flyout. The Opening handler populates the menu // Act 2 — open the Back SplitButton's flyout. The Opening handler populates the menu
// from the current back history. // from the current back history.
@ -123,6 +127,7 @@ public class NavigationTests
// open flyout on backSplit // open flyout on backSplit
flyout.ShowAt(backSplit); flyout.ShowAt(backSplit);
await Waiters.WaitForAsync(() => flyout.Items.OfType<MenuItem>().Count() >= 2); await Waiters.WaitForAsync(() => flyout.Items.OfType<MenuItem>().Count() >= 2);
TestCapture.Step("back-history-flyout-open");
// Assert 1 — newest-first ordering: index 0 is the immediate previous selection // Assert 1 — newest-first ordering: index 0 is the immediate previous selection
// (methodB), index 1 is the one before that (methodA). Each menu item carries a // (methodB), index 1 is the one before that (methodA). Each menu item carries a
@ -141,6 +146,7 @@ public class NavigationTests
// Act 3 — multi-step jump: clicking methodA pops two entries off the back stack in one go. // Act 3 — multi-step jump: clicking methodA pops two entries off the back stack in one go.
items[1].Command!.Execute(items[1].CommandParameter); items[1].Command!.Execute(items[1].CommandParameter);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, methodA)); await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, methodA));
TestCapture.Step("jumped-to-as-enumerable");
// Assert 2 — the two displaced entries (methodC, methodB) are now on the forward stack, // Assert 2 — the two displaced entries (methodC, methodB) are now on the forward stack,
// so Forward becomes available. // so Forward becomes available.
@ -173,6 +179,7 @@ public class NavigationTests
await Task.Delay(600); await Task.Delay(600);
vm.AssemblyTreeModel.SelectNode(typeNode); vm.AssemblyTreeModel.SelectNode(typeNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("exception-type-decompiled");
// Open the back-history flyout and read the entry header. // Open the back-history flyout and read the entry header.
var backSplit = window.GetVisualDescendants().OfType<SplitButton>() var backSplit = window.GetVisualDescendants().OfType<SplitButton>()
@ -180,6 +187,7 @@ public class NavigationTests
var flyout = (MenuFlyout)backSplit.Flyout!; var flyout = (MenuFlyout)backSplit.Flyout!;
flyout.ShowAt(backSplit); flyout.ShowAt(backSplit);
await Waiters.WaitForAsync(() => flyout.Items.OfType<MenuItem>().Any()); await Waiters.WaitForAsync(() => flyout.Items.OfType<MenuItem>().Any());
TestCapture.Step("back-history-flyout-open");
// Assert — the entry shows the richer "Base Types (System.Exception)" form, NOT the // Assert — the entry shows the richer "Base Types (System.Exception)" form, NOT the
// bare "Base Types" Text. // bare "Base Types" Text.

9
ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs

@ -57,6 +57,7 @@ public class ViewStateRoundTripTests
vm.AssemblyTreeModel.SelectNode(objectNode); vm.AssemblyTreeModel.SelectNode(objectNode);
await dockWorkspace.WaitForDecompiledTextAsync(); await dockWorkspace.WaitForDecompiledTextAsync();
var tab = dockWorkspace.ActiveDecompilerTab!; var tab = dockWorkspace.ActiveDecompilerTab!;
TestCapture.Step("object-decompiled");
// State is pulled from the editor on demand (CaptureViewState) when DockWorkspace records // State is pulled from the editor on demand (CaptureViewState) when DockWorkspace records
// a navigation away -- not pushed per caret/scroll event. Headless has no laid-out editor, // a navigation away -- not pushed per caret/scroll event. Headless has no laid-out editor,
@ -67,6 +68,7 @@ public class ViewStateRoundTripTests
// entry and records B as the new current. // entry and records B as the new current.
vm.AssemblyTreeModel.SelectNode(stringNode); vm.AssemblyTreeModel.SelectNode(stringNode);
await dockWorkspace.WaitForDecompiledTextAsync(); await dockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("string-decompiled");
// Verify the capture: the back stack's most recent entry should be A's, with the pulled // Verify the capture: the back stack's most recent entry should be A's, with the pulled
// caret + scroll values. // caret + scroll values.
@ -85,6 +87,7 @@ public class ViewStateRoundTripTests
// view consume it -- applying caret/scroll to the live editor is the view's job (covered by // view consume it -- applying caret/scroll to the live editor is the view's job (covered by
// the real UI; headless has no rendered viewport). // the real UI; headless has no rendered viewport).
dockWorkspace.NavigateBackCommand.Execute(null); dockWorkspace.NavigateBackCommand.Execute(null);
TestCapture.Step("navigated-back");
tab.PendingViewState.Should().NotBeNull("Back must stash the recorded view state for the editor to apply"); tab.PendingViewState.Should().NotBeNull("Back must stash the recorded view state for the editor to apply");
tab.PendingViewState!.Value.CaretOffset.Should().Be(500, tab.PendingViewState!.Value.CaretOffset.Should().Be(500,
"Back must restore the caret to where the user left it before navigating to B"); "Back must restore the caret to where the user left it before navigating to B");
@ -115,6 +118,7 @@ public class ViewStateRoundTripTests
vm.AssemblyTreeModel.SelectNode(objectNode); vm.AssemblyTreeModel.SelectNode(objectNode);
await dockWorkspace.WaitForDecompiledTextAsync(); await dockWorkspace.WaitForDecompiledTextAsync();
var tab = dockWorkspace.ActiveDecompilerTab!; var tab = dockWorkspace.ActiveDecompilerTab!;
TestCapture.Step("object-decompiled");
// Deterministic foldings snapshot — two expanded regions over a four-folding layout. // Deterministic foldings snapshot — two expanded regions over a four-folding layout.
// Compute via the helper to keep the checksum honest. (The snapshot itself is exercised // Compute via the helper to keep the checksum honest. (The snapshot itself is exercised
@ -131,6 +135,7 @@ public class ViewStateRoundTripTests
// onto the OUTGOING entry on the back stack. // onto the OUTGOING entry on the back stack.
vm.AssemblyTreeModel.SelectNode(stringNode); vm.AssemblyTreeModel.SelectNode(stringNode);
await dockWorkspace.WaitForDecompiledTextAsync(); await dockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("string-decompiled");
// Verify the capture: the back-stack entry for node A carries the snapshot. // Verify the capture: the back-stack entry for node A carries the snapshot.
var captured = dockWorkspace.BackHistory.OfType<TreeNodeEntry>().Last(); var captured = dockWorkspace.BackHistory.OfType<TreeNodeEntry>().Last();
@ -144,6 +149,7 @@ public class ViewStateRoundTripTests
// for the view to consume on the next ApplyDocument. The assignment is synchronous, so we // for the view to consume on the next ApplyDocument. The assignment is synchronous, so we
// can observe it before the await lets the editor consume it. // can observe it before the await lets the editor consume it.
dockWorkspace.NavigateBackCommand.Execute(null); dockWorkspace.NavigateBackCommand.Execute(null);
TestCapture.Step("navigated-back");
tab.PendingViewState.Should().NotBeNull("Back must propagate the captured state to the destination tab"); tab.PendingViewState.Should().NotBeNull("Back must propagate the captured state to the destination tab");
tab.PendingViewState!.Value.Foldings.Should().NotBeNull(); tab.PendingViewState!.Value.Foldings.Should().NotBeNull();
tab.PendingViewState!.Value.Foldings!.Value.Checksum.Should().Be(seeded.Checksum); tab.PendingViewState!.Value.Foldings!.Value.Checksum.Should().Be(seeded.Checksum);
@ -169,14 +175,17 @@ public class ViewStateRoundTripTests
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
vm.AssemblyTreeModel.SelectNode(stringNode); vm.AssemblyTreeModel.SelectNode(stringNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("string-decompiled");
vm.DockWorkspace.NavigateBackCommand.Execute(null); vm.DockWorkspace.NavigateBackCommand.Execute(null);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("navigated-back");
vm.DockWorkspace.NavigateForwardCommand.CanExecute(null).Should().BeTrue( vm.DockWorkspace.NavigateForwardCommand.CanExecute(null).Should().BeTrue(
"Forward must be enabled after Back leaves an entry on the forward stack"); "Forward must be enabled after Back leaves an entry on the forward stack");
vm.DockWorkspace.NavigateForwardCommand.Execute(null); vm.DockWorkspace.NavigateForwardCommand.Execute(null);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("navigated-forward");
// Back through Forward should land on B again. // Back through Forward should land on B again.
ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, stringNode).Should().BeTrue( ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, stringNode).Should().BeTrue(

9
ILSpy.Tests/Options/ApiVisibilityFilterTests.cs

@ -57,6 +57,7 @@ public class ApiVisibilityFilterTests
var (vm, enumerableNode) = await BootAndExpandEnumerableAsync(); var (vm, enumerableNode) = await BootAndExpandEnumerableAsync();
var publicMethod = enumerableNode.Children.OfType<MethodTreeNode>() var publicMethod = enumerableNode.Children.OfType<MethodTreeNode>()
.Single(m => m.MethodDefinition.Name == "Empty"); .Single(m => m.MethodDefinition.Name == "Empty");
TestCapture.Step("before-public-api-checks");
// Act + Assert — Empty<T> is public static. // Act + Assert — Empty<T> is public static.
publicMethod.IsPublicAPI.Should().BeTrue("Enumerable.Empty<T> is public"); publicMethod.IsPublicAPI.Should().BeTrue("Enumerable.Empty<T> is public");
@ -159,6 +160,7 @@ public class ApiVisibilityFilterTests
var stringType = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.String"); var stringType = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.String");
stringType.IsExpanded = true; stringType.IsExpanded = true;
await Waiters.WaitForAsync(() => stringType.Children.OfType<MethodTreeNode>().Any()); await Waiters.WaitForAsync(() => stringType.Children.OfType<MethodTreeNode>().Any());
TestCapture.Step("string-type-expanded");
var settings = AppComposition.Current.GetExport<SettingsService>().SessionSettings.LanguageSettings; var settings = AppComposition.Current.GetExport<SettingsService>().SessionSettings.LanguageSettings;
settings.ShowApiLevel = ApiVisibility.All; settings.ShowApiLevel = ApiVisibility.All;
@ -167,6 +169,7 @@ public class ApiVisibilityFilterTests
// Act — switch to PublicOnly. // Act — switch to PublicOnly.
settings.ShowApiLevel = ApiVisibility.PublicOnly; settings.ShowApiLevel = ApiVisibility.PublicOnly;
var publicCount = CountVisibleMethods(stringType, settings); var publicCount = CountVisibleMethods(stringType, settings);
TestCapture.Step("api-visibility-public-only");
// Assert — strictly fewer methods at PublicOnly than at All (String has internal/private // Assert — strictly fewer methods at PublicOnly than at All (String has internal/private
// helpers we expect to disappear). // helpers we expect to disappear).
@ -199,6 +202,7 @@ public class ApiVisibilityFilterTests
settings.ShowApiLevel = settings.ShowApiLevel == ApiVisibility.PublicOnly settings.ShowApiLevel = settings.ShowApiLevel == ApiVisibility.PublicOnly
? ApiVisibility.All ? ApiVisibility.All
: ApiVisibility.PublicOnly; : ApiVisibility.PublicOnly;
TestCapture.Step("api-visibility-flipped");
// Assert — model reference changed, indicating BindTree ran again with new filter state. // Assert — model reference changed, indicating BindTree ran again with new filter state.
grid.HierarchicalModel.Should().NotBeSameAs(modelBefore, grid.HierarchicalModel.Should().NotBeSameAs(modelBefore,
@ -222,6 +226,7 @@ public class ApiVisibilityFilterTests
var stringType = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.String"); var stringType = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.String");
stringType.IsExpanded = true; stringType.IsExpanded = true;
await Waiters.WaitForAsync(() => stringType.Children.OfType<MethodTreeNode>().Any()); await Waiters.WaitForAsync(() => stringType.Children.OfType<MethodTreeNode>().Any());
TestCapture.Step("string-type-expanded");
var publicMethod = stringType.Children.OfType<MethodTreeNode>().First(m => m.IsPublicAPI); var publicMethod = stringType.Children.OfType<MethodTreeNode>().First(m => m.IsPublicAPI);
var nonPublicMethod = stringType.Children.OfType<MethodTreeNode>().First(m => !m.IsPublicAPI); var nonPublicMethod = stringType.Children.OfType<MethodTreeNode>().First(m => !m.IsPublicAPI);
@ -238,6 +243,7 @@ public class ApiVisibilityFilterTests
vm.AssemblyTreeModel.SelectNode(nonPublicMethod); vm.AssemblyTreeModel.SelectNode(nonPublicMethod);
await Waiters.WaitForAsync(() => FindRowTextBlock(grid, (string)nonPublicMethod.Text!) != null); await Waiters.WaitForAsync(() => FindRowTextBlock(grid, (string)nonPublicMethod.Text!) != null);
var nonPublicLabel = FindRowTextBlock(grid, (string)nonPublicMethod.Text!); var nonPublicLabel = FindRowTextBlock(grid, (string)nonPublicMethod.Text!);
TestCapture.Step("non-public-method-selected");
// Assert — non-public row's TextBlock carries the class; public row does not. // Assert — non-public row's TextBlock carries the class; public row does not.
publicLabel.Should().NotBeNull(); publicLabel.Should().NotBeNull();
@ -267,6 +273,7 @@ public class ApiVisibilityFilterTests
// Arrange — boot. // Arrange — boot.
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
TestCapture.Step("booted");
var toggles = window.GetVisualDescendants().OfType<ToggleButton>() var toggles = window.GetVisualDescendants().OfType<ToggleButton>()
.Where(t => t.Name is "ShowPublicOnlyButton" or "ShowPrivateInternalButton" or "ShowAllButton") .Where(t => t.Name is "ShowPublicOnlyButton" or "ShowPrivateInternalButton" or "ShowAllButton")
.ToDictionary(t => t.Name!); .ToDictionary(t => t.Name!);
@ -281,6 +288,7 @@ public class ApiVisibilityFilterTests
toggles["ShowPrivateInternalButton"].IsChecked.Should().BeTrue("baseline before click"); toggles["ShowPrivateInternalButton"].IsChecked.Should().BeTrue("baseline before click");
toggles["ShowPublicOnlyButton"].IsChecked = true; toggles["ShowPublicOnlyButton"].IsChecked = true;
TestCapture.Step("show-public-only-checked");
settings.ShowApiLevel.Should().Be(ApiVisibility.PublicOnly); settings.ShowApiLevel.Should().Be(ApiVisibility.PublicOnly);
toggles["ShowPrivateInternalButton"].IsChecked.Should().BeFalse( toggles["ShowPrivateInternalButton"].IsChecked.Should().BeFalse(
"flipping ShowPublicOnly must propagate-cancel ShowPrivateInternal via OnPropertyChanged"); "flipping ShowPublicOnly must propagate-cancel ShowPrivateInternal via OnPropertyChanged");
@ -288,6 +296,7 @@ public class ApiVisibilityFilterTests
// Act + Assert — same but flipping the All button. // Act + Assert — same but flipping the All button.
toggles["ShowAllButton"].IsChecked = true; toggles["ShowAllButton"].IsChecked = true;
TestCapture.Step("show-all-checked");
settings.ShowApiLevel.Should().Be(ApiVisibility.All); settings.ShowApiLevel.Should().Be(ApiVisibility.All);
toggles["ShowPublicOnlyButton"].IsChecked.Should().BeFalse(); toggles["ShowPublicOnlyButton"].IsChecked.Should().BeFalse();
toggles["ShowPrivateInternalButton"].IsChecked.Should().BeFalse(); toggles["ShowPrivateInternalButton"].IsChecked.Should().BeFalse();

4
ILSpy.Tests/Options/OptionsPageScrollReachTests.cs

@ -57,11 +57,13 @@ public class OptionsPageScrollReachTests
window.Height = 600; window.Height = 600;
window.Show(); window.Show();
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
TestCapture.Step("booted");
var command = AppComposition.Current.GetExport<MainMenuCommandRegistry>() var command = AppComposition.Current.GetExport<MainMenuCommandRegistry>()
.GetCommand(nameof(Resources._Options)); .GetCommand(nameof(Resources._Options));
command.Execute(null); command.Execute(null);
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
TestCapture.Step("options-opened");
var view = window.GetVisualDescendants().OfType<OptionsPageView>().Single(); var view = window.GetVisualDescendants().OfType<OptionsPageView>().Single();
var model = (OptionsPageModel)((ContentTabPage)((MainWindowViewModel)window.DataContext!) var model = (OptionsPageModel)((ContentTabPage)((MainWindowViewModel)window.DataContext!)
@ -69,6 +71,7 @@ public class OptionsPageScrollReachTests
.OfType<ContentTabPage>().Single(t => t.Content is OptionsPageModel)).Content!; .OfType<ContentTabPage>().Single(t => t.Content is OptionsPageModel)).Content!;
model.SelectedPage = model.Pages.OfType<DisplaySettingsViewModel>().Single(); model.SelectedPage = model.Pages.OfType<DisplaySettingsViewModel>().Single();
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
TestCapture.Step("display-page-selected");
// Each panel now declares its own ScrollViewer (so per-tab scroll offset is // Each panel now declares its own ScrollViewer (so per-tab scroll offset is
// independent), so the visual tree may contain multiple ScrollViewer instances // independent), so the visual tree may contain multiple ScrollViewer instances
@ -77,6 +80,7 @@ public class OptionsPageScrollReachTests
.First(sv => sv.IsEffectivelyVisible && sv.Bounds.Height > 0); .First(sv => sv.IsEffectivelyVisible && sv.Bounds.Height > 0);
scrollViewer.Offset = new Vector(0, scrollViewer.Extent.Height); scrollViewer.Offset = new Vector(0, scrollViewer.Extent.Height);
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
TestCapture.Step("scrolled-to-max");
// The "Sort results by fitness" checkbox sits inside the last HeaderedContentControl // The "Sort results by fitness" checkbox sits inside the last HeaderedContentControl
// of the Display panel. After scrolling to max, its rendered bottom edge in // of the Display panel. After scrolling to max, its rendered bottom edge in

14
ILSpy.Tests/Options/OptionsTabTests.cs

@ -70,6 +70,7 @@ public class OptionsTabTests
.GetCommand(nameof(Resources._Options)); .GetCommand(nameof(Resources._Options));
command.Execute(null); command.Execute(null);
TestCapture.Step("options-tab-opened");
var docs = vm.DockWorkspace.Documents?.VisibleDockables; var docs = vm.DockWorkspace.Documents?.VisibleDockables;
((object?)docs).Should().NotBeNull(); ((object?)docs).Should().NotBeNull();
@ -95,6 +96,7 @@ public class OptionsTabTests
// Open Options and confirm it's the active document. // Open Options and confirm it's the active document.
AppComposition.Current.GetExport<MainMenuCommandRegistry>() AppComposition.Current.GetExport<MainMenuCommandRegistry>()
.GetCommand(nameof(Resources._Options)).Execute(null); .GetCommand(nameof(Resources._Options)).Execute(null);
TestCapture.Step("options-active");
var documents = vm.DockWorkspace.Documents!; var documents = vm.DockWorkspace.Documents!;
var optionsTab = documents.VisibleDockables!.OfType<ContentTabPage>() var optionsTab = documents.VisibleDockables!.OfType<ContentTabPage>()
@ -106,6 +108,7 @@ public class OptionsTabTests
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeNode); vm.AssemblyTreeModel.SelectNode(typeNode);
TestCapture.Step("tree-node-selected");
// MainTab now carries the new content. Without the fix, ActiveDockable still // MainTab now carries the new content. Without the fix, ActiveDockable still
// points at Options and the user sees nothing change. // points at Options and the user sees nothing change.
@ -129,6 +132,7 @@ public class OptionsTabTests
window.Show(); window.Show();
AppComposition.Current.GetExport<MainMenuCommandRegistry>() AppComposition.Current.GetExport<MainMenuCommandRegistry>()
.GetCommand(nameof(Resources._Options)).Execute(null); .GetCommand(nameof(Resources._Options)).Execute(null);
TestCapture.Step("options-opened");
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
var model = (OptionsPageModel)vm.DockWorkspace.Documents!.VisibleDockables! var model = (OptionsPageModel)vm.DockWorkspace.Documents!.VisibleDockables!
@ -153,6 +157,7 @@ public class OptionsTabTests
var command = AppComposition.Current.GetExport<MainMenuCommandRegistry>() var command = AppComposition.Current.GetExport<MainMenuCommandRegistry>()
.GetCommand(nameof(Resources._Options)); .GetCommand(nameof(Resources._Options));
command.Execute(null); command.Execute(null);
TestCapture.Step("options-opened");
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
var model = (OptionsPageModel)vm.DockWorkspace.Documents!.VisibleDockables! var model = (OptionsPageModel)vm.DockWorkspace.Documents!.VisibleDockables!
@ -177,6 +182,7 @@ public class OptionsTabTests
command.Execute(null); command.Execute(null);
command.Execute(null); command.Execute(null);
TestCapture.Step("options-reinvoked");
var optionsTabs = vm.DockWorkspace.Documents!.VisibleDockables! var optionsTabs = vm.DockWorkspace.Documents!.VisibleDockables!
.OfType<ContentTabPage>().Where(t => t.Content is OptionsPageModel).ToList(); .OfType<ContentTabPage>().Where(t => t.Content is OptionsPageModel).ToList();
@ -199,6 +205,7 @@ public class OptionsTabTests
AppComposition.Current.GetExport<MainMenuCommandRegistry>() AppComposition.Current.GetExport<MainMenuCommandRegistry>()
.GetCommand(nameof(Resources._Options)).Execute(null); .GetCommand(nameof(Resources._Options)).Execute(null);
TestCapture.Step("options-opened");
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
var model = (OptionsPageModel)vm.DockWorkspace.Documents!.VisibleDockables! var model = (OptionsPageModel)vm.DockWorkspace.Documents!.VisibleDockables!
@ -210,6 +217,7 @@ public class OptionsTabTests
.SelectMany(g => g.Settings) .SelectMany(g => g.Settings)
.First(s => s.Property.Name == nameof(global::ICSharpCode.Decompiler.DecompilerSettings.UsingDeclarations)); .First(s => s.Property.Name == nameof(global::ICSharpCode.Decompiler.DecompilerSettings.UsingDeclarations));
usingDecl.IsEnabled = !liveBefore; usingDecl.IsEnabled = !liveBefore;
TestCapture.Step("using-declarations-toggled");
// Live service must see the change immediately — no Apply needed. // Live service must see the change immediately — no Apply needed.
settings.DecompilerSettings.UsingDeclarations.Should().Be(!liveBefore); settings.DecompilerSettings.UsingDeclarations.Should().Be(!liveBefore);
@ -234,6 +242,7 @@ public class OptionsTabTests
.OfType<ContentTabPage>().First(t => t.Content is OptionsPageModel).Content!; .OfType<ContentTabPage>().First(t => t.Content is OptionsPageModel).Content!;
var decompilerPage = (DecompilerSettingsViewModel)model.Pages[0]; var decompilerPage = (DecompilerSettingsViewModel)model.Pages[0];
model.SelectedPage = decompilerPage; model.SelectedPage = decompilerPage;
TestCapture.Step("decompiler-page-selected");
// Flip a known-default-true setting to false. // Flip a known-default-true setting to false.
var item = decompilerPage.Settings var item = decompilerPage.Settings
@ -243,8 +252,10 @@ public class OptionsTabTests
item.IsEnabled = !defaultValue; item.IsEnabled = !defaultValue;
// Sanity: precondition flip took effect. // Sanity: precondition flip took effect.
item.IsEnabled.Should().Be(!defaultValue); item.IsEnabled.Should().Be(!defaultValue);
TestCapture.Step("setting-flipped");
model.ResetCurrentPageCommand.Execute(null); model.ResetCurrentPageCommand.Execute(null);
TestCapture.Step("page-reset");
// After reset, the reflection-rebuilt item list has the snapshot's defaults. // After reset, the reflection-rebuilt item list has the snapshot's defaults.
var refreshedItem = decompilerPage.Settings var refreshedItem = decompilerPage.Settings
@ -266,6 +277,7 @@ public class OptionsTabTests
var settings = AppComposition.Current.GetExport<SettingsService>(); var settings = AppComposition.Current.GetExport<SettingsService>();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
TestCapture.Step("booted");
// Materialise a DecompilerTextView by selecting a node. // Materialise a DecompilerTextView by selecting a node.
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
@ -273,12 +285,14 @@ public class OptionsTabTests
vm.AssemblyTreeModel.SelectNode(typeNode); vm.AssemblyTreeModel.SelectNode(typeNode);
var view = await window.WaitForComponent<DecompilerTextView>(); var view = await window.WaitForComponent<DecompilerTextView>();
var editor = await view.WaitForComponent<AvaloniaEdit.TextEditor>(); var editor = await view.WaitForComponent<AvaloniaEdit.TextEditor>();
TestCapture.Step("enumerable-decompiled");
var originalSize = settings.DisplaySettings.SelectedFontSize; var originalSize = settings.DisplaySettings.SelectedFontSize;
var newSize = originalSize + 5; var newSize = originalSize + 5;
// Act — change the live setting; subscriber should write through to Editor.FontSize. // Act — change the live setting; subscriber should write through to Editor.FontSize.
settings.DisplaySettings.SelectedFontSize = newSize; settings.DisplaySettings.SelectedFontSize = newSize;
TestCapture.Step("font-size-increased");
editor.FontSize.Should().Be(newSize); editor.FontSize.Should().Be(newSize);

2
ILSpy.Tests/Search/ScopeSearchToTests.cs

@ -55,6 +55,7 @@ public class ScopeSearchToTests
entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { asm } }) entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { asm } })
.Should().BeTrue("the entry must surface on AssemblyTreeNode selections"); .Should().BeTrue("the entry must surface on AssemblyTreeNode selections");
entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { asm } }); entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { asm } });
TestCapture.Step("scoped-to-assembly");
search.SearchTerm.Should().Contain("inassembly:", search.SearchTerm.Should().Contain("inassembly:",
"Execute must inject the inassembly: prefix into the live search term"); "Execute must inject the inassembly: prefix into the live search term");
@ -78,6 +79,7 @@ public class ScopeSearchToTests
entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { ns } }) entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { ns } })
.Should().BeTrue("the entry must surface on NamespaceTreeNode selections"); .Should().BeTrue("the entry must surface on NamespaceTreeNode selections");
entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { ns } }); entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { ns } });
TestCapture.Step("scoped-to-namespace");
search.SearchTerm.Should().Contain("innamespace:"); search.SearchTerm.Should().Contain("innamespace:");
search.SearchTerm.Should().Contain("System.Linq"); search.SearchTerm.Should().Contain("System.Linq");

2
ILSpy.Tests/Search/SearchInputFocusTests.cs

@ -53,6 +53,7 @@ public class SearchInputFocusTests
search.FocusRequested += () => fired++; search.FocusRequested += () => fired++;
dockWorkspace.ShowSearchCommand.Execute(null); dockWorkspace.ShowSearchCommand.Execute(null);
TestCapture.Step("show-search-executed");
fired.Should().BeGreaterThan(0, fired.Should().BeGreaterThan(0,
"ShowSearchCommand must request focus on the search input, not just activate the pane"); "ShowSearchCommand must request focus on the search input, not just activate the pane");
@ -71,6 +72,7 @@ public class SearchInputFocusTests
// Focus shifts via a Dispatcher.UIThread.Post so the view has a frame for the // Focus shifts via a Dispatcher.UIThread.Post so the view has a frame for the
// pane to surface in the layout — pump the dispatcher before asserting. // pane to surface in the layout — pump the dispatcher before asserting.
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
TestCapture.Step("search-input-focused");
var input = pane.FindControl<TextBox>("SearchInput"); var input = pane.FindControl<TextBox>("SearchInput");
((object?)input).Should().NotBeNull(); ((object?)input).Should().NotBeNull();

2
ILSpy.Tests/Search/SearchPaneAssemblyListChangedTests.cs

@ -68,6 +68,7 @@ public class SearchPaneAssemblyListChangedTests
MessageBus.Send(this, new CurrentAssemblyListChangedEventArgs(args)); MessageBus.Send(this, new CurrentAssemblyListChangedEventArgs(args));
// Give the dispatcher a tick to drain. // Give the dispatcher a tick to drain.
await Waiters.WaitForAsync(() => true, System.TimeSpan.FromMilliseconds(50)); await Waiters.WaitForAsync(() => true, System.TimeSpan.FromMilliseconds(50));
TestCapture.Step("search-restarted-on-user-load");
// If we got here without throwing, the handler ran. The contract this test pins: // If we got here without throwing, the handler ran. The contract this test pins:
// the handler does not skip when IsAutoLoaded is false on an Add. // the handler does not skip when IsAutoLoaded is false on an Add.
@ -93,6 +94,7 @@ public class SearchPaneAssemblyListChangedTests
// (A real regression would manifest as the search-pane endlessly restarting on // (A real regression would manifest as the search-pane endlessly restarting on
// every auto-load event.) // every auto-load event.)
var act = () => MessageBus.Send(this, new CurrentAssemblyListChangedEventArgs(args)); var act = () => MessageBus.Send(this, new CurrentAssemblyListChangedEventArgs(args));
TestCapture.Step("before-autoloaded-add");
act.Should().NotThrow(); act.Should().NotThrow();
} }
} }

6
ILSpy.Tests/Search/SearchPaneStreamingTests.cs

@ -67,6 +67,7 @@ public class SearchPaneStreamingTests
ICSharpCode.ILSpyX.Search.MemberSearchKind.Type); ICSharpCode.ILSpyX.Search.MemberSearchKind.Type);
strategy.Search(module, default); strategy.Search(module, default);
TestCapture.Step("before-strategy-results");
queue.Count.Should().BeGreaterThan(0, queue.Count.Should().BeGreaterThan(0,
"if the strategy can't find Enumerable in the fixture, the orchestrator certainly won't either"); "if the strategy can't find Enumerable in the fixture, the orchestrator certainly won't either");
} }
@ -89,6 +90,7 @@ public class SearchPaneStreamingTests
() => search.Results.Any(r => r.Name.Contains("Enumerable", StringComparison.OrdinalIgnoreCase)), () => search.Results.Any(r => r.Name.Contains("Enumerable", StringComparison.OrdinalIgnoreCase)),
timeout: TimeSpan.FromSeconds(30)); timeout: TimeSpan.FromSeconds(30));
TestCapture.Step("search-results-for-enumerable");
search.Results.Should().NotBeEmpty( search.Results.Should().NotBeEmpty(
"the Type search strategy must have surfaced at least one match within the timeout"); "the Type search strategy must have surfaced at least one match within the timeout");
search.Results.Should().Contain(r => r.Name.Contains("Enumerable", StringComparison.OrdinalIgnoreCase)); search.Results.Should().Contain(r => r.Name.Contains("Enumerable", StringComparison.OrdinalIgnoreCase));
@ -108,9 +110,11 @@ public class SearchPaneStreamingTests
search.SearchTerm = "Enumerable"; search.SearchTerm = "Enumerable";
await Waiters.WaitForAsync(() => search.Results.Count > 0, timeout: TimeSpan.FromSeconds(30)); await Waiters.WaitForAsync(() => search.Results.Count > 0, timeout: TimeSpan.FromSeconds(30));
TestCapture.Step("search-results-populated");
search.SearchTerm = string.Empty; search.SearchTerm = string.Empty;
await Waiters.WaitForAsync(() => search.Results.Count == 0, timeout: TimeSpan.FromSeconds(5)); await Waiters.WaitForAsync(() => search.Results.Count == 0, timeout: TimeSpan.FromSeconds(5));
TestCapture.Step("results-cleared");
search.Results.Should().BeEmpty("an empty search term must clear stale results"); search.Results.Should().BeEmpty("an empty search term must clear stale results");
} }
@ -132,6 +136,7 @@ public class SearchPaneStreamingTests
() => search.Results.Any(r => r.Name.Contains("String", StringComparison.OrdinalIgnoreCase)), () => search.Results.Any(r => r.Name.Contains("String", StringComparison.OrdinalIgnoreCase)),
timeout: TimeSpan.FromSeconds(30)); timeout: TimeSpan.FromSeconds(30));
TestCapture.Step("string-minus-builder-results");
search.Results.Should().NotBeEmpty(); search.Results.Should().NotBeEmpty();
search.Results.Should().OnlyContain( search.Results.Should().OnlyContain(
r => r.Name.Contains("String", StringComparison.OrdinalIgnoreCase) r => r.Name.Contains("String", StringComparison.OrdinalIgnoreCase)
@ -157,6 +162,7 @@ public class SearchPaneStreamingTests
() => search.Results.Any(r => r.Name.Equals("String", StringComparison.OrdinalIgnoreCase)), () => search.Results.Any(r => r.Name.Equals("String", StringComparison.OrdinalIgnoreCase)),
timeout: TimeSpan.FromSeconds(30)); timeout: TimeSpan.FromSeconds(30));
TestCapture.Step("exact-string-results");
search.Results.Should().Contain(r => r.Name.Equals("String", StringComparison.OrdinalIgnoreCase)); search.Results.Should().Contain(r => r.Name.Equals("String", StringComparison.OrdinalIgnoreCase));
search.Results.Should().NotContain( search.Results.Should().NotContain(
r => r.Name.Contains("Builder", StringComparison.OrdinalIgnoreCase), r => r.Name.Contains("Builder", StringComparison.OrdinalIgnoreCase),

5
ILSpy.Tests/Search/SearchPaneViewTests.cs

@ -46,6 +46,7 @@ public class SearchPaneViewTests
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
var pane = await window.WaitForComponent<SearchPane>(); var pane = await window.WaitForComponent<SearchPane>();
TestCapture.Step("booted");
var input = pane.FindControl<TextBox>("SearchInput"); var input = pane.FindControl<TextBox>("SearchInput");
((object?)input).Should().NotBeNull("the query TextBox is what the user types into"); ((object?)input).Should().NotBeNull("the query TextBox is what the user types into");
@ -65,10 +66,12 @@ public class SearchPaneViewTests
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
var pane = await window.WaitForComponent<SearchPane>(); var pane = await window.WaitForComponent<SearchPane>();
TestCapture.Step("booted");
var vm = (SearchPaneModel)pane.DataContext!; var vm = (SearchPaneModel)pane.DataContext!;
var input = pane.FindControl<TextBox>("SearchInput"); var input = pane.FindControl<TextBox>("SearchInput");
input!.Text = "Enumerable"; input!.Text = "Enumerable";
TestCapture.Step("typed-enumerable");
vm.SearchTerm.Should().Be("Enumerable", vm.SearchTerm.Should().Be("Enumerable",
"the TextBox is bound two-way to SearchPaneModel.SearchTerm"); "the TextBox is bound two-way to SearchPaneModel.SearchTerm");
@ -85,6 +88,7 @@ public class SearchPaneViewTests
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
var pane = await window.WaitForComponent<SearchPane>(); var pane = await window.WaitForComponent<SearchPane>();
TestCapture.Step("booted");
var vm = (SearchPaneModel)pane.DataContext!; var vm = (SearchPaneModel)pane.DataContext!;
var input = pane.FindControl<TextBox>("SearchInput"); var input = pane.FindControl<TextBox>("SearchInput");
@ -98,6 +102,7 @@ public class SearchPaneViewTests
RoutedEvent = global::Avalonia.Input.InputElement.KeyDownEvent, RoutedEvent = global::Avalonia.Input.InputElement.KeyDownEvent,
Source = input, Source = input,
}); });
TestCapture.Step("escape-cleared-term");
vm.SearchTerm.Should().BeEmpty("Escape in the search input must clear the term"); vm.SearchTerm.Should().BeEmpty("Escape in the search input must clear the term");
} }

3
ILSpy.Tests/Search/SearchProgressTests.cs

@ -69,6 +69,7 @@ public class SearchProgressTests
"the spinner must light up while the orchestrator is running"); "the spinner must light up while the orchestrator is running");
await Waiters.WaitForAsync(() => !search.IsSearching, timeout: TimeSpan.FromSeconds(30)); await Waiters.WaitForAsync(() => !search.IsSearching, timeout: TimeSpan.FromSeconds(30));
TestCapture.Step("search-completed");
search.IsSearching.Should().BeFalse("the spinner must turn off once the run completes"); search.IsSearching.Should().BeFalse("the spinner must turn off once the run completes");
} }
finally finally
@ -90,6 +91,7 @@ public class SearchProgressTests
await Waiters.WaitForAsync(() => search.Results.Any(), timeout: TimeSpan.FromSeconds(30)); await Waiters.WaitForAsync(() => search.Results.Any(), timeout: TimeSpan.FromSeconds(30));
TestCapture.Step("search-results-with-icons");
var first = search.Results.First(); var first = search.Results.First();
((object?)first.Image).Should().NotBeNull( ((object?)first.Image).Should().NotBeNull(
"every search result needs a glyph in the Name column"); "every search result needs a glyph in the Name column");
@ -105,6 +107,7 @@ public class SearchProgressTests
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
var pane = await window.WaitForComponent<SearchPane>(); var pane = await window.WaitForComponent<SearchPane>();
TestCapture.Step("booted");
var progress = pane.FindControl<ProgressBar>("SearchProgress"); var progress = pane.FindControl<ProgressBar>("SearchProgress");
((object?)progress).Should().NotBeNull( ((object?)progress).Should().NotBeNull(

2
ILSpy.Tests/Search/SearchResultNavigationTests.cs

@ -55,10 +55,12 @@ public class SearchResultNavigationTests
await Waiters.WaitForAsync(() => search.Results.Any(r => r is MemberSearchResult), await Waiters.WaitForAsync(() => search.Results.Any(r => r is MemberSearchResult),
timeout: TimeSpan.FromSeconds(30)); timeout: TimeSpan.FromSeconds(30));
TestCapture.Step("search-results-for-enumerable");
var hit = (MemberSearchResult)search.Results.First(r => r is MemberSearchResult); var hit = (MemberSearchResult)search.Results.First(r => r is MemberSearchResult);
search.Activate(hit); search.Activate(hit);
await Waiters.WaitForAsync(() => vm.AssemblyTreeModel.SelectedItem is TypeTreeNode); await Waiters.WaitForAsync(() => vm.AssemblyTreeModel.SelectedItem is TypeTreeNode);
TestCapture.Step("navigated-to-result");
((object?)vm.AssemblyTreeModel.SelectedItem).Should().NotBeNull( ((object?)vm.AssemblyTreeModel.SelectedItem).Should().NotBeNull(
"Activate must move the assembly-tree selection to the result's underlying entity"); "Activate must move the assembly-tree selection to the result's underlying entity");
(vm.AssemblyTreeModel.SelectedItem is TypeTreeNode).Should().BeTrue( (vm.AssemblyTreeModel.SelectedItem is TypeTreeNode).Should().BeTrue(

1
ILSpy.Tests/Search/SearchResultSortOrderTests.cs

@ -65,6 +65,7 @@ public class SearchResultSortOrderTests
() => !search.IsSearching && search.Results.Count >= 2, () => !search.IsSearching && search.Results.Count >= 2,
timeout: TimeSpan.FromSeconds(30)); timeout: TimeSpan.FromSeconds(30));
TestCapture.Step("search-results-name-sorted");
// Drop assembly/namespace results — they share a unit Fitness with all peers in // Drop assembly/namespace results — they share a unit Fitness with all peers in
// their bucket, so any tie-break is ambiguous between fitness- and name-sort. // their bucket, so any tie-break is ambiguous between fitness- and name-sort.
// Member results are where Fitness varies (1/Name.Length), so their order is // Member results are where Fitness varies (1/Name.Length), so their order is

1
ILSpy.Tests/Search/ShowSearchCommandTests.cs

@ -49,6 +49,7 @@ public class ShowSearchCommandTests
dockWorkspace.ShowSearchCommand.CanExecute(null).Should().BeTrue(); dockWorkspace.ShowSearchCommand.CanExecute(null).Should().BeTrue();
dockWorkspace.ShowSearchCommand.Execute(null); dockWorkspace.ShowSearchCommand.Execute(null);
TestCapture.Step("search-pane-activated");
// The dock factory's ActiveDockable should now be the search pane. We can't // The dock factory's ActiveDockable should now be the search pane. We can't
// assert against the factory's deep state easily (IDockable identity), so check // assert against the factory's deep state easily (IDockable identity), so check

1
ILSpy.Tests/TestAppBuilder.cs

@ -27,6 +27,7 @@ using ICSharpCode.ILSpy.Tests;
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))] [assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
[assembly: AvaloniaTestIsolation(AvaloniaTestIsolationLevel.PerAssembly)] [assembly: AvaloniaTestIsolation(AvaloniaTestIsolationLevel.PerAssembly)]
[assembly: ResetAppState] [assembly: ResetAppState]
[assembly: CaptureContext]
namespace ICSharpCode.ILSpy.Tests; namespace ICSharpCode.ILSpy.Tests;

117
ILSpy.Tests/TestCaptureExtensions.cs

@ -17,47 +17,124 @@
// DEALINGS IN THE SOFTWARE. // DEALINGS IN THE SOFTWARE.
using System; using System;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless; using Avalonia.Headless;
using Avalonia.Threading; using Avalonia.Threading;
using global::ILSpy.AppEnv;
using global::ILSpy.Views;
namespace ICSharpCode.ILSpy.Tests; namespace ICSharpCode.ILSpy.Tests;
/// <summary> /// <summary>
/// Deterministic-path screenshot helpers for the test-review workflow. Unlike /// Visual breakpoints for the headless suite. A <see cref="Step(string)"/> call snapshots the
/// <see cref="WindowExtensions.CaptureAndShow"/> these don't open the OS image viewer — /// live UI and writes it to
/// they write to <c>%TEMP%/ilspy-test-captures/</c> with a filename predictable from /// <c>&lt;captures&gt;/&lt;TestFixtureName&gt;/&lt;TestName&gt;_&lt;NN&gt;_&lt;ShortDescription&gt;.png</c>,
/// the (testName, step, label) triple so the audit workflow can read them back for /// where the fixture and test names come from NUnit's <see cref="TestContext"/> and <c>NN</c> is a
/// in-chat review. The <c>ILSPY_TEST_CAPTURES</c> environment variable overrides the /// per-test step counter that auto-increments (so inserting a breakpoint never renumbers the rest).
/// directory when a specific machine wants captures elsewhere. Only renders when /// <para>
/// <c>ILSPY_TESTS_VISIBLE=1</c> (otherwise the headless platform returns a null frame /// Drop <c>TestCapture.Step("selected-the-row")</c> after each action and before each assertion to
/// and the call is a no-op). /// build a frame-by-frame filmstrip of a test, then flip <c>ILSPY_TESTS_VISIBLE=1</c> to render them
/// and eyeball whether the test exercises what it claims. Without that variable the headless platform
/// returns a null frame and every call is a no-op, so instrumented tests cost nothing in CI.
/// </para>
/// <para>
/// The capture root is <c>%TEMP%/ilspy-test-captures/</c>, overridable via the
/// <c>ILSPY_TEST_CAPTURES</c> environment variable.
/// </para>
/// </summary> /// </summary>
public static class TestCaptureExtensions public static class TestCapture
{ {
static readonly string OutputDirectory = Environment.GetEnvironmentVariable("ILSPY_TEST_CAPTURES") static readonly string OutputDirectory = Environment.GetEnvironmentVariable("ILSPY_TEST_CAPTURES")
?? Path.Combine(Path.GetTempPath(), "ilspy-test-captures"); ?? Path.Combine(Path.GetTempPath(), "ilspy-test-captures");
public static void CaptureForReview( // Rendering is only on when a debugger is attached or ILSPY_TESTS_VISIBLE=1 (see TestAppBuilder).
this Window window, // When it's off, a breakpoint must be a TRUE no-op -- not even Dispatcher.RunJobs() -- so that
int step, // instrumenting a test cannot perturb the timing of navigation/tab state it asserts on.
string label, static readonly bool Enabled = Debugger.IsAttached
[CallerMemberName] string? testName = null) || string.Equals(Environment.GetEnvironmentVariable("ILSPY_TESTS_VISIBLE"), "1", StringComparison.Ordinal);
// The active test's identity, recorded by CaptureContextAttribute.BeforeTest from the real
// ITest. We can't read TestContext.CurrentContext live during the test body: the NUnit
// execution context doesn't reliably flow onto async continuations, so captures after an await
// would otherwise fall back to NUnit's ad-hoc context and collide under one filename.
// The dispatcher thread is single under AvaloniaTestIsolationLevel.PerAssembly, so the suite
// runs tests serially on it -- plain static fields are safe (no cross-test races).
static string fixtureName = "UnknownFixture";
static string testName = "UnknownTest";
static int step;
/// <summary>
/// Records the running test's identity and resets the step counter. Called by
/// <see cref="CaptureContextAttribute"/> before each test, off the real <c>ITest</c>.
/// </summary>
internal static void BeginTest(string? className, string? name)
{
fixtureName = ShortName(className);
testName = Sanitize(name);
step = 0;
}
/// <summary>
/// Snapshots the shared <see cref="MainWindow"/> as the next numbered step of the running test.
/// The window is resolved from the MEF container, so callers that discarded their window handle
/// (e.g. <c>var (_, vm) = await TestHarness.BootAsync()</c>) can still drop a breakpoint.
/// </summary>
public static void Step(string description)
{
if (!Enabled)
return;
var window = TryGetMainWindow();
if (window is not null)
window.Capture(description);
}
/// <summary>
/// Snapshots a specific <paramref name="window"/> (use when the frame of interest is a dialog or
/// secondary window rather than the main one) as the next numbered step of the running test.
/// </summary>
public static void Capture(this Window window, string description)
{ {
ArgumentNullException.ThrowIfNull(window); ArgumentNullException.ThrowIfNull(window);
if (!Enabled)
return;
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
var frame = window.CaptureRenderedFrame(); var frame = window.CaptureRenderedFrame();
if (frame is null) if (frame is null)
return; return; // headless platform isn't rendering (ILSPY_TESTS_VISIBLE != 1) -> no-op.
var directory = Path.Combine(OutputDirectory, fixtureName);
Directory.CreateDirectory(directory);
var fileName = $"{testName}_{step:D2}_{Sanitize(description)}.png";
step++;
frame.Save(Path.Combine(directory, fileName));
}
static Window? TryGetMainWindow()
{
// ApplicationLifetime is null in headless; the [Shared] MainWindow export is the same
// instance the test resolved.
try
{
return AppComposition.Current.GetExport<MainWindow>();
}
catch
{
return null;
}
}
Directory.CreateDirectory(OutputDirectory); static string ShortName(string? className)
var fileName = $"{Sanitize(testName)}-{step:D2}-{Sanitize(label)}.png"; {
var path = Path.Combine(OutputDirectory, fileName); if (string.IsNullOrWhiteSpace(className))
frame.Save(path); return "UnknownFixture";
var lastDot = className.LastIndexOf('.');
var shortName = lastDot >= 0 ? className[(lastDot + 1)..] : className;
return Sanitize(shortName);
} }
static string Sanitize(string? label) static string Sanitize(string? label)

2
ILSpy.Tests/TestHarness.cs

@ -55,6 +55,8 @@ public static class TestHarness
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumAssemblies, timeout); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumAssemblies, timeout);
// First visual breakpoint of every UI test: the booted window with its assembly list loaded.
window.Capture("booted");
return (window, vm); return (window, vm);
} }

Loading…
Cancel
Save